Live coding-orchestrator cockpit: relay context zone, subagents in flight, report-card grades and the Jev decision feed.

A Claude Code mod (a plugin of function hooks) that shows the orchestrator's state inside Claude Code.
Band above the prompt
● AMBER 162k/250k ██████░░░░ Jev ✔ 🧠 hunch 5 ⚠1 now: run: Run unit tests (12s) agents: critic 2m [ orch ] [ 🗂 9 ] [ hide ]
now: what the main loop is doing this moment, in plain words (read parse.ts, run: <the command's description>, waiting on critic, thinking, idle, waiting for you), and which subagents run and for how long.relay/config.json soft_tokens / hard_tokens.NoApiKey).⚠N counts the ones that warned or failed.⚠N counts its UNVERIFIED items.🗂 N: how many Claude Code sessions are registered on this machine; it opens the /sessions pane./orch pane
By default the pane is short:
you, main, or the role), ✔ / ✖ / ■ / ▶, a few plain words, and how long it took. Every tool call is a line (read parse.ts, edit register.tsx, run: Run unit tests, Hunch: check rules for lib/a.dart, start critic (fable): Review diff), as are your prompts, each subagent finishing and each turn ending. A line with more behind it (the full shell command, an agent's brief, an edit's old and new text, a long prompt) has a ▸ button that opens it as a drawer below the line; ▾ closes it.details opens the full sections below:
htask_ id, calls per tool, and the last eight calls (mcp__hunch__* tools and the hunch CLI run through Bash, task verify included) with caller, duration, target and outcome: constraint counts, merge verdict, escalations, verify exit code.relay/jev-log.jsonl./sessions pane
One row per registered Claude Code session, from the registry Claude Code keeps in ~/.claude/sessions/<pid>.json: name, repo, idle or busy, and how long since it last changed; this session is marked. A VS Code session has a switch button that runs bin/session-focus.py, which asks the handoff bridge (0.6.0 or later) in the window that owns the session to bring its tab forward; a terminal session has no button. The helper refuses a registry file whose process is gone or whose PID now belongs to another process, so a stale entry does not send the switch to a window that would resume a duplicate of the session. A crashed session's file can linger until its row's switch reports it gone. Switching needs session-focus.py and rollover-open.py in ~/.claude/bin and the bridge installed in every VS Code window.
Toasts when the zone changes, a role report comes back weak (a missing field, confidence below high, or UNVERIFIED items), the Jev risk gate denies an action, Jev flags a weak report, Jev goes offline, a Hunch invariant of warning severity or above first applies (once per session), a Hunch merge verdict is BLOCK, Hunch escalations need your decision, a task verify check fails, a Hunch call errors, or a task is finished without a hunch_context brief.
/orch band hides or shows the band; while it is hidden, a one-line summary sits in the status line.
Copy this folder to ~/.claude/mods/mission-control, then name it in the env block of ~/.claude/settings.json so every session in every repo loads it:
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\<you>\\.claude\\mods\\mission-control"
}
Several plugin folders are separated by the platform's path-list separator (; on Windows, : elsewhere). Open sessions pick it up after a restart; an interactive session then reloads the mod whenever a file in the folder is saved.
claude plugin validate mods/mission-control
claude plugin test mods/mission-control
The engine writes the API types into .claude-plugin/types/ and a tsconfig.json beside them when it loads the mod; both are generated and ignored here.
hooks/register.tsx 996 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type {
5 ActivityEntry,
6 AgentRun,
7 Gauge,
8 HunchCall,
9 HunchConstraint,
10 HunchLevel,
11 HunchLog,
12 JevStatus,
13 ReportCard,
14 SessionRow,
15 Zone,
16} from '../types'
17import {
18 ago,
19 bar,
20 capDetail,
21 clip,
22 clockSeconds,
23 clockTime,
24 describeTool,
25 hunchCli,
26 hunchInvocation,
27 hunchName,
28 hunchOutcome,
29 isRole,
30 jevStatus,
31 kTokens,
32 localDay,
33 maxLevel,
34 parseCard,
35 parseLines,
36 reapStale,
37 repoName,
38 roleOf,
39 seconds,
40 sessionRow,
41 sessionRows,
42 shortModel,
43 STALE_MS,
44 toolDetail,
45 wasInterrupted,
46 zoneOf,
47} from './parse'
48
49const PANE = 'mission-control'
50const SESSIONS_PANE = 'mission-control-sessions'
51const POLL_MS = 5000
52
53const agents = atom({ plugin: 'mission-control', key: 'agents' } as const, [])
54const reports = atom({ plugin: 'mission-control', key: 'reports' } as const, [])
55const gauge = atom({ plugin: 'mission-control', key: 'gauge' } as const, null)
56const jev = atom({ plugin: 'mission-control', key: 'jev' } as const, null)
57const hunch = atom({ plugin: 'mission-control', key: 'hunch' } as const, { calls: [], total: 0, seenConstraints: [] })
58const isBandHidden = atom({ plugin: 'mission-control', key: 'isBandHidden' } as const, false)
59const tick = atom({ plugin: 'mission-control', key: 'tick' } as const, 0)
60const sessions = atom({ plugin: 'mission-control', key: 'sessions' } as const, [])
61const sessionsCheckedAt = atom({ plugin: 'mission-control', key: 'sessionsCheckedAt' } as const, 0)
62const activity = atom({ plugin: 'mission-control', key: 'activity' } as const, [])
63const showDetails = atom({ plugin: 'mission-control', key: 'showDetails' } as const, false)
64const openEntries = atom({ plugin: 'mission-control', key: 'openEntries' } as const, [])
65// Set by a prompt, cleared when the main loop's turn ends: the timeline's prompt line can scroll out mid-turn.
66const turnOpen = atom({ plugin: 'mission-control', key: 'turnOpen' } as const, false)
67
68const DRAWER_LINES = 20
69
70const HUNCH_KEEP = 40
71const ACTIVITY_KEEP = 80
72const TIMELINE_ROWS = 15
73
74const ACTIVITY_MARK: Record<ActivityEntry['status'], string> = { running: '▶', ok: '✔', error: '✖', stopped: '■', note: '·' }
75
76const ACTIVITY_COLOR: Record<ActivityEntry['status'], string | undefined> = {
77 running: 'yellow',
78 ok: 'green',
79 error: 'red',
80 stopped: 'gray',
81 note: undefined,
82}
83
84const ZONE_PLAIN: Record<Zone, string> = {
85 green: 'keep going',
86 amber: 'getting full, hand off soon',
87 red: 'full, hand off now',
88 unknown: 'not measured yet',
89}
90
91const HUNCH_COLOR: Record<HunchLevel, string | undefined> = { info: undefined, warn: 'yellow', alert: 'red' }
92
93const HUNCH_MARK: Record<HunchCall['status'], string> = { running: '…', ok: '✔', error: '✖', denied: '⛔' }
94
95const ZONE_COLOR: Record<Zone, string> = { green: 'green', amber: 'yellow', red: 'red', unknown: 'gray' }
96
97const ZONE_HINT: Record<Zone, string> = {
98 green: 'continue',
99 amber: 'delegate reads, hand off at the next boundary',
100 red: 'write a relay handoff before new work',
101 unknown: '',
102}
103
104const CONFIDENCE_COLOR: Record<ReportCard['confidence'], string> = {
105 high: 'green',
106 medium: 'yellow',
107 low: 'red',
108 missing: 'red',
109}
110
111// A hot reload evaluates this module afresh, so these start over; everything drawn lives in $.state.
112let homeDir = ''
113let relayDir = ''
114let soft = 150_000
115let hard = 250_000
116let isJevEnabled = true
117let logSize = -1
118let alertCursor = -1
119
120async function loadRelay($: EngineInterface) {
121 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
122 homeDir = home.replace(/\\/g, '/')
123 relayDir = `${homeDir}/.claude/relay`
124
125 try {
126 const config = JSON.parse(await $.fs.read(`${relayDir}/config.json`)) as {
127 soft_tokens?: number
128 hard_tokens?: number
129 jev?: { enabled?: boolean }
130 }
131 soft = config.soft_tokens ?? soft
132 hard = config.hard_tokens ?? hard
133 isJevEnabled = config.jev?.enabled !== false
134 } catch {
135 // no relay installed: default thresholds, the gauge still draws
136 }
137}
138
139async function measure($: EngineInterface, tokens: number | undefined, window: number, percent?: number, usd?: number) {
140 const zone = zoneOf(tokens, soft, hard)
141 const before = await read($, gauge)
142 const next: Gauge = { tokens, window, percent, zone, soft, hard, usd }
143 await update($, gauge, () => next)
144
145 if (before !== null && before.zone !== zone && zone !== 'unknown' && before.zone !== 'unknown') {
146 $.ui.toast(`Relay ${zone.toUpperCase()} at ${kTokens(tokens)}: ${ZONE_HINT[zone]}`, { timeoutMs: 8000 })
147 }
148 await refreshStatus($)
149}
150
151async function pollJev($: EngineInterface) {
152 const path = `${relayDir}/jev-log.jsonl`
153 const stat = await $.fs.stat(path).catch(() => undefined)
154
155 if (stat === undefined) {
156 await update($, jev, () => ({ isOnline: null, spendToday: 0, deniesToday: 0, weakToday: 0, recent: [], note: 'no jev-log.jsonl' }))
157 return
158 }
159 if (stat.size === logSize) return
160 logSize = stat.size
161
162 let raw: string
163 try {
164 raw = await $.fs.read(path)
165 } catch {
166 await update($, jev, current => ({
167 ...(current ?? { isOnline: null, spendToday: 0, deniesToday: 0, weakToday: 0, recent: [] }),
168 note: 'jev-log.jsonl unreadable (over 4 MiB?)',
169 }))
170 return
171 }
172
173 const lines = parseLines(raw)
174 const before = await read($, jev)
175 const status: JevStatus = jevStatus(lines, localDay(await $.clock.now()), isJevEnabled)
176 await update($, jev, () => status)
177
178 // Alert only on lines written after this load first read the log.
179 if (alertCursor >= 0) {
180 for (const line of lines.slice(alertCursor)) {
181 if (line.feature === 'risk_gate' && line.decision === 'deny') {
182 const why = line.fail_closed === true ? `fail-closed, Jev ${String(line.error ?? 'unavailable')}` : 'risky'
183 $.ui.toast(`⛔ Jev risk gate denied ${String(line.op ?? 'the action')} (${why})`, { timeoutMs: 10000 })
184 } else if (line.feature === 'report_check' && (line.decision === 'deny' || line.decision === 'weak')) {
185 $.ui.toast(`⚠ Jev: ${String(line.subagent_type ?? 'subagent')} report ${String(line.decision)}`, { timeoutMs: 8000 })
186 }
187 }
188 }
189 alertCursor = lines.length
190
191 if (before !== null && before.isOnline === true && status.isOnline === false) {
192 $.ui.toast(`Jev went offline: ${status.lastError ?? 'unavailable'}; risk gate now fails closed`, { timeoutMs: 10000 })
193 }
194 await refreshStatus($)
195}
196
197/** The calls that belong to the current task, or every call while no task id has been seen. */
198function taskCalls(log: HunchLog): HunchCall[] {
199 return log.taskId === undefined ? log.calls : log.calls.filter(call => call.taskId === undefined || call.taskId === log.taskId)
200}
201
202function loudest(calls: HunchCall[]): HunchLevel {
203 return calls.reduce<HunchLevel>((level, call) => maxLevel(level, call.level), 'info')
204}
205
206function resultText(result: ToolCallResult): string {
207 if (result.deny !== undefined) return result.deny
208 if (result.text !== undefined) return result.text
209 if (typeof result.result === 'string') return result.result
210
211 return result.result === undefined ? '' : JSON.stringify(result.result)
212}
213
214async function startHunchCall(
215 $: EngineInterface,
216 id: string,
217 agentId: string | undefined,
218 invocation: { name: string; target: string; taskId?: string },
219) {
220 const caller = agentId === undefined ? undefined : (await read($, agents)).find(agent => agent.id === agentId)
221 const call: HunchCall = {
222 id,
223 name: invocation.name,
224 target: invocation.target,
225 taskId: invocation.taskId,
226 role: agentId === undefined ? 'main' : roleOf(caller?.type ?? 'subagent'),
227 startedAt: await $.clock.now(),
228 status: 'running',
229 summary: '',
230 level: 'info',
231 }
232 await update($, hunch, log => ({
233 ...log,
234 taskId: agentId === undefined ? (invocation.taskId ?? log.taskId) : log.taskId,
235 total: log.total + 1,
236 calls: [...reapStale(log.calls, call.startedAt).filter(one => one.id !== id), call].slice(-HUNCH_KEEP),
237 }))
238 await refreshStatus($)
239}
240
241async function finishHunchCall($: EngineInterface, id: string, result: ToolCallResult) {
242 const log = await read($, hunch)
243 const call = log.calls.find(one => one.id === id)
244 if (call === undefined) return
245
246 const now = await $.clock.now()
247 const isDenied = result.deny !== undefined
248
249 // The person stopped it: no verdict on Hunch, so no failure toast and no finish check.
250 if (!isDenied && wasInterrupted(result.result, resultText(result))) {
251 const stopped: HunchCall = { ...call, status: 'error', durationMs: now - call.startedAt, summary: 'interrupted', level: 'info' }
252 await update($, hunch, current => ({ ...current, calls: current.calls.map(one => (one.id === id ? stopped : one)) }))
253 await refreshStatus($)
254
255 return
256 }
257
258 const isError = isDenied || result.isError === true
259 const outcome = hunchOutcome(call.name, resultText(result), isError)
260 const taskId = call.taskId ?? outcome.taskId
261 let { level, summary } = outcome
262
263 // A task closed through the tool without the brief Hunch says to read first.
264 const isFinish = call.name === 'task' && call.target.startsWith('finish')
265 const hadContext = log.calls.some(
266 one => one.name === 'context' && (taskId === undefined || one.taskId === undefined || one.taskId === taskId),
267 )
268 if (isFinish && !isError && !hadContext) {
269 level = maxLevel(level, 'warn')
270 summary = `no hunch_context this task · ${summary}`
271 }
272
273 let fresh: HunchConstraint[] = []
274 const done: HunchCall = {
275 ...call,
276 taskId,
277 status: isDenied ? 'denied' : isError ? 'error' : 'ok',
278 durationMs: now - call.startedAt,
279 summary,
280 level,
281 }
282 // Read seenConstraints inside the write, so parallel checks of one invariant toast it once.
283 // 5: a task id in an output only moves the current task when the main loop started it.
284 const isMainStart = call.role === 'main' && call.name === 'task' && call.target.startsWith('start')
285 await update($, hunch, current => {
286 fresh = outcome.constraints.filter(one => one.severity !== 'advisory' && !current.seenConstraints.includes(one.id))
287
288 return {
289 ...current,
290 taskId: isMainStart ? (outcome.taskId ?? current.taskId) : current.taskId,
291 seenConstraints: [...current.seenConstraints, ...fresh.map(one => one.id)].slice(-200),
292 calls: current.calls.map(one => (one.id === id ? done : one)),
293 }
294 })
295
296 const where = call.target === '' ? '' : ` (${call.target})`
297 const first = fresh[0]
298 if (first !== undefined) {
299 const more = fresh.length > 1 ? ` +${fresh.length - 1} more` : ''
300 $.ui.toast(`🧠 Hunch invariant [${first.severity}]${where}: ${first.statement}${more}`, { timeoutMs: 10000 })
301 }
302 if (outcome.verdict === 'BLOCK') {
303 $.ui.toast(`⛔ Hunch merge verdict BLOCK${where}: fix the cited invariant before merging`, { timeoutMs: 12000 })
304 }
305 if (call.name === 'escalations' && level === 'alert' && !isError) {
306 $.ui.toast(`🧠 Hunch escalations: ${summary}; ask the user, silence is never approval`, { timeoutMs: 10000 })
307 }
308 if (call.name === 'verify' && level === 'alert') {
309 $.ui.toast(`✖ Hunch verify ${summary}${where}`, { timeoutMs: 10000 })
310 }
311 if (isError && call.name !== 'verify') {
312 $.ui.toast(`✖ Hunch ${call.name} ${isDenied ? 'denied' : 'failed'}: ${summary}`, { timeoutMs: 8000 })
313 }
314 if (isFinish && !hadContext && !isError) {
315 $.ui.toast('⚠ Hunch task finished without a hunch_context brief', { timeoutMs: 8000 })
316 }
317 await refreshStatus($)
318}
319
320/** Closes a call that got no result: `interrupted` (the dispatch was abandoned) is quiet, `lost` alerts. */
321async function settleHunchCall($: EngineInterface, id: string, why: 'interrupted' | 'lost') {
322 const now = await $.clock.now()
323 const [level, summary] = why === 'interrupted' ? (['info', 'interrupted'] as const) : (['alert', 'aborted or not recorded'] as const)
324 await update($, hunch, log => ({
325 ...log,
326 calls: log.calls.map(one =>
327 one.id === id && one.status === 'running' ? { ...one, status: 'error' as const, level, durationMs: now - one.startedAt, summary } : one,
328 ),
329 }))
330 await refreshStatus($)
331}
332
333async function whoOf($: EngineInterface, agentId: string | undefined): Promise<string> {
334 if (agentId === undefined) return 'main'
335 const run = (await read($, agents)).find(agent => agent.id === agentId)
336
337 return roleOf(run?.type ?? 'agent')
338}
339
340/** Adds or replaces one timeline line; running lines older than STALE_MS are closed as stopped. */
341async function logActivity($: EngineInterface, entry: ActivityEntry) {
342 await update($, activity, list =>
343 [
344 ...list
345 .filter(one => one.id !== entry.id)
346 .map(one => (one.status === 'running' && entry.at - one.at > STALE_MS ? { ...one, status: 'stopped' as const } : one)),
347 entry,
348 ].slice(-ACTIVITY_KEEP),
349 )
350}
351
352/** Closes one line; a line already closed as stale (stopped, no duration) still takes its real outcome. */
353async function endActivity($: EngineInterface, id: string, status: ActivityEntry['status']) {
354 const now = await $.clock.now()
355 const isOpen = (one: ActivityEntry) => one.status === 'running' || (one.status === 'stopped' && one.durationMs === undefined)
356 await update($, activity, list => list.map(one => (one.id === id && isOpen(one) ? { ...one, status, durationMs: now - one.at } : one)))
357}
358
359/** Closes running lines older than STALE_MS as stopped; writes only when one changed, so idle polls draw nothing. */
360async function reapActivity($: EngineInterface, now: number) {
361 const isStale = (one: ActivityEntry) => one.status === 'running' && now - one.at > STALE_MS
362 if (!(await read($, activity)).some(isStale)) return
363 await update($, activity, list => list.map(one => (isStale(one) ? { ...one, status: 'stopped' as const } : one)))
364}
365
366/** The time a drawing measures ages against, from state alone: the poll's tick advances it while work runs. */
367function drawnNow(tick: number, list: readonly ActivityEntry[], runs: readonly AgentRun[]): number {
368 return Math.max(tick, ...list.map(one => one.at), ...runs.map(run => run.startedAt))
369}
370
371/** What the session is doing right now, in a few words. */
372function nowText(list: readonly ActivityEntry[], running: readonly AgentRun[], now: number, isTurnOpen: boolean): string {
373 const main = list.filter(one => one.who === 'main' && one.status === 'running').at(-1)
374 if (main !== undefined) return `${main.text} (${seconds(now - main.at)})`
375 if (running.length > 0) return `waiting on ${running.map(agent => roleOf(agent.type)).join(', ')}`
376
377 return isTurnOpen ? 'thinking' : 'idle, waiting for you'
378}
379
380/** The status line text last set by this copy of the module; a repeat of it is not sent again. */
381let shownStatus: string | undefined | null = null
382
383function setStatus($: EngineInterface, text: string | undefined) {
384 if (text === shownStatus) return
385 shownStatus = text
386 $.ui.status(text)
387}
388
389async function refreshStatus($: EngineInterface) {
390 if (!(await read($, isBandHidden))) {
391 setStatus($, undefined)
392 return
393 }
394 const g = await read($, gauge)
395 const list = await read($, agents)
396 const running = list.filter(agent => agent.status === 'running')
397 const zone = g === null ? '?' : g.zone.toUpperCase()
398 const lines = await read($, activity)
399 const doing = nowText(lines, running, drawnNow(await read($, tick), lines, running), await read($, turnOpen))
400 setStatus($, clip(`${zone} ${kTokens(g?.tokens)} · now: ${doing} · ${running.length} agents`, 100))
401}
402
403function openPane($: EngineInterface) {
404return $.ui.open({ id: PANE, title: 'Mission Control' })
405}
406
407/** Rereads the session registry; writes the atom only when a row changed, so idle polls draw nothing. */
408async function pollSessions($: EngineInterface) {
409 if (homeDir === '') await loadRelay($)
410 const dir = `${homeDir}/.claude/sessions`
411 const entries = await $.fs.list(dir).catch(() => [])
412 const files = entries.filter(entry => entry.kind === 'file' && entry.name.endsWith('.json'))
413 const raws = await Promise.all(files.map(entry => $.fs.read(`${dir}/${entry.name}`).catch(() => '')))
414 const rows = sessionRows(raws.flatMap(raw => sessionRow(typeof raw === 'string' ? raw : '') ?? []))
415
416 if (JSON.stringify(rows) !== JSON.stringify(await read($, sessions))) await update($, sessions, () => rows)
417
418 // Ages are drawn from the clock: bump this at most every 30 s so the pane redraws without a row change.
419 const checkedAt = Math.floor((await $.clock.now()) / 30_000) * 30_000
420 if (checkedAt !== (await read($, sessionsCheckedAt))) await update($, sessionsCheckedAt, () => checkedAt)
421}
422
423/** Asks the handoff bridge in the session's own VS Code window to bring its tab forward. */
424async function focusSession($: EngineInterface, row: SessionRow) {
425 $.ui.toast(`Switching to ${row.name}…`, { timeoutMs: 3000 })
426 let message: string
427 try {
428 const helper = `${homeDir}/.claude/bin/session-focus.py`
429 const { exitCode, stdout, stderr } = await $.process.run(['python', helper, '--session', row.sessionId], {
430 timeoutMs: 20_000,
431 })
432 const last = (text: string) => text.trim().split(/\r?\n/).at(-1) ?? ''
433 const line = last(stdout) || last(stderr) || `session-focus exited ${exitCode}`
434 message = exitCode === 0 ? `🗂 ${line}` : `✖ ${line}`
435 } catch (error) {
436 message = `✖ session-focus could not run: ${error instanceof Error ? error.message : String(error)}`
437 }
438 $.ui.toast(message, { timeoutMs: 8000 })
439}
440
441async function openSessions($: EngineInterface) {
442 await pollSessions($)
443
444 return $.ui.open({ id: SESSIONS_PANE, title: 'Sessions' })
445}
446
447async function setBandHidden($: EngineInterface, isHidden: boolean) {
448 await update($, isBandHidden, () => isHidden)
449 await refreshStatus($)
450}
451
452export const register: Register = on => {
453 on('session.start', async ($, e, next) => {
454 await loadRelay($)
455 await $.command.register({
456 name: 'orch',
457 description: 'Mission Control: open the orchestrator pane (`/orch band` toggles the band)',
458 })
459 await $.command.register({
460 name: 'sessions',
461 description: 'Mission Control: list open Claude Code sessions and switch VS Code to one',
462 })
463
464 const usage = await $.session.usage()
465 await measure($, usage.context.tokens, usage.context.window, usage.context.percent, usage.cost?.usd)
466 await pollJev($)
467 await pollSessions($)
468
469 $.clock.every(POLL_MS, () => {
470 void (async () => {
471 await pollJev($)
472 await pollSessions($)
473 const list = await read($, agents)
474 await reapActivity($, await $.clock.now())
475 const isBusy = (await read($, activity)).some(one => one.status === 'running')
476 if (isBusy || list.some(agent => agent.status === 'running')) {
477 const now = await $.clock.now()
478 await update($, tick, () => now)
479 }
480 })()
481 })
482
483 return next(e)
484 })
485
486 on('command.run', { command: 'orch' }, async ($, e) => {
487 if (e.args.trim() === 'band') {
488 const isHidden = !(await read($, isBandHidden))
489 await setBandHidden($, isHidden)
490
491 return { text: isHidden ? 'Mission Control band hidden (summary moved to the status line).' : 'Mission Control band shown.' }
492 }
493 await openPane($)
494
495 return { text: 'Mission Control opened.' }
496 })
497
498 on('command.run', { command: 'sessions' }, async ($) => {
499 await openSessions($)
500 const count = (await read($, sessions)).length
501
502 return { text: `Sessions opened: ${count} registered.` }
503 })
504
505 on('prompt.submit', async ($, e, next) => {
506 try {
507 const at = await $.clock.now()
508 const detail = e.text.length > 70 ? capDetail(e.text) : undefined
509 await update($, turnOpen, () => true)
510 await logActivity($, { id: `prompt-${at}`, at, who: 'you', text: `asked: "${clip(e.text, 70)}"`, status: 'note', detail })
511 } catch {
512 // the timeline is a view; a prompt never waits on it
513 }
514
515 return next(e)
516 }).catch(($, e, next) => next(e))
517
518 // Every tool call, from the main loop or a subagent, becomes one plain timeline line.
519 on('tool.call', async ($, e, next) => {
520 const id = e.tool_use_id
521 // Logged alongside the call, not before it: the tool never waits on the timeline.
522 const logged = (async () => {
523 const at = await $.clock.now()
524 const args = e as unknown as Record<string, unknown>
525 const text = describeTool(e.tool, args)
526 const detail = toolDetail(e.tool, args) || undefined
527 await logActivity($, { id, at, who: await whoOf($, e.agentId), text, status: 'running', detail })
528 await refreshStatus($)
529 })().catch(() => undefined)
530
531 let status: ActivityEntry['status'] = 'stopped'
532 try {
533 const result = await next(e)
534 if (!next.signal.aborted) {
535 const isStopped = result.deny === undefined && wasInterrupted(result.result, resultText(result))
536 status = isStopped ? 'stopped' : result.deny !== undefined || result.isError === true ? 'error' : 'ok'
537 }
538
539 return result
540 } finally {
541 // A fast call can settle before its line is written; close the line only once it exists.
542 await logged
543 await endActivity($, id, status).catch(() => undefined)
544 await refreshStatus($).catch(() => undefined)
545 }
546 // An observer: whatever went wrong here, the call itself goes on (or replays what it settled to).
547 }).catch(($, e, next) => next(e))
548
549 on('session.measure', async ($, e, next) => {
550 await measure($, e.context.tokens, e.context.window, e.context.percent, e.cost?.usd)
551
552 return next(e)
553 })
554
555 on('agent.spawn', async ($, e, next) => {
556 const spawned = await next(e)
557 const agentId = 'agentId' in spawned ? spawned.agentId : undefined
558 const model = 'model' in spawned ? spawned.model : undefined
559
560 if (agentId !== undefined && model !== undefined) {
561 const run: AgentRun = {
562 id: agentId,
563 type: e.subagentType,
564 model: shortModel(model),
565 description: e.description,
566 isBackground: e.background,
567 startedAt: await $.clock.now(),
568 status: 'running',
569 }
570 await update($, agents, list => [...list.filter(one => one.id !== agentId), run].slice(-30))
571 await refreshStatus($)
572 }
573
574 return spawned
575 }).catch(($, e, next) => next(e))
576
577 on('turn.complete', async ($, e, next) => {
578 const agentId = e.agentId
579 if (agentId === undefined) {
580 const at = await $.clock.now()
581 const how = e.reason === 'answer' ? 'answered' : e.reason === 'aborted' ? 'stopped' : 'ended with an error'
582 await logActivity($, {
583 id: `turn-${at}`,
584 at,
585 who: 'main',
586 text: `${how} (turn took ${seconds(e.durationMs)})`,
587 status: e.reason === 'answer' ? 'note' : e.reason === 'aborted' ? 'stopped' : 'error',
588 }).catch(() => undefined)
589 await update($, turnOpen, () => false).catch(() => undefined)
590 await refreshStatus($).catch(() => undefined)
591
592 return next(e)
593 }
594
595 const now = await $.clock.now()
596 const status: AgentRun['status'] = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'aborted' : 'error'
597 const run = (await read($, agents)).find(one => one.id === agentId)
598 await update($, agents, list => list.map(one => (one.id === agentId ? { ...one, status, endedAt: now } : one)))
599
600 const type = run?.type ?? 'subagent'
601 const card = parseCard(e.answer)
602
603 // Only role agents owe a card; an Explore or general-purpose answer is not graded.
604 if (status === 'done' && (isRole(type) || card.hasAnyField)) {
605 const report: ReportCard = {
606 agentId,
607 type,
608 model: run?.model ?? shortModel(e.usage?.model ?? ''),
609 at: now,
610 durationMs: e.durationMs,
611 confidence: card.confidence,
612 missing: card.missing,
613 unverified: card.unverified,
614 exitCodes: card.exitCodes,
615 isWeak: card.isWeak,
616 result: card.result,
617 }
618 await update($, reports, list => [...list, report].slice(-20))
619
620 if (card.isWeak) {
621 const why = [
622 card.missing.length > 0 ? `missing ${card.missing.join('/')}` : '',
623 card.confidence !== 'high' && card.confidence !== 'missing' ? `confidence ${card.confidence}` : '',
624 card.unverified > 0 ? `${card.unverified} unverified` : '',
625 ]
626 .filter(Boolean)
627 .join(', ')
628 $.ui.toast(`⚠ ${roleOf(type)} report weak: ${why}; verify before relying on it`, { timeoutMs: 8000 })
629 }
630 }
631 const verdict = card.result === '' ? '' : `: ${card.result}`
632 const graded = card.hasAnyField ? ` (${card.confidence} confidence)` : ''
633 await logActivity($, {
634 id: `agent-end-${agentId}`,
635 at: now,
636 who: roleOf(type),
637 text: clip(status === 'done' ? `finished${graded}${verdict}` : `${status}`, 90),
638 status: status === 'done' ? 'ok' : status === 'aborted' ? 'stopped' : 'error',
639 durationMs: run === undefined ? undefined : now - run.startedAt,
640 }).catch(() => undefined)
641 await refreshStatus($)
642
643 return next(e)
644 })
645
646 on('tool.call', { tool: /^mcp__hunch__/ }, async ($, e, next) => {
647 const name = hunchName(e.tool)
648 if (name === undefined) return next(e)
649
650 await startHunchCall($, e.tool_use_id, e.agentId, hunchInvocation(name, e as unknown as Record<string, unknown>))
651 const onAbort = () => void settleHunchCall($, e.tool_use_id, 'interrupted').catch(() => undefined)
652 next.signal.addEventListener('abort', onAbort, { once: true })
653 if (next.signal.aborted) onAbort()
654 const result = await next(e)
655 next.signal.removeEventListener('abort', onAbort)
656 // Abandoned, then resolved late (e.g. backgrounded by a turn abort): keep it interrupted.
657 if (next.signal.aborted) return result
658 await finishHunchCall($, e.tool_use_id, result)
659
660 return result
661 }).catch(async ($, e, next) => {
662 try {
663 return await next(e)
664 } finally {
665 await settleHunchCall($, e.tool_use_id, next.signal.aborted ? 'interrupted' : 'lost').catch(() => undefined)
666 }
667 })
668
669 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
670 const invocation = e.tool === 'Bash' ? hunchCli(e.command) : undefined
671 if (invocation === undefined) return next(e)
672
673 await startHunchCall($, e.tool_use_id, e.agentId, invocation)
674 const onAbort = () => void settleHunchCall($, e.tool_use_id, 'interrupted').catch(() => undefined)
675 next.signal.addEventListener('abort', onAbort, { once: true })
676 if (next.signal.aborted) onAbort()
677 const result = await next(e)
678 next.signal.removeEventListener('abort', onAbort)
679 // Abandoned, then resolved late (e.g. backgrounded by a turn abort): keep it interrupted.
680 if (next.signal.aborted) return result
681 await finishHunchCall($, e.tool_use_id, result)
682
683 return result
684 }).catch(async ($, e, next) => {
685 try {
686 return await next(e)
687 } finally {
688 await settleHunchCall($, e.tool_use_id, next.signal.aborted ? 'interrupted' : 'lost').catch(() => undefined)
689 }
690 })
691
692 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
693 if (e.props.hasSurvey || (await read($, isBandHidden))) return next(e)
694
695 const { Box, Text, Button } = $.ui.resolve(e)
696 const g = await read($, gauge)
697 const j = await read($, jev)
698 const running = (await read($, agents)).filter(agent => agent.status === 'running')
699 const hunchNow = taskCalls(await read($, hunch))
700 const hunchLevel = loudest(hunchNow)
701 const sessionCount = (await read($, sessions)).length
702 const isWide = e.props.bodyColumns >= 100
703 const list = await read($, activity)
704 const now = drawnNow(await read($, tick), list, running)
705
706 const zone: Zone = g?.zone ?? 'unknown'
707 const fill = g?.tokens === undefined ? 0 : g.tokens / g.hard
708 const jevColor = j?.isOnline === true ? 'green' : j?.isOnline === false ? 'red' : 'gray'
709 const jevText = j?.isOnline === true ? 'Jev ✔' : j?.isOnline === false ? `Jev ✖ ${j.lastError ?? ''}`.trim() : 'Jev ?'
710 const doing = nowText(list, running, now, await read($, turnOpen))
711 const crew = running.map(agent => `${roleOf(agent.type)} ${seconds(now - agent.startedAt)}`).join(', ')
712
713 return (
714 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
715 <Text color={ZONE_COLOR[zone]} bold>
716 {'●'} {zone.toUpperCase()} {kTokens(g?.tokens)}/{kTokens(g?.hard)}
717 </Text>
718 {isWide && <Text color={ZONE_COLOR[zone]}>{bar(fill, 10)}</Text>}
719 <Text color={jevColor}>{jevText}</Text>
720 <Text color={HUNCH_COLOR[hunchLevel] ?? (hunchNow.length === 0 ? 'gray' : 'cyan')}>
721 {'🧠'} hunch {hunchNow.length}
722 {hunchNow.some(call => call.status === 'running') ? '…' : ''}
723 {hunchLevel !== 'info' ? ` ⚠${hunchNow.filter(call => call.level !== 'info').length}` : ''}
724 </Text>
725 <Text wrap="truncate" color={doing.startsWith('idle') ? 'gray' : 'yellow'}>
726 now: {doing}
727 </Text>
728 {crew !== '' && <Text wrap="truncate">agents: {crew}</Text>}
729 <Button key="open" label="orch" onPress={() => openPane($)} />
730 <Button key="sessions" label={`🗂 ${sessionCount}`} onPress={() => openSessions($)} />
731 <Button key="hide" label="hide" onPress={() => setBandHidden($, true)} />
732 </Box>
733 )
734 })
735
736 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
737 const { Box, Text, Button } = $.ui.resolve(e)
738 const g = await read($, gauge)
739 const j = await read($, jev)
740 const list = await read($, agents)
741 const cards = await read($, reports)
742 const hunchLog = await read($, hunch)
743 const hunchNow = taskCalls(hunchLog)
744 const counts = new Map<string, number>()
745 for (const call of hunchNow) counts.set(call.name, (counts.get(call.name) ?? 0) + 1)
746 const now = drawnNow(await read($, tick), await read($, activity), list)
747 const width = Math.max(20, e.props.bodyColumns)
748 const zone: Zone = g?.zone ?? 'unknown'
749 const running = list.filter(agent => agent.status === 'running')
750 const lines = await read($, activity)
751 const isDetailed = await read($, showDetails)
752 const opened = await read($, openEntries)
753 const mainNow = lines.filter(one => one.who === 'main' && one.status === 'running').at(-1)
754 const isThinking = await read($, turnOpen)
755 const lastCard = cards.at(-1)
756 const hunchWarnings = hunchNow.filter(call => call.level !== 'info').length
757
758 return (
759 <Box flexDirection="column" width={width}>
760 <Text bold>Now</Text>
761 <Text wrap="truncate" color={mainNow === undefined && running.length === 0 ? 'gray' : 'yellow'}>
762 {' '}main {mainNow === undefined ? (isThinking ? 'thinking' : 'idle, waiting for you') : `${mainNow.text} · ${seconds(now - mainNow.at)}`}
763 </Text>
764 {running.map(agent => (
765 <Text wrap="truncate" color="yellow">
766 {' '}
767 {roleOf(agent.type)} ({agent.model}) {agent.description} · {seconds(now - agent.startedAt)}
768 {agent.isBackground ? ' · background' : ''}
769 </Text>
770 ))}
771
772 <Text bold>Timeline</Text>
773 {lines.length === 0 && <Text dimColor>{' '}Nothing yet.</Text>}
774 {lines
775 .slice(-TIMELINE_ROWS)
776 .reverse()
777 .map(one => {
778 const isOpen = one.detail !== undefined && opened.includes(one.id)
779 const drawer = isOpen ? (one.detail ?? '').split(/\r?\n/) : []
780
781 return (
782 <Box key={`line-${one.id}`} flexDirection="column">
783 <Box flexDirection="row" columnGap={1}>
784 {one.detail !== undefined ? (
785 <Button
786 key={`drawer-${one.id}`}
787 label={isOpen ? '▾' : '▸'}
788 onPress={() => update($, openEntries, ids => (ids.includes(one.id) ? ids.filter(id => id !== one.id) : [...ids, one.id].slice(-20)))}
789 />
790 ) : (
791 <Text> </Text>
792 )}
793 <Text wrap="truncate" color={one.status === 'note' ? undefined : ACTIVITY_COLOR[one.status]} dimColor={one.status === 'note'}>
794 {clockSeconds(one.at)} {clip(one.who, 8).padEnd(8)} {ACTIVITY_MARK[one.status]} {one.text}
795 {one.status === 'running'
796 ? ` · ${seconds(now - one.at)}`
797 : one.durationMs !== undefined && one.durationMs >= 1000
798 ? ` · ${seconds(one.durationMs)}`
799 : ''}
800 </Text>
801 </Box>
802 {drawer.slice(0, DRAWER_LINES).map(line => (
803 <Text dimColor wrap="truncate">
804 {' │ '}
805 {line}
806 </Text>
807 ))}
808 {drawer.length > DRAWER_LINES && <Text dimColor>{` │ … ${drawer.length - DRAWER_LINES} more lines`}</Text>}
809 </Box>
810 )
811 })}
812
813 <Text bold>Summary</Text>
814 <Text wrap="truncate" color={ZONE_COLOR[zone]}>
815 {' '}context {kTokens(g?.tokens)} of {kTokens(g?.hard)} · {zone.toUpperCase()} · {ZONE_PLAIN[zone]}
816 {g?.usd !== undefined ? ` · $${g.usd.toFixed(2)}` : ''}
817 </Text>
818 <Text wrap="truncate">
819 {' '}agents {running.length} running
820 {lastCard !== undefined
821 ? ` · last report: ${roleOf(lastCard.type)} ${lastCard.confidence}${lastCard.unverified > 0 ? `, ${lastCard.unverified} unverified` : ''}`
822 : ''}
823 </Text>
824 <Text wrap="truncate" color={HUNCH_COLOR[loudest(hunchNow)]}>
825 {' '}hunch {hunchNow.length} call{hunchNow.length === 1 ? '' : 's'} · {hunchWarnings === 0 ? 'no warnings' : `${hunchWarnings} warning${hunchWarnings === 1 ? '' : 's'}`}
826 </Text>
827 <Text wrap="truncate" color={j?.isOnline === false ? 'red' : undefined}>
828 {' '}jev {j?.isOnline === true ? 'online' : j?.isOnline === false ? `offline (${j.lastError ?? 'unavailable'})` : 'unknown'}
829 </Text>
830
831 <Box flexDirection="row" columnGap={1}>
832 <Button key="details" label={isDetailed ? 'hide details' : 'details'} onPress={() => update($, showDetails, shown => !shown)} />
833 <Button
834 key="band"
835 label="toggle band"
836 onPress={async () => setBandHidden($, !(await read($, isBandHidden)))}
837 />
838 <Button
839 key="clear"
840 label="clear done"
841 onPress={async () => {
842 await update($, agents, all => all.filter(agent => agent.status === 'running'))
843 await update($, activity, all => all.filter(one => one.status === 'running'))
844 const now = await $.clock.now()
845 await update($, hunch, log => ({ ...log, calls: reapStale(log.calls, now).filter(call => call.status === 'running') }))
846 }}
847 />
848 </Box>
849
850 {isDetailed && (
851 <Box flexDirection="column">
852 <Text bold>Relay</Text>
853 <Text color={ZONE_COLOR[zone]}>
854 {zone.toUpperCase()} {bar(g?.tokens === undefined ? 0 : g.tokens / g.hard, Math.min(24, width - 22))}{' '}
855 {kTokens(g?.tokens)}/{kTokens(g?.hard)}
856 </Text>
857 <Text dimColor wrap="truncate">
858 amber {kTokens(g?.soft)} · red {kTokens(g?.hard)}
859 {g?.usd !== undefined ? ` · session $${g.usd.toFixed(2)}` : ''}
860 {ZONE_HINT[zone] !== '' ? ` · ${ZONE_HINT[zone]}` : ''}
861 </Text>
862
863 <Text bold>In flight ({running.length})</Text>
864 {running.length === 0 && <Text dimColor>No subagents running.</Text>}
865 {running.map(agent => (
866 <Text wrap="truncate">
867 {roleOf(agent.type).padEnd(8)} {agent.model.padEnd(7)} {seconds(now - agent.startedAt).padStart(4)}{' '}
868 {agent.isBackground ? 'bg ' : ''}
869 {agent.description}
870 </Text>
871 ))}
872
873 <Text bold>Report cards</Text>
874 {cards.length === 0 && <Text dimColor>No role reports yet.</Text>}
875 {cards
876 .slice(-6)
877 .reverse()
878 .map(card => (
879 <Box flexDirection="column">
880 <Text wrap="truncate">
881 <Text color={CONFIDENCE_COLOR[card.confidence]}>{card.confidence.toUpperCase().padEnd(7)}</Text>{' '}
882 {roleOf(card.type).padEnd(8)} {seconds(card.durationMs).padStart(4)}
883 {card.exitCodes.length > 0 ? ` exit ${card.exitCodes.join(',')}` : ''}
884 {card.unverified > 0 ? ` ⚠ ${card.unverified} unverified` : ''}
885 {card.missing.length > 0 ? ` missing ${card.missing.join('/')}` : ''}
886 </Text>
887 {card.result !== '' && (
888 <Text dimColor wrap="truncate">
889 {' '}
890 {card.result}
891 </Text>
892 )}
893 </Box>
894 ))}
895
896 <Text bold>
897 Hunch{' '}
898 <Text color={HUNCH_COLOR[loudest(hunchNow)]}>
899 {hunchLog.taskId ?? 'no task id yet'} · {hunchNow.length} call{hunchNow.length === 1 ? '' : 's'}
900 </Text>
901 </Text>
902 {hunchNow.length === 0 && <Text dimColor>No Hunch calls yet this task.</Text>}
903 {counts.size > 0 && (
904 <Text dimColor wrap="truncate">
905 {[...counts].map(([name, count]) => `${name} ${count}`).join(' · ')}
906 </Text>
907 )}
908 {hunchNow
909 .slice(-8)
910 .reverse()
911 .map(call => (
912 <Box flexDirection="column">
913 <Text wrap="truncate" color={HUNCH_COLOR[call.level]}>
914 {clockTime(call.startedAt)} {HUNCH_MARK[call.status]} {call.role.padEnd(7)} {call.name.padEnd(17)}{' '}
915 {call.durationMs === undefined ? '' : `${seconds(call.durationMs).padStart(4)} `}
916 {call.target}
917 </Text>
918 {call.summary !== '' && (
919 <Text dimColor wrap="truncate">
920 {' '}
921 {call.summary}
922 </Text>
923 )}
924 </Box>
925 ))}
926
927 <Text bold>
928 Jev{' '}
929 <Text color={j?.isOnline === true ? 'green' : j?.isOnline === false ? 'red' : 'gray'}>
930 {j?.isOnline === true ? 'online' : j?.isOnline === false ? `offline (${j.lastError ?? 'unavailable'})` : 'unknown'}
931 </Text>
932 </Text>
933 {j !== null && (
934 <Text dimColor>
935 today ${j.spendToday.toFixed(4)} · {j.deniesToday} gate denies · {j.weakToday} weak reports
936 </Text>
937 )}
938 {j?.note !== undefined && <Text color="yellow">{j.note}</Text>}
939 {(j?.recent ?? []).map(entry => (
940 <Text wrap="truncate" color={entry.isAlert ? 'red' : undefined}>
941 {entry.ts.slice(11, 16)} {entry.feature.padEnd(13)} {entry.summary}
942 </Text>
943 ))}
944 </Box>
945 )}
946 </Box>
947 )
948 })
949
950 on('ui.render', { component: 'Pane', requestId: SESSIONS_PANE }, async ($, e) => {
951 const { Box, Text, Button } = $.ui.resolve(e)
952 const rows = await read($, sessions)
953 await read($, sessionsCheckedAt) // read so the pane redraws as ages advance
954 const self = await $.session.id()
955 const now = await $.clock.now()
956 const width = Math.max(20, e.props.bodyColumns)
957 const nameWidth = Math.min(22, Math.max(8, ...rows.map(row => row.name.length)))
958
959 return (
960 <Box flexDirection="column" width={width}>
961 <Text bold>Open sessions ({rows.length})</Text>
962 {rows.length === 0 && <Text dimColor>No Claude Code sessions registered.</Text>}
963 {rows.map(row => {
964 const isSelf = row.sessionId === self
965 const isEditor = row.entrypoint === 'claude-vscode'
966
967 return (
968 <Box key={`row-${row.sessionId}`} flexDirection="row" columnGap={1}>
969 <Text wrap="truncate" color={isSelf ? 'cyan' : undefined}>
970 <Text color={row.status === 'busy' ? 'yellow' : row.status === 'idle' ? 'green' : 'gray'}>
971 {row.status === 'busy' ? '◐' : '●'}
972 </Text>{' '}
973 {row.name.slice(0, nameWidth).padEnd(nameWidth)} {repoName(row.cwd).slice(0, 18).padEnd(18)}{' '}
974 {row.status.padEnd(4)} {ago(now - row.updatedAt).padStart(4)}
975 </Text>
976 {isSelf ? (
977 <Text dimColor>this session</Text>
978 ) : isEditor ? (
979 <Button key={`focus-${row.sessionId}`} label="switch" onPress={() => focusSession($, row)} />
980 ) : (
981 <Text dimColor>terminal</Text>
982 )}
983 </Box>
984 )
985 })}
986 <Text dimColor wrap="truncate">
987 switch asks the handoff bridge in the session's VS Code window to show its tab
988 </Text>
989 <Box flexDirection="row" columnGap={1}>
990 <Button key="refresh" label="refresh" onPress={() => pollSessions($)} />
991 </Box>
992 </Box>
993 )
994 })
995}
996hooks/parse.ts 658 lines1import type { Confidence, HunchCall, HunchConstraint, HunchLevel, JevEntry, JevStatus, SessionRow, Zone } from '../types'
2
3// Pure helpers: no `$`, so the tests exercise them directly.
4
5export const ROLES = ['scout', 'runner', 'builder', 'critic'] as const
6
7const FIELDS = ['RESULT', 'EVIDENCE', 'CONFIDENCE', 'UNVERIFIED'] as const
8
9const HEADER = /^[ \t>#*_\-]*(RESULT|EVIDENCE|CONFIDENCE|UNVERIFIED)\b[ \t*_]*[:\-–—]?[ \t]*(.*)$/i
10
11const NOTHING = /^(none|n\/?a|nothing|-+|—|\(none\)|no(ne)? unverified.*)\.?$/i
12
13const BULLET = /^\s*(?:[-*•]|\d+[.)])\s+/
14
15export type ParsedCard = {
16 hasAnyField: boolean
17 confidence: Confidence
18 missing: string[]
19 unverified: number
20 exitCodes: number[]
21 isWeak: boolean
22 result: string
23}
24
25/** The role an agent type names: `scout` for `scout` or `some-plugin:scout`. */
26export function roleOf(type: string): string {
27 return type.slice(type.lastIndexOf(':') + 1)
28}
29
30export function isRole(type: string): boolean {
31 return (ROLES as readonly string[]).includes(roleOf(type).toLowerCase())
32}
33
34/** `claude-sonnet-5-5` -> `sonnet`; anything else as given. */
35export function shortModel(model: string): string {
36 const family = /(opus|sonnet|haiku|fable)/i.exec(model)
37 return (family?.[1] ?? model).toLowerCase()
38}
39
40/** Reads the orchestrator's report card out of a subagent's final answer. */
41export function parseCard(answer: string): ParsedCard {
42 const sections = new Map<string, string[]>()
43 let current: string | undefined
44
45 for (const raw of answer.split(/\r?\n/)) {
46 const line = raw.replace(/\*\*/g, '')
47 const header = HEADER.exec(line)
48
49 if (header !== null) {
50 const [, name = '', rest = ''] = header
51 current = name.toUpperCase()
52 sections.set(current, rest.trim() === '' ? [] : [rest.trim()])
53 } else if (current !== undefined) {
54 sections.get(current)?.push(line)
55 }
56 }
57
58 const missing = FIELDS.filter(field => !sections.has(field))
59 const confidenceText = (sections.get('CONFIDENCE') ?? []).join(' ').toLowerCase()
60 const level = /\b(high|medium|med|low)\b/.exec(confidenceText)?.[1]
61 const confidence: Confidence =
62 level === undefined ? 'missing' : level === 'med' ? 'medium' : (level as Confidence)
63
64 const unverifiedLines = (sections.get('UNVERIFIED') ?? [])
65 .map(line => line.trim())
66 .filter(line => line !== '')
67 const realItems = unverifiedLines.filter(line => !NOTHING.test(line.replace(BULLET, '').trim()))
68 const bullets = realItems.filter(line => BULLET.test(line)).length
69 const unverified = realItems.length === 0 ? 0 : Math.max(1, bullets)
70
71 const exitCodes = [...answer.matchAll(/\bexit(?:\s+code)?\s*[:=]?\s*(-?\d+)\b/gi)].map(match =>
72 Number(match[1]),
73 )
74
75 const result = (sections.get('RESULT') ?? [])
76 .map(line => line.replace(BULLET, '').trim())
77 .find(line => line !== '') ?? ''
78
79 return {
80 hasAnyField: missing.length < FIELDS.length,
81 confidence,
82 missing,
83 unverified,
84 exitCodes,
85 isWeak: missing.length > 0 || confidence !== 'high' || unverified > 0,
86 result: result.slice(0, 120),
87 }
88}
89
90export function zoneOf(tokens: number | undefined, soft: number, hard: number): Zone {
91 if (tokens === undefined) return 'unknown'
92 if (tokens >= hard) return 'red'
93 if (tokens >= soft) return 'amber'
94
95 return 'green'
96}
97
98type JevLine = Record<string, unknown>
99
100const text = (value: unknown): string => (typeof value === 'string' ? value : '')
101
102/** One display line for a Jev decision, or undefined for noise (usage rows, routine shifts). */
103export function summarize(line: JevLine): JevEntry | undefined {
104 if (line.kind === 'usage') return undefined
105
106 const ts = text(line.ts)
107 const feature = text(line.feature)
108 const decision = text(line.decision)
109 const offline = text(line.error) !== '' ? ' (offline)' : ''
110
111 switch (feature) {
112 case 'risk_gate': {
113 const fail = line.fail_closed === true ? ' fail-closed' : ''
114 const risk = typeof line.risk === 'number' || typeof line.risk === 'string' ? ` risk ${line.risk}` : ''
115
116 return {
117 ts,
118 feature,
119 summary: `${text(line.op)} ${decision.toUpperCase()}${fail}${risk}`,
120 isAlert: decision === 'deny',
121 }
122 }
123 case 'report_check': {
124 if (decision === '' || decision.startsWith('skipped')) return undefined
125 const reasons = Array.isArray(line.reasons) ? line.reasons.map(text).filter(Boolean) : []
126
127 return {
128 ts,
129 feature,
130 summary: `${text(line.subagent_type)} ${decision}${reasons.length > 0 ? `: ${reasons[0]}` : ''}${offline}`,
131 isAlert: decision === 'deny' || decision === 'weak',
132 }
133 }
134 case 'route': {
135 const choice = text(line.choice) || text(line.current)
136 const applied = line.applied === true ? '' : ' (kept)'
137
138 return { ts, feature, summary: `${text(line.subagent_type)} -> ${choice}${applied}${offline}`, isAlert: false }
139 }
140 case 'shift':
141 if (decision === '' || decision === 'continue' || decision === 'unsure') return undefined
142
143 return { ts, feature, summary: `${text(line.zone)} ${decision}`, isAlert: false }
144 case 'handoff_grade': {
145 const score = typeof line.score === 'number' ? line.score.toFixed(2) : '?'
146
147 return { ts, feature, summary: `score ${score}${line.weak === true ? ' WEAK' : ''}`, isAlert: line.weak === true }
148 }
149 default:
150 return feature === '' ? undefined : { ts, feature, summary: decision || '-', isAlert: false }
151 }
152}
153
154export function parseLines(raw: string): JevLine[] {
155 const lines: JevLine[] = []
156
157 for (const one of raw.split(/\r?\n/)) {
158 if (one.trim() === '') continue
159 try {
160 const value: unknown = JSON.parse(one)
161 if (value !== null && typeof value === 'object' && !Array.isArray(value)) lines.push(value as JevLine)
162 } catch {
163 // a torn last line while Jev writes: skipped, read again next poll
164 }
165 }
166
167 return lines
168}
169
170/** Jev's health and today's figures from its log, newest line last. */
171export function jevStatus(lines: JevLine[], today: string, isEnabled: boolean): JevStatus {
172 let isOnline: boolean | null = null
173 let lastError: string | undefined
174
175 const newestFirst = [...lines].reverse()
176
177 for (const line of newestFirst.slice(0, 400)) {
178 if (isOnline !== null) break
179 if (line.kind === 'usage') {
180 isOnline = true
181 } else if (text(line.error) !== '') {
182 isOnline = false
183 lastError = text(line.error)
184 }
185 }
186
187 let spendToday = 0
188 let deniesToday = 0
189 let weakToday = 0
190
191 for (const line of newestFirst) {
192 if (!text(line.ts).startsWith(today)) break
193 if (line.kind === 'usage' && typeof line.usd === 'number') spendToday += line.usd
194 if (line.feature === 'risk_gate' && line.decision === 'deny') deniesToday += 1
195 if (line.feature === 'report_check' && (line.decision === 'deny' || line.decision === 'weak')) weakToday += 1
196 }
197
198 const recent: JevEntry[] = []
199 for (const line of newestFirst) {
200 if (recent.length >= 8) break
201 const entry = summarize(line)
202 if (entry !== undefined) recent.push(entry)
203 }
204
205 return {
206 isOnline: isEnabled ? isOnline : false,
207 lastError: isEnabled ? lastError : 'disabled',
208 spendToday,
209 deniesToday,
210 weakToday,
211 recent,
212 }
213}
214
215/** `2026-10-06` for a clock reading, in local time as Jev stamps it. */
216export function localDay(ms: number): string {
217 const date = new Date(ms)
218 const pad = (n: number) => String(n).padStart(2, '0')
219
220 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
221}
222
223export function kTokens(n: number | undefined): string {
224 if (n === undefined) return '?'
225
226 return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
227}
228
229export function bar(fraction: number, cells: number): string {
230 const filled = Math.max(0, Math.min(cells, Math.round(fraction * cells)))
231
232 return '█'.repeat(filled) + '░'.repeat(cells - filled)
233}
234
235export function seconds(ms: number): string {
236 const s = Math.max(0, Math.round(ms / 1000))
237
238 return s < 90 ? `${s}s` : `${Math.round(s / 60)}m`
239}
240
241/** `14:05` for a clock reading, local time. */
242export function clockTime(ms: number): string {
243 const date = new Date(ms)
244 const pad = (n: number) => String(n).padStart(2, '0')
245
246 return `${pad(date.getHours())}:${pad(date.getMinutes())}`
247}
248
249// Hunch: calls through its MCP server (`mcp__hunch__hunch_context`) or its CLI through Bash.
250
251const HUNCH_MCP = /^mcp__hunch__(?:hunch_)?(.+)$/
252
253const TASK_ID = /\bhtask_[a-f0-9]{24}\b/
254
255const TARGET_KEYS = ['target', 'scope', 'topic', 'symbol', 'symptom_or_symbol', 'query', 'id', 'title'] as const
256
257// The CLI as the prompt hook prints it (`node .../@davesheffer/hunch/dist/cli/index.js task verify ...`),
258// or `hunch <sub>` / `npx @davesheffer/hunch <sub>` starting a command (matched with quoted text blanked
259// and heredoc bodies dropped, so `git commit -m "hunch update"` is not a call).
260
261// A heredoc opener (`<<EOF`, `<<-'EOF'`; not `<<<` or `1 << 2`), the rest of its line, then its body
262// up to the terminator line, or to the end when none follows.
263const HEREDOC = /(?<!<)<<-?[ \t]*(['"]?)([A-Za-z_]\w*)\1([^\n]*)(?:\n[\s\S]*?(?:\n[ \t]*\2[ \t]*(?=\r?\n|$)|$)|$)/g
264const HUNCH_LAUNCHER = /@davesheffer[\\/]+hunch[\\/]+dist[\\/]+cli[\\/]+index\.js['"]?\s+([a-z][\w-]*)(?:\s+([a-z][\w-]*))?/i
265
266const HUNCH_BARE = /(?:^|[;&|\n]|\$\()\s*(?:\(\s*)?(?:npx\s+(?:-y\s+)?)?(?:@davesheffer\/)?hunch\s+([a-z][\w-]*)(?:\s+([a-z][\w-]*))?/i
267
268const LEVEL_RANK: Record<HunchLevel, number> = { info: 0, warn: 1, alert: 2 }
269
270const BLOCKING = /^(blocking|block|error|critical|must)$/i
271
272export type HunchInvocation = {
273 name: string
274 target: string
275 taskId?: string
276}
277
278export type HunchOutcome = {
279 summary: string
280 level: HunchLevel
281 constraints: HunchConstraint[]
282 verdict?: 'BLOCK' | 'WARN' | 'PASS'
283 exitCode?: number
284 taskId?: string
285}
286
287export function maxLevel(a: HunchLevel, b: HunchLevel): HunchLevel {
288 return LEVEL_RANK[a] >= LEVEL_RANK[b] ? a : b
289}
290
291/** `context` for `mcp__hunch__hunch_context`; undefined for any other tool. */
292export function hunchName(tool: string): string | undefined {
293 return HUNCH_MCP.exec(tool)?.[1]
294}
295
296/** What an MCP Hunch call is about, read from its arguments. */
297export function hunchInvocation(name: string, args: Record<string, unknown>): HunchInvocation {
298 const taskId = text(args.task_id) || undefined
299 let target: string
300
301 if (name === 'task') {
302 target = [text(args.action), text(args.title)].filter(Boolean).join(' ')
303 } else if (name === 'merge_verdict' || name === 'pr_impact') {
304 target = text(args.base) || text(args.commit) || (args.working === true ? 'working tree' : 'staged')
305 } else if (name === 'compare' && Array.isArray(args.candidates)) {
306 target = args.candidates.map(text).filter(Boolean).join(' vs ')
307 } else {
308 target = TARGET_KEYS.map(key => text(args[key])).find(Boolean) ?? ''
309 }
310
311 return { name, target: oneLine(target), taskId }
312}
313
314/** The hunch CLI call a Bash command makes, or undefined when it makes none. */
315export function hunchCli(command: string): HunchInvocation | undefined {
316 const lines = command.replace(HEREDOC, (_all, _quote: string, tag: string, rest: string) => `<<${tag}${rest}`)
317 const match = HUNCH_LAUNCHER.exec(lines) ?? HUNCH_BARE.exec(lines.replace(/"[^"]*"|'[^']*'/g, '""'))
318 if (match === null) return undefined
319
320 const [, sub = '', action = ''] = match
321 const taskId = TASK_ID.exec(command)?.[0]
322
323 if (sub === 'task' && action === 'verify') {
324 const separator = command.indexOf(' -- ')
325 const checked = separator >= 0 ? command.slice(separator + 4) : ''
326
327 return { name: 'verify', target: oneLine(checked.replace(/\s*2>&1.*$/, '')), taskId }
328 }
329
330 return { name: `cli ${sub}`, target: action, taskId }
331}
332
333/** Grades what a Hunch call returned: a summary line, how loud, and what to toast about. */
334export function hunchOutcome(name: string, output: string, isError: boolean): HunchOutcome {
335 const taskId = TASK_ID.exec(output)?.[0]
336
337 if (name === 'verify') return verifyOutcome(output, isError)
338 if (isError) {
339 return { summary: firstLine(output) || 'failed', level: 'alert', constraints: [], taskId }
340 }
341
342 switch (name) {
343 case 'check_constraints': {
344 const constraints = [...output.matchAll(/^\s*[•*-]\s*(con_\w+)\s*\[([\w-]+)[^\]]*\]\s*(.*)$/gm)].map(match => ({
345 id: match[1] ?? '',
346 severity: (match[2] ?? '').toLowerCase(),
347 statement: oneLine(match[3] ?? ''),
348 }))
349 const blocking = constraints.filter(one => BLOCKING.test(one.severity)).length
350 const warnings = constraints.filter(one => one.severity === 'warning').length
351 const parts = [blocking > 0 ? `${blocking} blocking` : '', warnings > 0 ? `${warnings} warning` : ''].filter(Boolean)
352
353 return {
354 summary:
355 constraints.length === 0
356 ? 'no constraints in scope'
357 : `${constraints.length} constraint${constraints.length === 1 ? '' : 's'}${parts.length > 0 ? ` (${parts.join(', ')})` : ''}`,
358 level: blocking > 0 ? 'alert' : warnings > 0 ? 'warn' : 'info',
359 constraints,
360 taskId,
361 }
362 }
363 case 'merge_verdict': {
364 const verdict = /VERDICT:\W*(BLOCK|WARN|PASS)/i.exec(output)?.[1]?.toUpperCase() as HunchOutcome['verdict']
365 const scope = /\(scope:\s*(.+?)\)\s*$/m.exec(output)?.[1]
366
367 return {
368 summary: `verdict ${verdict ?? '?'}${scope !== undefined ? ` · ${scope}` : ''}`,
369 level: verdict === 'BLOCK' ? 'alert' : verdict === 'WARN' ? 'warn' : 'info',
370 constraints: [],
371 verdict,
372 taskId,
373 }
374 }
375 case 'escalations': {
376 if (/nothing needs/i.test(output) || output.trim() === '') {
377 return { summary: 'none', level: 'info', constraints: [], taskId }
378 }
379 const headline = /(\d+)\s+decisions?\s+needs?\b/i.exec(output)?.[1]
380 const items = output.split(/\r?\n/).filter(line => /^\s*(?:[-*•⚖·]|\d+[.)])\s+/.test(line)).length
381 const count = headline !== undefined ? Number(headline) : Math.max(1, items)
382
383 return { summary: `${count} need your decision`, level: 'alert', constraints: [], taskId }
384 }
385 default:
386 return { summary: firstLine(output), level: 'info', constraints: [], taskId }
387 }
388}
389
390/**
391 * `task verify` streams the checked command's output, then its result JSON last: the last
392 * `"exit_code"` is the launcher's. Piped through `tail` or cut by the Bash output cap the JSON
393 * can be gone; then the Bash call's own exit (`Exit code N`, isError) is the evidence.
394 */
395function verifyOutcome(output: string, isError: boolean): HunchOutcome {
396 const taskId = TASK_ID.exec(output)?.[0]
397 const codes = [...output.matchAll(/"exit_code"\s*:\s*(-?\d+)/g)]
398 const jsonCode = codes.at(-1)?.[1]
399 const bashCode = /^Exit code (-?\d+)/m.exec(output)?.[1]
400 // On an errored call core's `Exit code N` beats a 0 that may be the checked command's own JSON
401 // (the launcher's cut off); otherwise an `Exit code` line is the command's stdout, not evidence.
402 const code = isError && bashCode !== undefined && bashCode !== '0' ? bashCode : (jsonCode ?? bashCode)
403 const isTimedOut = /"timed_out"\s*:\s*true/.test(output)
404
405 if (code === undefined) {
406 if (isTimedOut && isError) return { summary: 'timed out', level: 'alert', constraints: [], taskId }
407
408 return isError
409 ? { summary: `failed: ${firstLine(output) || 'no exit code'}`, level: 'alert', constraints: [], taskId }
410 : { summary: 'exit code not in output (piped or truncated?)', level: 'warn', constraints: [], taskId }
411 }
412 const exitCode = Number(code)
413
414 return {
415 summary: `exit ${exitCode}${isTimedOut ? ' (timed out)' : ''}`,
416 level: exitCode === 0 && !isTimedOut && !isError ? 'info' : 'alert',
417 constraints: [],
418 exitCode,
419 taskId,
420 }
421}
422
423/** A call still running this long never got its result recorded (a lost abort, a reload); past Bash's 10 min cap. */
424export const STALE_MS = 15 * 60_000
425
426/** Marks running calls older than STALE_MS as errored, so the pane and band stop waiting on them. */
427export function reapStale(calls: readonly HunchCall[], now: number): HunchCall[] {
428 return calls.map(call =>
429 call.status === 'running' && now - call.startedAt > STALE_MS
430 ? { ...call, status: 'error', level: 'warn', durationMs: now - call.startedAt, summary: 'no result recorded' }
431 : call,
432 )
433}
434
435/** Whether an abort ended the call: Bash's own `interrupted`, or the marker text core gives the model. */
436export function wasInterrupted(result: unknown, text: string): boolean {
437 if (typeof result === 'object' && result !== null && (result as { interrupted?: unknown }).interrupted === true) return true
438
439 return /^\[?Request interrupted by user|^Interrupted by user/i.test(text.trim())
440}
441
442function oneLine(value: string): string {
443 return value.replace(/\s+/g, ' ').trim().slice(0, 120)
444}
445
446/** The first line that says something: no rule, bare heading mark or footer. */
447function firstLine(output: string): string {
448 for (const raw of output.split(/\r?\n/)) {
449 const line = raw
450 .replace(/<[^>]+>/g, '')
451 .replace(/^[\s#>*_`•-]+/, '')
452 .replace(/\*\*/g, '')
453 .trim()
454 if (line !== '' && !/^[-=_─]{3,}$/.test(line) && line !== '{') return oneLine(line)
455 }
456
457 return ''
458}
459
460// Sessions: the registry Claude Code keeps at `~/.claude/sessions/<pid>.json`, one file per live process.
461
462const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
463
464/** The last path segment of a Windows or POSIX path. */
465export function repoName(cwd: string): string {
466 return cwd.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1) ?? ''
467}
468
469/** One registry file as a row; undefined for anything that is not a session entry. */
470export function sessionRow(raw: string): SessionRow | undefined {
471 let entry: unknown
472 try {
473 entry = JSON.parse(raw)
474 } catch {
475 return undefined
476 }
477 if (typeof entry !== 'object' || entry === null) return undefined
478
479 const { pid, sessionId, cwd, name, entrypoint, status, updatedAt, startedAt } = entry as Record<string, unknown>
480 if (typeof pid !== 'number' || typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) return undefined
481
482 const folder = typeof cwd === 'string' ? cwd : ''
483 const id = sessionId.toLowerCase()
484
485 return {
486 pid,
487 sessionId: id,
488 name: typeof name === 'string' && name.trim() !== '' ? oneLine(name) : repoName(folder) || id.slice(0, 8),
489 cwd: folder,
490 entrypoint: typeof entrypoint === 'string' ? entrypoint : 'unknown',
491 status: status === 'idle' || status === 'busy' ? status : 'unknown',
492 updatedAt: typeof updatedAt === 'number' ? updatedAt : typeof startedAt === 'number' ? startedAt : 0,
493 }
494}
495
496/** Newest first, one row per session: a crashed process can leave an older file for the same id. */
497export function sessionRows(rows: readonly SessionRow[]): SessionRow[] {
498 const newest = new Map<string, SessionRow>()
499 for (const row of rows) {
500 const seen = newest.get(row.sessionId)
501 if (seen === undefined || row.updatedAt > seen.updatedAt) newest.set(row.sessionId, row)
502 }
503
504 return [...newest.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name))
505}
506
507/** `45s`, `12m`, `3h`, `2d`: how long ago, coarsely. */
508export function ago(ms: number): string {
509 const s = Math.max(0, Math.round(ms / 1000))
510 if (s < 90) return `${s}s`
511 if (s < 90 * 60) return `${Math.round(s / 60)}m`
512 if (s < 36 * 3600) return `${Math.round(s / 3600)}h`
513
514 return `${Math.round(s / 86400)}d`
515}
516
517// Activity: one short plain-language line per thing the session does.
518
519const HUNCH_VERB: Record<string, string> = {
520 context: 'brief on',
521 check_constraints: 'check rules for',
522 why: 'why',
523 bug_lineage: 'past bugs for',
524 blast_radius: 'blast radius of',
525 get_dependents: 'dependents of',
526 current_decision: 'current decision on',
527 record_decision: 'record decision',
528 record_correction: 'record correction',
529 record_finding: 'record finding',
530 capture_decision: 'capture decision',
531 merge_verdict: 'merge verdict for',
532 pr_impact: 'PR impact of',
533 conformance: 'conformance check',
534 escalations: 'open escalations',
535 structure: 'repo map',
536 query: 'search',
537 verify: 'check',
538}
539
540/** The last segment of a path, for a short label. */
541export function fileName(path: string): string {
542 return path.split(/[\\/]+/).filter(Boolean).at(-1) ?? path
543}
544
545export function clip(value: string, max: number): string {
546 const line = value.replace(/\s+/g, ' ').trim()
547
548 return line.length > max ? `${line.slice(0, max - 1)}…` : line
549}
550
551/** `hh:mm:ss`, local time. */
552export function clockSeconds(ms: number): string {
553 const date = new Date(ms)
554
555 return `${clockTime(ms)}:${String(date.getSeconds()).padStart(2, '0')}`
556}
557
558const DETAIL_MAX = 4000
559
560/** The full text behind a timeline line (a command, a prompt, an edit), for its drawer; '' when the line says it all. */
561export function toolDetail(tool: string, args: Record<string, unknown>): string {
562 let detail: string
563 switch (tool) {
564 case 'Bash':
565 case 'PowerShell':
566 detail = text(args.command)
567 break
568 case 'Agent':
569 detail = text(args.prompt)
570 break
571 case 'Edit':
572 detail = `${text(args.file_path)}\n--- old\n${text(args.old_string)}\n+++ new\n${text(args.new_string)}`
573 break
574 case 'Write':
575 detail = `${text(args.file_path)}\n${text(args.content)}`
576 break
577 case 'Read':
578 case 'Glob':
579 case 'Grep':
580 case 'ToolSearch':
581 case 'Skill':
582 detail = ''
583 break
584 default: {
585 const reserved = ['tool', 'tool_use_id', 'agentId', 'consent']
586 const rest = Object.fromEntries(Object.entries(args).filter(([key]) => !reserved.includes(key)))
587 detail = tool.startsWith('mcp__') && Object.keys(rest).length > 0 ? JSON.stringify(rest, null, 2) : ''
588 }
589 }
590
591 return capDetail(detail)
592}
593
594/** Cuts drawer text to DETAIL_MAX characters, saying how much was left out. */
595export function capDetail(detail: string): string {
596 return detail.length > DETAIL_MAX ? `${detail.slice(0, DETAIL_MAX)}\n… (${detail.length - DETAIL_MAX} more characters)` : detail
597}
598
599/** What a tool call does, in a few plain words: `read parse.ts`, `start critic (fable): Review diff`. */
600export function describeTool(tool: string, args: Record<string, unknown>): string {
601 const file = fileName(text(args.file_path) || text(args.notebook_path) || text(args.path))
602 const hunch = hunchName(tool)
603 if (hunch !== undefined) {
604 const { target } = hunchInvocation(hunch, args)
605 const verb = HUNCH_VERB[hunch] ?? hunch.replace(/_/g, ' ')
606
607 return clip(`Hunch: ${verb}${target === '' ? '' : ` ${target}`}`, 80)
608 }
609
610 switch (tool) {
611 case 'Bash':
612 case 'PowerShell': {
613 const command = text(args.command)
614 const cli = hunchCli(command)
615 const what = text(args.description) || command
616 if (cli?.name === 'verify') return clip(`check: ${what}`, 80)
617
618 return clip(`run: ${what}`, 80)
619 }
620 case 'Read':
621 return `read ${file}`
622 case 'Edit':
623 case 'NotebookEdit':
624 return `edit ${file}`
625 case 'Write':
626 return `write ${file}`
627 case 'Grep':
628 return clip(`search for "${text(args.pattern)}"${file === '' ? '' : ` in ${file}`}`, 80)
629 case 'Glob':
630 return clip(`find files ${text(args.pattern)}`, 80)
631 case 'Agent': {
632 const role = roleOf(text(args.subagent_type) || 'general-purpose')
633 const model = text(args.model)
634
635 return clip(`start ${role}${model === '' ? '' : ` (${model})`}: ${text(args.description)}`, 80)
636 }
637 case 'SendMessage':
638 return 'message an agent'
639 case 'Skill':
640 return `load skill ${text(args.skill)}`
641 case 'ToolSearch':
642 return 'load tool definitions'
643 case 'WebSearch':
644 return clip(`web search "${text(args.query)}"`, 80)
645 case 'WebFetch':
646 return clip(`fetch ${text(args.url).replace(/^https?:\/\//, '').split('/')[0] ?? ''}`, 80)
647 case 'AskUserQuestion':
648 return 'ask you a question'
649 case 'Artifact':
650 return `artifact ${text(args.action) || 'publish'}`
651 }
652
653 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
654 if (mcp !== null) return clip(`${mcp[1]?.replace(/^claude_ai_/, '')}: ${mcp[2]?.replace(/_/g, ' ')}`, 80)
655
656 return tool
657}
658types/index.d.ts 153 lines1export type AgentStatusMark = 'running' | 'done' | 'aborted' | 'error'
2
3/** One subagent the session spawned, from `agent.spawn` to its `turn.complete`. */
4export type AgentRun = {
5 id: string
6 type: string
7 model: string
8 description: string
9 isBackground: boolean
10 startedAt: number
11 endedAt?: number
12 status: AgentStatusMark
13}
14
15export type Confidence = 'high' | 'medium' | 'low' | 'missing'
16
17/** The RESULT / EVIDENCE / CONFIDENCE / UNVERIFIED card a role agent hands back. */
18export type ReportCard = {
19 agentId: string
20 type: string
21 model: string
22 at: number
23 durationMs: number
24 confidence: Confidence
25 missing: string[]
26 unverified: number
27 exitCodes: number[]
28 isWeak: boolean
29 result: string
30}
31
32export type Zone = 'green' | 'amber' | 'red' | 'unknown'
33
34/** The relay gauge: live context tokens against relay/config.json thresholds. */
35export type Gauge = {
36 tokens?: number
37 window: number
38 percent?: number
39 zone: Zone
40 soft: number
41 hard: number
42 usd?: number
43}
44
45/** One Jev decision line, summarized for display. */
46export type JevEntry = {
47 ts: string
48 feature: string
49 summary: string
50 isAlert: boolean
51}
52
53export type JevStatus = {
54 /** null while no recent line says either way. */
55 isOnline: boolean | null
56 lastError?: string
57 spendToday: number
58 deniesToday: number
59 weakToday: number
60 recent: JevEntry[]
61 note?: string
62}
63
64export type HunchLevel = 'info' | 'warn' | 'alert'
65
66export type HunchCallStatus = 'running' | 'ok' | 'error' | 'denied'
67
68/** One Hunch call: an `mcp__hunch__*` tool, or the hunch CLI run through Bash (`task verify`). */
69export type HunchCall = {
70 /** The tool_use_id. */
71 id: string
72 /** `context`, `check_constraints`, `verify`, `cli update`, ... */
73 name: string
74 target: string
75 taskId?: string
76 /** `main`, or the subagent role that made the call. */
77 role: string
78 startedAt: number
79 durationMs?: number
80 status: HunchCallStatus
81 summary: string
82 level: HunchLevel
83}
84
85/** Constraint lines `hunch_check_constraints` returned. */
86export type HunchConstraint = {
87 id: string
88 severity: string
89 statement: string
90}
91
92export type HunchLog = {
93 /** The newest htask_ id seen in a call's arguments or result. */
94 taskId?: string
95 calls: HunchCall[]
96 total: number
97 /** Constraint ids already toasted this session, so a `**` invariant toasts once. */
98 seenConstraints: string[]
99}
100
101/** One open Claude Code session, from its `~/.claude/sessions/<pid>.json` registry file. */
102export type SessionRow = {
103 pid: number
104 sessionId: string
105 name: string
106 cwd: string
107 /** `claude-vscode` (a VS Code tab the bridge can switch to) or `cli` (a terminal). */
108 entrypoint: string
109 /** `idle` or `busy`, as the session last wrote it; anything else is `unknown`. */
110 status: 'idle' | 'busy' | 'unknown'
111 updatedAt: number
112}
113
114/** One line of the activity timeline: a prompt, a tool call, an agent finishing, a turn ending. */
115export type ActivityEntry = {
116 /** The tool_use_id for a tool call; a made-up id otherwise. */
117 id: string
118 at: number
119 /** `you`, `main`, or the subagent role. */
120 who: string
121 /** A few plain words: `read parse.ts`, `run: Run unit tests`. */
122 text: string
123 status: 'running' | 'ok' | 'error' | 'stopped' | 'note'
124 durationMs?: number
125 /** The full command, prompt or edit, shown in the line's drawer when opened. */
126 detail?: string
127}
128
129declare module 'claude-code' {
130 interface PluginState {
131 'mission-control': {
132 agents: AgentRun[]
133 reports: ReportCard[]
134 gauge: Gauge | null
135 jev: JevStatus | null
136 hunch: HunchLog
137 isBandHidden: boolean
138 tick: number
139 sessions: SessionRow[]
140 /** When the session registry was last polled, rounded down to 30 s so the pane's ages redraw. */
141 sessionsCheckedAt: number
142 /** The newest activity lines, oldest first. */
143 activity: ActivityEntry[]
144 /** Whether the /orch pane shows its full report cards, Hunch calls and Jev log. */
145 showDetails: boolean
146 /** Timeline lines whose drawer is open, by entry id. */
147 openEntries: string[]
148 /** Whether the main loop is mid-turn: set by a prompt, cleared by the main loop's turn end. */
149 turnOpen: boolean
150 }
151 }
152}
153