SLOPSHOPPER

vnext-session-record

Records the host session's own subagents, per-request model and usage to .vnext/host/<session-id>.jsonl (in the project, or under ~/.vnext/host/), with no…

newpaneguardcommandagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vnext-session-record
│ ┃ vNext ✕ › fix the failing auth test and add an audit log call │ ┃ claude-opus-5-5 97k of 200k $0.42 │ ┃ ⏺ Read(src/auth.ts) │ ┃ No subagents yet. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /vnext │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · vNext
claude-opus-5-5 97k of 200k $0.42 No subagents yet.
README

vnext-session-record

A Claude Code mod that records what the host session does with models, and shows it in a pane. It writes one JSON line per event.

Where the file goes:

  • If the project root already has a .vnext/ folder: <root>/.vnext/host/<session-id>.jsonl
  • In every other folder: ~/.vnext/host/<slug>/<session-id>.jsonl. The slug is the root path with each / and space changed to -. So the mod never adds a .vnext/ folder to an unrelated repository.

The mod only observes. Every hook passes its event on unchanged, and a recording error never stops the session.

What it records

| Row kind | When | Fields | | :- | :- | :- | | start | The session starts | session id, cwd, project root, main model, Claude Code version, interactive or not | | agent.spawn | Claude Code is about to start a subagent (Agent tool) | subagent type, requested model, parent model, background, fork, description (first 120 characters) | | agent.spawn_result | The subagent started | resolved model, agent id | | agent.complete | A subagent's turn ended | agent id, reason, duration | | turn.step | One model request finished, main loop or subagent | requested model, effort, agent id, answering model, token counts, stop reason | | vnext.delegate, vnext.delegate_result | A call to a tool whose name matches mcp__*vnext*__delegate | model_id, role, effort, the child agent id the call returned | | measure | Claude Code measures the session | context fill, rate-limit windows, cost | | end | The session ends | reason, context fill, rate-limit windows, cost |

The mod stores no prompt text. It drops the subagent prompt and the delegate task text.

The mod rewrites the whole file after each row, because the mods API has no append. It stops at 3 MiB and adds one truncated row.

The pane

Type /vnext to open the pane, and type it again to close it. The pane also opens by itself, without focus, the first time a subagent or a vNext delegate call starts. If you close it, it stays closed for the rest of the session.

claude-opus-5-5[1m]  182k of 1M  $4.10

Explore          claude-haiku-4-5-20251001  done    List files in directory
general-purpose  opus                       failed  Check it

vNext
worker  gpt-6-luna high  23:16  03b55785

The first line shows the session model, the context fill and the cost. Next come up to 8 subagents, newest first. The vNext delegate calls follow under their own heading. The word "failed" is the only text in colour.

Load it

claude --plugin-dir /path/to/plugins/vnext-session-record

The mod also works with claude -p. Mods load only in a trusted workspace.

Check it

claude plugin validate plugins/vnext-session-record --strict
claude plugin test plugins/vnext-session-record

Tested with Claude Code 2.1.287.

This mod is separate from the vnext plugin, because the vNext bridge turns that plugin off inside its own Claude workers.

