SLOPSHOPPER

mission-control

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ Mission Control ✕ › fix the failing auth test and add an audit log call │ ┃ Now │ ┃ main idle, waiting for you ⏺ Read(src/auth.ts) │ ┃ Timeline ⎿ Read 6 lines │ ┃ 08:53:20 main · answered (turn took 42 ⏺ Update(src/auth.ts) │ ┃ [ ▸ ] 08:53:20 main ✔ run: Show environm ⎿ Added 2 lines, removed 1 line │ ┃ [ ▸ ] 08:53:20 main ✔ run: Clean build o ⏺ Bash(bun test) │ ┃ [ ▸ ] 08:53:20 main ✔ run: Show working ⎿ 3 pass, 1 fail │ ┃ [ ▸ ] 08:53:20 main ✖ run: Run the test │ ┃ [ ▸ ] 08:53:20 main ✔ write cache.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ ▸ ] 08:53:20 main ✔ write audit.ts │ ┃ [ ▸ ] 08:53:20 main ✔ edit auth.ts ✻ Worked for 42s · done 4:20 PM │ ┃ 08:53:20 main ✔ search for "refresh\(" │ ┃ 08:53:20 main ✔ read auth.ts › /orch │ ┃ 08:53:20 you · asked: "fix the failin ⎿ mission-control: Mission Control opened. │ ┃ Summary │ ┃ context 97k of 250k · GREEN · keep going · │ ┃ agents 0 running │ ┃ hunch 0 calls · no warnings │ ┃ jev unknown │ ┃ [ details ] [ toggle band ] [ clear done ] │ ● GREEN 97k/250k ████░░░░░░ Jev ? 🧠 hunch 0 now: idle, waiting for you [ orch ] [ 🗂 0 ] [ hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● GREEN 97k/250k ████░░░░░░ Jev ? 🧠 hunch 0 now: idle, waiting for you [ orch ] [ 🗂 0 ] [ h
Pane · Mission Control
Now main idle, waiting for you Timeline 08:53:20 main · answered (turn took 42s) [ ▸ ] 08:53:20 main ✔ run: Show environment file [ ▸ ] 08:53:20 main ✔ run: Clean build output and push [ ▸ ] 08:53:20 main ✔ run: Show working tree status [ ▸ ] 08:53:20 main ✖ run: Run the test suite [ ▸ ] 08:53:20 main ✔ write cache.ts [ ▸ ] 08:53:20 main ✔ write audit.ts [ ▸ ] 08:53:20 main ✔ edit auth.ts 08:53:20 main ✔ search for "refresh\(" in src 08:53:20 main ✔ read auth.ts 08:53:20 you · asked: "fix the failing auth test a… Summary context 97k of 250k · GREEN · keep going · $0.42 agents 0 running hunch 0 calls · no warnings jev unknown [ details ] [ toggle band ] [ clear done ]
Pane · Sessions
Open sessions (0) No Claude Code sessions registered. switch asks the handoff bridge in the session's VS Code… [ refresh ]
README

Mission Control

A Claude Code mod (a plugin of function hooks) that shows the orchestrator's state inside Claude Code.

What it shows

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.
  • The relay zone (GREEN / AMBER / RED) and context tokens, against relay/config.json soft_tokens / hard_tokens.
  • Whether Jev is online, or the reason it is not (for example NoApiKey).
  • How many Hunch calls the current task has made; ⚠N counts the ones that warned or failed.
  • The subagents running now, as role·model.
  • The confidence of the last role report; ⚠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:

  • Now: what the main loop and each running subagent is doing, and for how long.
  • Timeline: the last 15 things that happened, newest first, one line each: time, who (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.
  • Summary: context use and what the zone means, agents running and the last report's confidence, Hunch calls and warnings, Jev online or not.

details opens the full sections below:

  • Relay: the gauge, the session's cost and what the zone asks of you.
  • In flight: the running subagents and how long each has run.
  • Report cards: the last six scout / runner / builder / critic reports, parsed for RESULT / EVIDENCE / CONFIDENCE / UNVERIFIED, exit codes and missing fields.
  • Hunch: the current 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.
  • Jev: online or offline, today's spend, risk-gate denies and weak reports, and its last eight decisions from 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.

Install

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.

Develop

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.

Source 3 files
hooks/register.tsx 996 lines
1import { 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}
996
hooks/parse.ts 658 lines
1import 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}
658
types/index.d.ts 153 lines
1export 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