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…

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:
.vnext/ folder: <root>/.vnext/host/<session-id>.jsonl~/.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.
| 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.
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.
claude --plugin-dir /path/to/plugins/vnext-session-record
The mod also works with claude -p. Mods load only in a trusted workspace.
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.
hooks/register.js 397 lines1// 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