Source 1 files
hooks/register.js 397 lines
1// Records the host session's own subagents, per-request model and usage
2// into a JSONL file, and shows them in the /vnext pane. Every hook observes
3// and passes the event on unchanged. No prompt text is ever stored.
4//
5// Where the file goes: <root>/.vnext/host/<session-id>.jsonl when the project
6// root already has a .vnext folder, else ~/.vnext/host/<slug>/<session-id>.jsonl
7// so that no .vnext folder appears in an unrelated repository.
8
9const LIMIT_BYTES = 3 * 1024 * 1024
10const DESCRIPTION_CHARS = 120
11const PANE_DESCRIPTION_CHARS = 40
12const PANE_SUBAGENTS = 8
13const DELEGATE_TOOL = /^mcp__.*vnext.*__delegate$/
14const PANE = 'vnext-session'
15
16// Rows kept in memory; the whole file is rewritten after each new row,
17// because $.fs has no append and one write may hold at most 4 MiB.
18let rows = []
19let bytes = 0
20let truncated = false
21let filePath = null
22let writing = Promise.resolve()
23
24// What the pane draws, kept in memory so ui.render reads no file
25const view = {
26  model: '',
27  context: null,
28  cost: null,
29  subagents: [], // { toolUseId, agentId, type, model, status, description }
30  delegates: [], // { toolUseId, role, modelId, effort, startedAt, agentId }
31}
32let paneAutoOpened = false
33let paneClosedByUser = false
34
35function reset() {
36  rows = []
37  bytes = 0
38  truncated = false
39  filePath = null
40  view.subagents = []
41  view.delegates = []
42}
43
44function utf8Length(text) {
45  let n = 0
46  for (let i = 0; i < text.length; i++) {
47    const c = text.charCodeAt(i)
48    if (c < 0x80) n += 1
49    else if (c < 0x800) n += 2
50    else if (c >= 0xd800 && c <= 0xdbff) { n += 4; i++ }
51    else n += 3
52  }
53  return n
54}
55
56export function slugOf(root) {
57  return root.replace(/[\/ ]/g, '-')
58}
59
60async function resolvePath($) {
61  if (filePath) return filePath
62  const root = await $.session.root()
63  const id = await $.session.id()
64  if (await $.fs.exists(root + '/.vnext')) {
65    filePath = root + '/.vnext/host/' + id + '.jsonl'
66  } else {
67    const home = await $.env.get('HOME')
68    filePath = home + '/.vnext/host/' + slugOf(root) + '/' + id + '.jsonl'
69  }
70  return filePath
71}
72
73// Adds one row and rewrites the file. Never throws.
74async function record($, row) {
75  try {
76    if (truncated) return
77    const line = JSON.stringify({ ts: new Date().toISOString(), ...row }) + '\n'
78    const size = utf8Length(line)
79    if (bytes + size > LIMIT_BYTES) {
80      truncated = true
81      const last = JSON.stringify({ ts: new Date().toISOString(), kind: 'truncated', limitBytes: LIMIT_BYTES }) + '\n'
82      rows.push(last)
83      bytes += utf8Length(last)
84    } else {
85      rows.push(line)
86      bytes += size
87    }
88    const path = await resolvePath($)
89    const text = rows.join('')
90    // Writes run one after another, each with the whole file
91    writing = writing.then(() => $.fs.write(path, text)).catch(() => {})
92    await writing
93  } catch {
94    // A recording error never breaks the session
95  }
96}
97
98async function recordUsage($, kind, extra) {
99  try {
100    const usage = await $.session.usage()
101    await record($, { kind, ...extra, context: usage.context, rateLimits: usage.rateLimits, cost: usage.cost })
102  } catch {
103    await record($, { kind, ...extra, usageError: true })
104  }
105}
106
107// Reads an agent id from a vNext delegate result, whatever shape it came in
108function delegateAgentId(result) {
109  try {
110    const value = result && result.result
111    const seen = []
112    const visit = (v, depth) => {
113      if (v == null || depth > 4) return
114      if (typeof v === 'string') {
115        try { visit(JSON.parse(v), depth + 1) } catch {}
116        return
117      }
118      if (typeof v !== 'object') return
119      if (typeof v.agent_id === 'string') seen.push(v.agent_id)
120      for (const k of Object.keys(v)) visit(v[k], depth + 1)
121    }
122    visit(value, 0)
123    return seen[0]
124  } catch {
125    return undefined
126  }
127}
128
129// Opens the pane without focus the first time it has something to show,
130// and never again in this session once the user has closed it
131async function autoOpen($) {
132  try {
133    $.ui.invalidate('ui.render')
134    if (paneAutoOpened || paneClosedByUser) return
135    paneAutoOpened = true
136    await $.ui.open({ id: PANE, title: 'vNext' })
137  } catch {}
138}
139
140function tokens(n) {
141  if (n >= 1000000) return +(n / 1000000).toFixed(1) + 'M'
142  if (n >= 1000) return Math.round(n / 1000) + 'k'
143  return String(n)
144}
145
146function cut(text, n) {
147  return text.length > n ? text.slice(0, n - 1) + '…' : text
148}
149
150function two(n) {
151  return String(n).padStart(2, '0')
152}
153
154// The pane's lines as data: each line is a list of [text, isError] parts
155export function paneLines(v) {
156  const header = [v.model]
157  if (v.context && typeof v.context.tokens === 'number' && typeof v.context.window === 'number') {
158    header.push(tokens(v.context.tokens) + ' of ' + tokens(v.context.window))
159  }
160  if (v.cost && typeof v.cost.usd === 'number') header.push('$' + v.cost.usd.toFixed(2))
161  const lines = [[[header.filter(Boolean).join('  '), false]], [[' ', false]]]
162
163  const subs = v.subagents.slice(-PANE_SUBAGENTS).reverse()
164  if (subs.length > 0) {
165    const wType = Math.max(...subs.map((s) => s.type.length))
166    const wModel = Math.max(...subs.map((s) => s.model.length))
167    const wStatus = Math.max(...subs.map((s) => s.status.length))
168    for (const s of subs) {
169      lines.push([
170        [s.type.padEnd(wType) + '  ' + s.model.padEnd(wModel) + '  ', false],
171        [s.status, s.status === 'failed'],
172        [' '.repeat(wStatus - s.status.length) + '  ' + cut(s.description, PANE_DESCRIPTION_CHARS), false],
173      ])
174    }
175  }
176
177  if (v.delegates.length > 0) {
178    if (subs.length > 0) lines.push([[' ', false]])
179    lines.push([['vNext', false]])
180    const cells = v.delegates.map((d) => {
181      const at = new Date(d.startedAt)
182      return [
183        d.role || '',
184        (d.modelId || '') + (d.effort ? ' ' + d.effort : ''),
185        two(at.getHours()) + ':' + two(at.getMinutes()),
186        (d.agentId || '').slice(0, 8),
187      ]
188    })
189    const widths = [0, 1, 2].map((i) => Math.max(...cells.map((c) => c[i].length)))
190    for (const c of cells) {
191      lines.push([[c.map((cell, i) => (i < 3 ? cell.padEnd(widths[i]) : cell)).join('  ').trimEnd(), false]])
192    }
193  }
194
195  if (subs.length === 0 && v.delegates.length === 0) lines.push([['No subagents yet.', false]])
196  return lines
197}
198
199export function register(on) {
200  on('session.start', async ($, e, next) => {
201    try {
202      await $.command.register({ name: 'vnext', description: 'Show or hide the vNext pane', immediate: true })
203    } catch {}
204    let version
205    try { version = (await $.session.version()).version } catch {}
206    try {
207      const model = await $.session.model()
208      view.model = model
209      await record($, {
210        kind: 'start',
211        session: await $.session.id(),
212        cwd: await $.session.cwd(),
213        root: await $.session.root(),
214        model,
215        version,
216        isInteractive: e.isInteractive,
217      })
218    } catch {}
219    return next(e)
220  })
221
222  on('command.run', { command: 'vnext' }, async ($) => {
223    try {
224      const isOpen = (await $.ui.panes()).some((pane) => pane.id === PANE)
225      if (isOpen) {
226        paneClosedByUser = true
227        await $.ui.close({ id: PANE })
228      } else {
229        await $.ui.open({ id: PANE, title: 'vNext', focus: true, closeOnEscape: true })
230      }
231    } catch {}
232    return {}
233  })
234
235  on('ui.close', { id: PANE }, async ($, e, next) => {
236    if (e.origin && e.origin.kind === 'person') paneClosedByUser = true
237    return next(e)
238  })
239
240  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
241    if (e.requestId !== PANE) return next(e)
242    const { Box, Text } = $.ui.resolve(e)
243    return Box({
244      flexDirection: 'column',
245      children: paneLines(view).map((parts) =>
246        parts.length === 1
247          ? Text({ wrap: 'truncate-end', children: [parts[0][0]] })
248          : Text({
249              wrap: 'truncate-end',
250              children: parts.map(([text, isError]) => (isError ? Text({ color: 'error', children: [text] }) : text)),
251            }),
252      ),
253    })
254  })
255
256  on('agent.spawn', async ($, e, next) => {
257    // Written before the subagent starts; the prompt is left out on purpose
258    await record($, {
259      kind: 'agent.spawn',
260      tool_use_id: e.tool_use_id,
261      subagentType: e.subagentType,
262      model: e.model ?? null,
263      parentModel: e.parentModel,
264      parentAgentId: e.parentAgentId,
265      background: e.background,
266      fork: e.fork,
267      name: e.name,
268      description: typeof e.description === 'string' ? e.description.slice(0, DESCRIPTION_CHARS) : undefined,
269      provider: e.provider,
270    })
271    const entry = {
272      toolUseId: e.tool_use_id,
273      agentId: undefined,
274      type: e.subagentType || '',
275      model: e.model || e.parentModel || '',
276      status: 'running',
277      description: typeof e.description === 'string' ? e.description : '',
278    }
279    view.subagents.push(entry)
280    let result
281    try {
282      result = await next(e)
283    } catch (error) {
284      entry.status = 'failed'
285      await autoOpen($)
286      throw error
287    }
288    try {
289      if (result && result.deny) entry.status = 'failed'
290      if (result && result.model) entry.model = result.model
291      if (result && result.agentId) entry.agentId = result.agentId
292      await record($, {
293        kind: 'agent.spawn_result',
294        tool_use_id: e.tool_use_id,
295        model: result && result.model,
296        agentId: result && result.agentId,
297        denied: Boolean(result && result.deny),
298      })
299    } catch {}
300    await autoOpen($)
301    return result
302  })
303
304  on('turn.complete', async ($, e, next) => {
305    try {
306      if (e.agentId) {
307        const entry = view.subagents.find((s) => s.agentId === e.agentId)
308        if (entry) {
309          entry.status = e.reason === 'answer' ? 'done' : 'failed'
310          await record($, { kind: 'agent.complete', agentId: e.agentId, reason: e.reason, durationMs: e.durationMs })
311          $.ui.invalidate('ui.render')
312        }
313      }
314    } catch {}
315    return next(e)
316  })
317
318  on('turn.step', async function* ($, e, next) {
319    const result = yield* next(e)
320    try {
321      const usage = result && result.usage
322      await record($, {
323        kind: 'turn.step',
324        turnId: e.turnId,
325        index: e.index,
326        model: e.model,
327        effort: e.effort,
328        agentId: e.agentId,
329        answeredBy: usage ? usage.model : undefined,
330        usage: usage
331          ? {
332              input_tokens: usage.input_tokens,
333              output_tokens: usage.output_tokens,
334              cache_read_input_tokens: usage.cache_read_input_tokens,
335              cache_creation_input_tokens: usage.cache_creation_input_tokens,
336            }
337          : null,
338        stopReason: result ? result.stopReason : null,
339      })
340    } catch {}
341    return result
342  })
343
344  on('tool.call', { tool: DELEGATE_TOOL }, async ($, e, next) => {
345    await record($, {
346      kind: 'vnext.delegate',
347      tool: e.tool,
348      tool_use_id: e.tool_use_id,
349      agentId: e.agentId,
350      model_id: e.model_id,
351      role: e.role,
352      effort: e.effort,
353    })
354    const entry = {
355      toolUseId: e.tool_use_id,
356      role: typeof e.role === 'string' ? e.role : '',
357      modelId: typeof e.model_id === 'string' ? e.model_id : '',
358      effort: typeof e.effort === 'string' ? e.effort : '',
359      startedAt: Date.now(),
360      agentId: undefined,
361    }
362    view.delegates.push(entry)
363    const result = await next(e)
364    try {
365      entry.agentId = delegateAgentId(result)
366      await record($, {
367        kind: 'vnext.delegate_result',
368        tool_use_id: e.tool_use_id,
369        delegatedAgentId: entry.agentId,
370        denied: Boolean(result && result.deny),
371        isError: Boolean(result && result.isError),
372      })
373    } catch {}
374    await autoOpen($)
375    return result
376  })
377
378  on('session.measure', async ($, e, next) => {
379    try {
380      const usage = await $.session.usage()
381      view.context = usage.context
382      view.cost = usage.cost
383      $.ui.invalidate('ui.render')
384      await record($, { kind: 'measure', changed: e.changed, context: usage.context, rateLimits: usage.rateLimits, cost: usage.cost })
385    } catch {}
386    return next(e)
387  })
388
389  on('session.end', async ($, e, next) => {
390    await recordUsage($, 'end', { reason: e.reason, sessionId: e.sessionId })
391    // /clear and /resume go on in the same process with another session id:
392    // start a new file and an empty pane for it
393    if (e.reason === 'clear' || e.reason === 'resume') reset()
394    return next(e)
395  })
396}
397