SLOPSHOPPER

claude-code-cockpit

Cockpit pane and band for Claude Code: interview clarity meter with a rule enforcer and auto panel, AI quota, live hooks, and background subagent runs

newpanebandguardcommandtoast
v0.3.0MITupdated 2026-10-07m-esm/claude-code-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-code-cockpit
│ ┃ Cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ Cockpit 15:23:33 r: Refresh │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ Clarity │ ⎿ Read 6 lines │ ┃ │ no interview │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ ▾ Questioning ● no interview │ │ ┃ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ No interview yet. Run /questioning and │ │ ┃ │ the meter fills in here as questions go │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ out. │ │ ┃ │ │ › /cockpit │ ┃ ╰──────────────────────────────────────────╯ ⎿ claude-code-cockpit: Cockpit open. │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ▾ Quota ● error │ │ ┃ │ │ │ ┃ │ ╭──────────────────────────────────────╮ │ │ ┃ │ │ Feed error │ │ │ ┃ │ │ Error: │ │ │ ┃ │ │ ~/.claude/scripts/ai-cli-usage.py │ │ │ ┃ │ │ not found. Install smart-subagents │ │ │ ┃ │ │ (https://github.com/m-esm/smart-suba │ │ │ ┃ │ │ gents) to see quota. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Cockpit
Cockpit 15:23:33 r: Refresh ╭──────────────────────────────────────────────────────────╮ │ Clarity │ │ no interview │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▾ Questioning ● no interview │ │ │ │ No interview yet. Run /questioning and the meter fills │ │ in here as questions go out. │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▾ Quota ● error │ │ │ │ ╭──────────────────────────────────────────────────────╮ │ │ │ Feed error │ │ │ │ Error: ~/.claude/scripts/ai-cli-usage.py not found. │ │ │ │ Install smart-subagents │ │ │ │ (https://github.com/m-esm/smart-subagents) to see │ │ │ │ quota. │ │ │ ╰──────────────────────────────────────────────────────╯ │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▾ Hooks ● 0 running · avg 0 ms │ │ │ │ 11 hook runs · avg 0 ms │ │ │ │ Live │ │ 15:23:33 ● Stop ok │ │ 15:23:33 ● PostToolUse · Bash ok │ │ 15:23:33 ● PostToolUse · Bash ok │ │ 15:23:33 ● PostToolUse · Bash ok │ │ 15:23:33 ● PostToolUse · Bash ok │ │ 15:23:33 ● PostToolUse · Write ok │ │ │ │ Gate and Jev log │ │ No log rows yet. │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▾ Subagents ● 0 recent │ │ │ │ Running now │ │ Nothing running. │ │ │ │ History │ │ No runs recorded yet. │ │ │ ╰──────────────────────────────────────────────────────────╯
README

claude-code-cockpit

A Claude Code mod that adds a Cockpit pane and a status band above the prompt. It shows how clear an interview has become, enforces the interview's rules, and keeps an eye on the agent tooling around it: AI quota, hook runs, and background subagent runs.

It works in the Claude Code desktop app (Code tab), where charts draw as SVG, and in the terminal, where they fall back to text bars.

ClarityQuota
Clarity gauge and trend across stop checksRemaining quota per CLI
Hook runsSubagent history
Recent hook runs by duration and outcomeRecent subagent runs by outcome

The images above come from hooks/charts.ts with sample data, rendered by scripts/render-docs.ts.

What you get

Cockpit pane. A row of status tiles, then one collapsible card per section:

  • Questioning. A clarity gauge (0 to 10) with its trend across stop checks, the current picture and what would be built now, slot chips (settled, partial, open), open questions ranked by impact with the cost of guessing wrong, covered and thin lenses, and the enforcer's counters.
  • Quota. Remaining quota per AI CLI, the tightest window first in color, reset times, and the recommended next worker.
  • Hooks. Every settings hook run this session with its duration and outcome, the last block, and rows from optional gate logs.
  • Subagents. Background runs in progress, a strip of recent outcomes, and a history kept across sessions (100 runs or 30 days).

Band above the prompt. Clarity during an interview, a pending auto-panel countdown, the tightest quota, live subagent runs, and any hook block from the last ten minutes. c opens the Cockpit, h hides the band.

Interview enforcer. Once an interview reports start through the meter tool, the mod:

  • blocks an AskUserQuestion that has no fresh meter call,
  • blocks the question that would pass 4 answers without a stop check,
  • warns the model (in the question's result, not to you) on a third question in a row under one lens, or on more questions at clarity 8 with nothing high-impact open.

Nothing is blocked until an interview starts, so a skill that never calls the meter is never affected.

Auto panel. When an Approach or Risks item stays open across two stop checks, the pane counts down 30 seconds with a Cancel button, then runs a three-planner smart-subagents panel on it and fills a follow-up into your prompt box when it lands. It runs at most once per open item, and skips itself when quota is missing, stale, or exhausted.

Requirements

  • Claude Code with function-hook mods. Built and tested against Claude Code 2.1.288.
  • Optional: smart-subagents installed so that ~/.claude/scripts/ai-cli-usage.py and ~/.claude/scripts/smart-subagents.sh exist. Without it the Quota card says so and the auto panel stays off; everything else works.
  • Optional: JSONL logs at ~/.claude/cache/jev-hooks.jsonl and ~/.claude/cache/delegation-gate.jsonl from your own hooks. When absent, the Hooks card shows live runs only.

Install

Clone it anywhere:

git clone https://github.com/m-esm/claude-code-cockpit.git ~/claude-code-cockpit

Terminal, for one session:

claude --plugin-dir ~/claude-code-cockpit

Desktop app, or every session: add the folder to the env block of ~/.claude/settings.json, then start a new session.

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-code-cockpit"
  }
}

Open the pane with /cockpit (or /clarity). /clarity reset clears the interview state.

Reporting an interview: the meter tool

The mod registers mcp__claude-code-cockpit__meter. Any interviewing skill can report through it. Call it right before each AskUserQuestion and await the result.

eventWhen
startThe interview opens. Arms the enforcer.
questionBefore every ordinary AskUserQuestion, listing its questions with lens and move.
stop_checkWhen you show the stop check (progress summary), listing its question. Resets the 4-answer counter.
finishAt wrap-up. Disarms the enforcer.

Every call carries the whole current picture. Keep each open item's id stable across stop checks, because the auto panel tracks items by id.

{
  "event": "stop_check",
  "topic": "billing rewrite",
  "phase": "probing",
  "clarity": 6,
  "picture": "Move invoicing off the cron job onto queue workers.",
  "buildNow": "A queue consumer that mirrors the cron job's output.",
  "assuming": ["Invoices can be generated out of order"],
  "slots": [
    { "id": "goal", "label": "Goal", "state": "settled" },
    { "id": "approach", "label": "Approach", "state": "partial" }
  ],
  "openItems": [
    { "id": "retry-policy", "slotId": "approach", "question": "Retry or dead-letter failed invoices?", "impact": "high", "costOfWrong": "duplicate charges" }
  ],
  "lensesCovered": ["User", "Outcome"],
  "lensesThin": ["Risk"],
  "questions": [{ "text": "Where next?", "lens": "Risk", "move": "Converge" }]
}

Lenses: User, Outcome, Scope, Execution, Constraint, Risk, Alternative, Contrarian. Moves: Clarify, Dig, Explore, Challenge, Brainstorm, Panel, Converge. The full JSON schema is METER_SCHEMA in hooks/interview.ts.

A second tool, mcp__claude-code-cockpit__status, returns the whole Cockpit as JSON for an agent to read.

Layout

PathWhat it holds
hooks/register.tsxHook wiring: tools, enforcer, feeds and timers, auto panel, render hooks
hooks/components.tsxThe Cockpit, its section cards and the band
hooks/charts.tsSVG chart builders with a light and dark palette
hooks/interview.tsMeter schema, interview state machine and enforcer rules
hooks/feeds.tsParsers for quota, hook logs and subagent run folders
hooks/meter.tsFallback parser for a stop check printed as text
types/index.d.tsThe mod's state contract
tests/Enforcer, feed, chart and render tests

Development

claude plugin validate .
claude plugin test .
node --experimental-strip-types scripts/render-docs.ts --check

tsc -p . type-checks once Claude Code has loaded the folder, because the engine writes the API types into .claude-plugin/types/ (ignored by git) on load. After changing hooks/charts.ts, regenerate the README images with node --experimental-strip-types scripts/render-docs.ts.

License

MIT

Source 8 files
hooks/register.tsx 651 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register, Timer } from 'claude-code'
3
4import type { HookRow, HooksSnapshot, Interview, Meter, Panel, QuotaSnapshot, Sections, SsaRun, SsaSnapshot } from '../types'
5import { emptyFeed, feedError, feedOk, gateRows, isRunList, jevRows, jsonLines, lowestWindow, mergeHistory, parseQuota, planRun, quotaBlocksPanel, startedAtFromId, taskRun, tightestPct } from './feeds'
6import { ago, clock, until } from './format'
7import { applyMeter, checkAsk, countAnswered, emptyInterview, METER_DESCRIPTION, METER_SCHEMA, meterFromInput, panelCandidate, panelGoal, parseMeterInput, recordAnswers, STOP_EVERY } from './interview'
8import { replay } from './meter'
9import { Band, Cockpit } from './components'
10import type { CockpitActions, CockpitData } from './components'
11
12const PANE = 'claude-code-cockpit'
13const TITLE = 'Cockpit'
14const METER_TOOL = 'mcp__claude-code-cockpit__meter'
15const COUNTDOWN_MS = 30000
16const TICK_MS = 2000
17const ACTIVE_WINDOW_MS = 30000
18const QUOTA_ACTIVE_MS = 180000
19const QUOTA_IDLE_MS = 900000
20const FILES_ACTIVE_MS = 4000
21const FILES_IDLE_MS = 60000
22const LOG_TAIL_BYTES = 65536
23const ROWS = 40
24const USAGE_SCRIPT = '.claude/scripts/ai-cli-usage.py'
25const PANEL_SCRIPT = '.claude/scripts/smart-subagents.sh'
26const SSA_URL = 'https://github.com/m-esm/smart-subagents'
27
28const IDLE_PANEL: Panel = { phase: 'idle', itemId: '', label: '', deadline: 0, message: '', startedAt: 0, goalFile: '' }
29const ALL_OPEN: Sections = { questioning: true, quota: true, hooks: true, subagents: true }
30
31const meter = atom({ plugin: 'claude-code-cockpit', key: 'meter' } as const, null)
32const isBandHidden = atom({ plugin: 'claude-code-cockpit', key: 'isBandHidden' } as const, false)
33const interview = atom({ plugin: 'claude-code-cockpit', key: 'interview' } as const, emptyInterview())
34const panel = atom({ plugin: 'claude-code-cockpit', key: 'panel' } as const, IDLE_PANEL)
35const quota = atom({ plugin: 'claude-code-cockpit', key: 'quota' } as const, emptyFeed<QuotaSnapshot>())
36const hooks = atom({ plugin: 'claude-code-cockpit', key: 'hooks' } as const, emptyFeed<HooksSnapshot>())
37const ssa = atom({ plugin: 'claude-code-cockpit', key: 'ssa' } as const, emptyFeed<SsaSnapshot>())
38const sections = atom({ plugin: 'claude-code-cockpit', key: 'sections' } as const, ALL_OPEN)
39
40const poll = { quotaAt: 0, logsAt: 0, ssaAt: 0, quotaBusy: false, logsBusy: false, ssaBusy: false }
41const logs: { jev: HookRow[]; gate: HookRow[]; size: Record<string, number> } = { jev: [], gate: [], size: {} }
42const live: { rows: HookRow[]; inflight: number; dirty: boolean } = { rows: [], inflight: 0, dirty: false }
43const runs: { history: SsaRun[]; cache: Map<string, { mtime: number; run: SsaRun }>; states: Map<string, string> } = {
44  history: [],
45  cache: new Map(),
46  states: new Map(),
47}
48const timers: { tick: Timer | null; countdown: Timer | null; countdownTicker: Timer | null } = { tick: null, countdown: null, countdownTicker: null }
49const host: { home: string; tmp: string; cwd: string; lastRenderAt: number; lastOpen: string } = { home: '', tmp: '', cwd: '', lastRenderAt: 0, lastOpen: 'not opened' }
50
51const join = (dir: string, name: string): string => (dir.endsWith('/') ? `${dir}${name}` : `${dir}/${name}`)
52
53const isFresh = (before: Meter | null, after: Meter | null): boolean =>
54  after !== null && (before === null || (before.status !== 'interviewing' && after.status === 'interviewing'))
55
56const isRecord = (value: unknown): value is Record<string, unknown> =>
57  typeof value === 'object' && value !== null && !Array.isArray(value)
58
59const hookTool = (e: unknown): string => {
60  if (!isRecord(e)) return ''
61  const tool = e.tool ?? e.tool_name
62  return typeof tool === 'string' ? tool : ''
63}
64
65const hookOutcome = (result: unknown): string => {
66  if (!isRecord(result)) return 'ok'
67  if (typeof result.deny === 'string') return 'deny'
68  if (typeof result.block === 'string') return 'block'
69  if (typeof result.ask === 'string') return 'ask'
70  if (result.permissionDecision === 'deny') return 'deny'
71  if (Array.isArray(result.additionalContext) && result.additionalContext.length > 0) return 'context'
72  return 'ok'
73}
74
75const isBlocking = (row: HookRow): boolean => row.outcome === 'deny' || row.outcome === 'block' || row.outcome.startsWith('fired')
76
77const recordLive = (event: string, tool: string, startedAt: number, outcome: string): void => {
78  live.rows = [...live.rows, { at: Date.now(), source: 'live' as const, event, tool, ms: Date.now() - startedAt, outcome, detail: '' }].slice(-ROWS)
79  live.dirty = true
80}
81
82async function openPane($: Engine, focus = false): Promise<void> {
83  try {
84    const opened = await $.ui.open(focus ? { id: PANE, title: TITLE, focus: true } : { id: PANE, title: TITLE })
85    host.lastOpen = opened.isPlaced ? 'placed' : `waiting: ${opened.reason}`
86  } catch (error) {
87    host.lastOpen = `failed: ${String(error)}`
88  }
89}
90
91async function paths($: Engine): Promise<{ home: string; tmp: string; cwd: string }> {
92  if (!host.home) {
93    host.home = (await $.env.get('HOME')) ?? ''
94    host.tmp = (await $.env.get('TMPDIR')) ?? '/tmp/'
95    const pwd = await $.process.run(['pwd'], { timeoutMs: 5000 }).catch(() => null)
96    host.cwd = pwd?.stdout.trim() ?? ''
97  }
98  return host
99}
100
101async function readSmall($: Engine, path: string): Promise<string> {
102  try {
103    const text = await $.fs.read(path)
104    return typeof text === 'string' ? text : ''
105  } catch {
106    return ''
107  }
108}
109
110async function refreshFromText($: Engine, extra?: string): Promise<void> {
111  const before = await read($, meter)
112  if (before?.source === 'tool') return
113  const messages = await $.session.messages()
114  if (!Array.isArray(messages)) return
115  const texts = messages.filter(message => message.role === 'assistant').map(message => message.text)
116  if (extra) texts.push(extra)
117  const rebuilt = replay(texts)
118  if (rebuilt === null) return
119  const tagged: Meter = { ...rebuilt, source: 'text' }
120  if (before !== null && JSON.stringify(before) === JSON.stringify(tagged)) return
121  await update($, meter, () => tagged)
122  if (isFresh(before, tagged)) {
123    await update($, isBandHidden, () => false)
124    void openPane($)
125  }
126}
127
128async function pollQuota($: Engine, now: number): Promise<void> {
129  if (poll.quotaBusy) return
130  poll.quotaBusy = true
131  poll.quotaAt = now
132  try {
133    const { home } = await paths($)
134    const script = join(home, USAGE_SCRIPT)
135    if (!(await $.fs.exists(script))) throw new Error(`~/${USAGE_SCRIPT} not found. Install smart-subagents (${SSA_URL}) to see quota.`)
136    const ran = await $.process.run(['python3', script, '--json'], { timeoutMs: 90000 })
137    if (ran.exitCode !== 0) throw new Error(`ai-cli-usage exited ${ran.exitCode}: ${ran.stderr.trim().split('\n').pop() ?? ''}`)
138    const snapshot = parseQuota(ran.stdout)
139    const at = await $.clock.now()
140    await update($, quota, feed => feedOk(feed, snapshot, at))
141  } catch (error) {
142    const at = await $.clock.now()
143    await update($, quota, feed => feedError(feed, String(error), at))
144  } finally {
145    poll.quotaBusy = false
146  }
147}
148
149async function tailLog($: Engine, path: string): Promise<unknown[] | null> {
150  let size = 0
151  try {
152    size = (await $.fs.stat(path)).size
153  } catch {
154    return null
155  }
156  if (logs.size[path] === size) return null
157  logs.size[path] = size
158  const ran = await $.process.run(['tail', '-c', String(LOG_TAIL_BYTES), path], { timeoutMs: 5000 })
159  return jsonLines(ran.stdout, size > LOG_TAIL_BYTES)
160}
161
162async function pollLogs($: Engine, now: number): Promise<void> {
163  if (poll.logsBusy) return
164  poll.logsBusy = true
165  poll.logsAt = now
166  try {
167    const { home } = await paths($)
168    const jev = await tailLog($, join(home, '.claude/cache/jev-hooks.jsonl'))
169    if (jev) logs.jev = jevRows(jev).slice(-ROWS)
170    const gate = await tailLog($, join(home, '.claude/cache/delegation-gate.jsonl'))
171    if (gate) logs.gate = gateRows(gate).slice(-ROWS)
172    if (jev || gate) live.dirty = true
173  } catch (error) {
174    const at = await $.clock.now()
175    await update($, hooks, feed => feedError(feed, `hook logs: ${String(error)}`, at))
176  } finally {
177    poll.logsBusy = false
178  }
179}
180
181async function flushHooks($: Engine): Promise<void> {
182  if (!live.dirty) return
183  live.dirty = false
184  const merged = [...logs.jev, ...logs.gate].sort((a, b) => a.at - b.at).slice(-ROWS)
185  const candidates = [...live.rows, ...merged].filter(isBlocking).sort((a, b) => a.at - b.at)
186  const snapshot: HooksSnapshot = { live: live.rows, logs: merged, inflight: live.inflight, lastBlock: candidates[candidates.length - 1] ?? null }
187  const at = await $.clock.now()
188  await update($, hooks, feed => feedOk(feed, snapshot, at))
189}
190
191async function readTask($: Engine, dir: string, id: string, now: number): Promise<SsaRun | null> {
192  const entries = await $.fs.list(dir).catch(() => [])
193  if (entries.length === 0) return null
194  const mtime = Math.max(...entries.map(entry => entry.mtimeMs))
195  const cached = runs.cache.get(id)
196  if (cached && cached.mtime === mtime) return cached.run
197  const names = new Set(entries.map(entry => entry.name))
198  const eventsEntry = entries.find(entry => entry.name === 'events.jsonl')
199  const events = eventsEntry && eventsEntry.size < 1048576 ? await readSmall($, join(dir, 'events.jsonl')) : ''
200  const file = (name: string): Promise<string> => (names.has(name) ? readSmall($, join(dir, name)) : Promise.resolve(''))
201  const run = taskRun(
202    {
203      id,
204      events,
205      kind: await file('kind.txt'),
206      model: await file('model-used.txt'),
207      worker: await file('worker.txt'),
208      exitCode: await file('exit-code.txt'),
209      startedAt: startedAtFromId(id),
210      mtime,
211    },
212    now,
213  )
214  runs.cache.set(id, { mtime, run })
215  return run
216}
217
218async function pollSsa($: Engine, now: number): Promise<void> {
219  if (poll.ssaBusy) return
220  poll.ssaBusy = true
221  poll.ssaAt = now
222  try {
223    const { tmp } = await paths($)
224    const root = join(tmp, 'smart-subagents')
225    const entries = (await $.fs.exists(root)) ? await $.fs.list(root) : []
226    const seen: SsaRun[] = []
227    for (const entry of entries) {
228      if (entry.kind !== 'dir' || entry.name === 'wt') continue
229      const dir = join(root, entry.name)
230      if (entry.name.startsWith('plan-')) {
231        const files = await $.fs.list(dir).catch(() => [])
232        const mtime = files.length ? Math.max(...files.map(file => file.mtimeMs)) : entry.mtimeMs
233        seen.push(planRun({ id: entry.name, names: files.map(file => file.name), mtime, startedAt: startedAtFromId(entry.name) }, now))
234      } else if (/^\d+-\d+$/.test(entry.name)) {
235        const run = await readTask($, dir, entry.name, now)
236        if (run) seen.push(run)
237      }
238    }
239    const before = JSON.stringify(runs.history.map(run => [run.id, run.state, run.updatedAt]))
240    runs.history = mergeHistory(runs.history, seen, now)
241    if (JSON.stringify(runs.history.map(run => [run.id, run.state, run.updatedAt])) !== before) {
242      await $.store.set('ssaHistory', runs.history).catch(() => undefined)
243    }
244    for (const run of seen) {
245      const previous = runs.states.get(run.id)
246      if (previous === 'running' && run.state !== 'running') $.ui.toast(`Smart Subagents ${run.type} ${run.id} ${run.state}${run.failureClass ? ` (${run.failureClass})` : ''}`)
247      runs.states.set(run.id, run.state)
248    }
249    const snapshot: SsaSnapshot = {
250      running: seen.filter(run => run.state === 'running').sort((a, b) => b.updatedAt - a.updatedAt),
251      recent: runs.history.filter(run => run.state !== 'running').slice(0, 15),
252    }
253    const at = await $.clock.now()
254    await update($, ssa, feed => feedOk(feed, snapshot, at))
255  } catch (error) {
256    const at = await $.clock.now()
257    await update($, ssa, feed => feedError(feed, String(error), at))
258  } finally {
259    poll.ssaBusy = false
260  }
261}
262
263async function tick($: Engine): Promise<void> {
264  try {
265    const now = await $.clock.now()
266    const panelState = await read($, panel)
267    const ssaState = await read($, ssa)
268    const current = await read($, interview)
269    const panes = await $.ui.panes().catch(() => [])
270    const isPaneShown = panes.some(pane => pane.id === PANE && pane.isShown)
271    const isActive =
272      isPaneShown ||
273      now - host.lastRenderAt < ACTIVE_WINDOW_MS ||
274      (ssaState.data?.running.length ?? 0) > 0 ||
275      panelState.phase === 'countdown' ||
276      panelState.phase === 'running' ||
277      current.id !== null
278    if (now - poll.quotaAt >= (isActive ? QUOTA_ACTIVE_MS : QUOTA_IDLE_MS)) void pollQuota($, now)
279    const filesEvery = isActive ? FILES_ACTIVE_MS : FILES_IDLE_MS
280    if (now - poll.ssaAt >= filesEvery) void pollSsa($, now)
281    if (now - poll.logsAt >= filesEvery) void pollLogs($, now)
282    await flushHooks($)
283  } catch {
284    live.dirty = true
285  }
286}
287
288function stopCountdown(): void {
289  timers.countdown?.cancel()
290  timers.countdownTicker?.cancel()
291  timers.countdown = null
292  timers.countdownTicker = null
293}
294
295async function markTried($: Engine, itemId: string): Promise<void> {
296  await update($, interview, current => (current.panelTried.includes(itemId) ? current : { ...current, panelTried: [...current.panelTried, itemId] }))
297}
298
299async function cancelPanel($: Engine): Promise<void> {
300  stopCountdown()
301  const current = await read($, panel)
302  if (current.phase !== 'countdown') return
303  await markTried($, current.itemId)
304  await update($, panel, (value): Panel => ({ ...value, phase: 'cancelled', message: 'Cancelled by you.' }))
305}
306
307async function maybeStartPanel($: Engine): Promise<void> {
308  const current = await read($, panel)
309  if (current.phase === 'countdown' || current.phase === 'running') return
310  const item = panelCandidate(await read($, interview), await read($, meter))
311  if (item === null) return
312  const now = await $.clock.now()
313  const blocked = quotaBlocksPanel(await read($, quota), now)
314  if (blocked) {
315    await markTried($, item.id)
316    await update($, panel, (): Panel => ({ ...IDLE_PANEL, phase: 'skipped', itemId: item.id, label: item.question || item.id, message: `Auto panel skipped: ${blocked}.` }))
317    return
318  }
319  await update($, panel, (): Panel => ({ ...IDLE_PANEL, phase: 'countdown', itemId: item.id, label: item.question || item.id, deadline: now + COUNTDOWN_MS, message: 'Starts in 30s.' }))
320  await update($, isBandHidden, () => false)
321  void openPane($)
322  $.ui.toast(`Auto panel on "${item.question || item.id}" starts in 30s. Cancel in the Cockpit pane.`)
323  stopCountdown()
324  timers.countdownTicker = $.clock.every(1000, () => {
325    void countdownTick($)
326  })
327  timers.countdown = $.clock.after(COUNTDOWN_MS, () => {
328    void launchPanel($)
329  })
330}
331
332async function countdownTick($: Engine): Promise<void> {
333  const now = await $.clock.now()
334  await update($, panel, (value): Panel =>
335    value.phase === 'countdown' ? { ...value, message: `Starts in ${Math.max(0, Math.ceil((value.deadline - now) / 1000))}s.` } : value,
336  )
337}
338
339async function panelRepo($: Engine, scratch: string): Promise<string> {
340  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 }).catch(() => null)
341  if (top && top.exitCode === 0 && top.stdout.trim()) return top.stdout.trim()
342  await $.process.run(['git', 'init', '-q', '-b', 'panel', scratch], { timeoutMs: 10000 })
343  await $.process.run(['git', '-C', scratch, 'commit', '--allow-empty', '-qm', 'panel context'], { timeoutMs: 10000 })
344  return scratch
345}
346
347async function launchPanel($: Engine): Promise<void> {
348  stopCountdown()
349  const current = await read($, panel)
350  if (current.phase !== 'countdown') return
351  const fail = (message: string) => update($, panel, (value): Panel => ({ ...value, phase: 'skipped', message }))
352  const now = await $.clock.now()
353  const iv = await read($, interview)
354  const m = await read($, meter)
355  const item = m?.openItems?.find(entry => entry.id === current.itemId)
356  if (iv.id === null || m === null) return void (await fail('Auto panel skipped: the interview ended.'))
357  if (!item) return void (await fail('Auto panel skipped: the item is no longer open.'))
358  if (!(await $.fs.exists(join((await paths($)).home, PANEL_SCRIPT)))) return void (await fail(`Auto panel skipped: ~/${PANEL_SCRIPT} not found (${SSA_URL}).`))
359  const blocked = quotaBlocksPanel(await read($, quota), now)
360  if (blocked) return void (await fail(`Auto panel skipped: ${blocked}.`))
361  await markTried($, item.id)
362  try {
363    const { home, tmp, cwd } = await paths($)
364    const dir = join(tmp, `cockpit-panels/${now}`)
365    await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5000 })
366    const decisions = join(home, `.claude/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/questioning.md`)
367    const goalFile = join(dir, 'goal.md')
368    await $.fs.write(goalFile, panelGoal(m, item, decisions))
369    const repo = await panelRepo($, join(dir, 'repo'))
370    await update($, panel, (value): Panel => ({ ...value, phase: 'running', startedAt: now, goalFile, message: 'Panel running: 3 planners.' }))
371    const stream = $.process.spawn({
372      argv: ['bash', join(home, PANEL_SCRIPT), 'plan', '--repo', repo, '--n', '3', '--difficulty', 'hard', '--goal-file', goalFile],
373    })
374    let output = ''
375    for await (const chunk of stream) output = (output + chunk.text).slice(-20000)
376    const ended = await stream.result
377    const runDir = output.match(/smart-subagents\/(plan-[\d-]+)/)?.[1] ?? ''
378    const usable = output.match(/"usable_plans":\s*(\d+)/)?.[1] ?? '?'
379    const isOk = ended.code === 0
380    await update($, panel, (value): Panel => ({
381      ...value,
382      phase: isOk ? 'done' : 'failed',
383      message: isOk ? `Panel done: ${usable} usable plans in ${runDir || 'the smart-subagents folder'}.` : `Panel failed (exit ${ended.code ?? ended.signal}).`,
384    }))
385    $.ui.toast(isOk ? `Auto panel finished: ${usable} plans.` : 'Auto panel failed.')
386    if (isOk) {
387      await $.prompt.fill({
388        text: `The auto panel on "${item.question || item.id}" finished (${usable} plans in ${join(tmp, `smart-subagents/${runDir}`)}). Read every plan, reconcile them, and bring back one answer.`,
389        mode: 'insert',
390      })
391    }
392  } catch (error) {
393    await update($, panel, (value): Panel => ({ ...value, phase: 'failed', message: `Panel failed: ${String(error).slice(0, 200)}` }))
394  }
395}
396
397async function recoverPanel($: Engine): Promise<void> {
398  const current = await read($, panel)
399  if (current.phase === 'running') {
400    await update($, panel, (value): Panel => ({ ...value, phase: 'failed', message: 'Interrupted: the mod reloaded while the panel ran. Its run folder may still finish; check Subagents.' }))
401    return
402  }
403  if (current.phase !== 'countdown') return
404  const remaining = Math.max(1000, current.deadline - (await $.clock.now()))
405  stopCountdown()
406  timers.countdownTicker = $.clock.every(1000, () => {
407    void countdownTick($)
408  })
409  timers.countdown = $.clock.after(remaining, () => {
410    void launchPanel($)
411  })
412}
413
414async function handleMeter($: Engine, input: Record<string, unknown>): Promise<string> {
415  const parsed = parseMeterInput(input)
416  if (!parsed.ok) return `Rejected: ${parsed.error}. Fix the fields and call the meter again.`
417  const now = await $.clock.now()
418  const before = await read($, interview)
419  const applied = applyMeter(before, parsed.value, String(now))
420  if (!applied.ok) return `Rejected: ${applied.error}.`
421  const next: Interview = applied.value
422  await update($, interview, () => next)
423  const previous = await read($, meter)
424  const built = meterFromInput(previous, parsed.value, next)
425  await update($, meter, () => built)
426  if (parsed.value.event === 'start') {
427    await update($, isBandHidden, () => false)
428    await update($, panel, () => IDLE_PANEL)
429    void openPane($)
430  }
431  if (parsed.value.event === 'stop_check') await maybeStartPanel($)
432  if (parsed.value.event === 'finish') return `Recorded finish. Interview closed after ${next.totalAnswers} answers.`
433  const allowed = next.permits.length
434  return [
435    `Recorded ${parsed.value.event}. ${next.totalAnswers} answers so far, ${next.answersSinceStop} since the last stop check (stop check due at ${STOP_EVERY}).`,
436    allowed ? `You may now ask ${allowed} question${allowed === 1 ? '' : 's'} with AskUserQuestion.` : '',
437  ]
438    .filter(Boolean)
439    .join(' ')
440}
441
442async function statusReport($: Engine): Promise<string> {
443  const now = await $.clock.now()
444  const current = await read($, meter)
445  const iv = await read($, interview)
446  const panelState = await read($, panel)
447  const quotaFeed = await read($, quota)
448  const hooksFeed = await read($, hooks)
449  const ssaFeed = await read($, ssa)
450  const lowest = lowestWindow(quotaFeed.data)
451  const feed = (status: string, error: string, lastOkAt: number) => ({ status, error: error || undefined, lastOk: ago(lastOkAt, now) })
452  return JSON.stringify(
453    {
454      questioning: {
455        meter: current ? { clarity: current.clarity, status: current.status, source: current.source ?? 'text', history: current.history, open: current.open } : null,
456        interview: { armed: iv.id !== null, totalAnswers: iv.totalAnswers, answersSinceStop: iv.answersSinceStop, blocks: iv.blocks, stopChecks: iv.stopChecks, warnings: iv.warnings.slice(-3), openStreak: iv.openStreak },
457        panel: { phase: panelState.phase, item: panelState.label, message: panelState.message },
458      },
459      quota: {
460        ...feed(quotaFeed.status, quotaFeed.error, quotaFeed.lastOkAt),
461        lowest: lowest ? `${lowest.cli} ${lowest.window.remainingPct}% ${lowest.window.name} ${until(lowest.window.resetsAt, now)}` : null,
462        primary: quotaFeed.data?.primary,
463        localLaborOk: quotaFeed.data?.localLaborOk,
464        clis: (quotaFeed.data?.clis ?? []).map(cli => `${cli.cli}:${cli.available ? tightestPct(cli) ?? '?' : 'unavailable'}`),
465      },
466      hooks: {
467        ...feed(hooksFeed.status, hooksFeed.error, hooksFeed.lastOkAt),
468        inflight: hooksFeed.data?.inflight ?? 0,
469        live: (hooksFeed.data?.live ?? []).slice(-5).map(row => `${clock(row.at)} ${row.event} ${row.tool} ${row.ms}ms ${row.outcome}`),
470        logs: (hooksFeed.data?.logs ?? []).slice(-5).map(row => `${clock(row.at)} ${row.source} ${row.event} ${row.tool} ${row.outcome}`),
471        lastBlock: hooksFeed.data?.lastBlock ? `${clock(hooksFeed.data.lastBlock.at)} ${hooksFeed.data.lastBlock.source} ${hooksFeed.data.lastBlock.outcome} ${hooksFeed.data.lastBlock.tool}` : null,
472      },
473      subagents: {
474        ...feed(ssaFeed.status, ssaFeed.error, ssaFeed.lastOkAt),
475        running: (ssaFeed.data?.running ?? []).map(run => `${run.id} ${run.type} ${run.worker} ${run.phase}`),
476        recent: (ssaFeed.data?.recent ?? []).slice(0, 5).map(run => `${run.id} ${run.state} ${run.kind} ${run.worker} ${run.failureClass}`.trim()),
477        historySize: runs.history.length,
478      },
479      pane: { lastRender: ago(host.lastRenderAt, now), lastOpen: host.lastOpen, panes: (await $.ui.panes()).map(pane => `${pane.id}${pane.isShown ? ' shown' : ' hidden'}`) },
480    },
481    null,
482    1,
483  )
484}
485
486async function cockpitData($: Engine, columns: number): Promise<CockpitData> {
487  return {
488    now: Date.now(),
489    width: columns,
490    meter: await read($, meter),
491    interview: await read($, interview),
492    panel: await read($, panel),
493    quota: await read($, quota),
494    hooks: await read($, hooks),
495    ssa: await read($, ssa),
496    open: await read($, sections),
497  }
498}
499
500async function refreshAll($: Engine): Promise<void> {
501  poll.quotaAt = 0
502  poll.logsAt = 0
503  poll.ssaAt = 0
504  logs.size = {}
505  await tick($)
506}
507
508function cockpitActions($: Engine): CockpitActions {
509  return {
510    toggle: key => void update($, sections, value => ({ ...value, [key]: !value[key] })),
511    cancelPanel: () => void cancelPanel($),
512    refresh: () => void refreshAll($),
513    openPane: () => void openPane($, true),
514    hideBand: () => void update($, isBandHidden, () => true),
515  }
516}
517
518export const register: Register = on => {
519  on('session.start', async ($, e, next) => {
520    await $.command.register({ name: 'cockpit', description: 'Open the Cockpit pane: questioning, quota, hooks and Smart Subagents.' })
521    await $.command.register({ name: 'clarity', description: 'Open the Cockpit pane. "/clarity reset" clears the questioning meter and interview.' })
522    await $.tool.register({ name: 'meter', description: METER_DESCRIPTION, inputSchema: METER_SCHEMA })
523    await $.tool.register({ name: 'status', description: 'Read the Cockpit: questioning meter and enforcer state, AI quota per CLI, recent settings-hook runs and gate/Jev log rows, Smart Subagents runs now and recent history. Read-only, returns JSON.', inputSchema: { type: 'object', properties: {} } })
524    const stored = await $.store.get('ssaHistory').catch(() => undefined)
525    if (isRunList(stored)) runs.history = stored
526    await refreshFromText($)
527    await recoverPanel($)
528    void openPane($)
529    timers.tick?.cancel()
530    timers.tick = $.clock.every(TICK_MS, () => {
531      void tick($)
532    })
533    void tick($)
534    return next(e)
535  })
536
537  on('session.end', async ($, e, next) => {
538    timers.tick?.cancel()
539    stopCountdown()
540    return next(e)
541  })
542
543  on('tool.call', { tool: METER_TOOL }, async ($, e) => {
544    const { tool, tool_use_id, ...input } = e
545    void tool
546    void tool_use_id
547    return { result: await handleMeter($, input) }
548  })
549
550  on('tool.call', { tool: 'mcp__claude-code-cockpit__status' }, async $ => ({ result: await statusReport($) }))
551
552  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
553    if (e.agentId !== undefined) return next(e)
554    const questionCount = e.questions.length
555    const decision = checkAsk(await read($, interview), await read($, meter), questionCount)
556    await update($, interview, () => decision.interview)
557    if (decision.block) return { deny: decision.block }
558    await refreshFromText($)
559    const ran = await next(e)
560    if (ran.deny !== undefined || ran.isError) return ran
561    const answered = isRecord(ran.result) ? countAnswered(ran.result.answers, ran.result.response, questionCount) : 0
562    await update($, interview, current => recordAnswers(current, e.tool_use_id, answered, decision.fromStop))
563    if (decision.warnings.length === 0) return ran
564    return { ...ran, context: [...(ran.context ?? []), `Questioning enforcer: ${decision.warnings.join(' ')}`] }
565  })
566
567  on('classic.PreToolUse', async ($, e, next) => {
568    const startedAt = Date.now()
569    live.inflight += 1
570    live.dirty = true
571    try {
572      const result = await next(e)
573      recordLive('PreToolUse', hookTool(e), startedAt, hookOutcome(result))
574      return result
575    } finally {
576      live.inflight -= 1
577    }
578  })
579
580  on('classic.PostToolUse', async ($, e, next) => {
581    const startedAt = Date.now()
582    live.inflight += 1
583    try {
584      const result = await next(e)
585      recordLive('PostToolUse', hookTool(e), startedAt, hookOutcome(result))
586      return result
587    } finally {
588      live.inflight -= 1
589    }
590  })
591
592  on('classic.UserPromptSubmit', async ($, e, next) => {
593    const startedAt = Date.now()
594    live.inflight += 1
595    try {
596      const result = await next(e)
597      recordLive('UserPromptSubmit', '', startedAt, hookOutcome(result))
598      return result
599    } finally {
600      live.inflight -= 1
601    }
602  })
603
604  on('classic.Stop', async ($, e, next) => {
605    const startedAt = Date.now()
606    live.inflight += 1
607    try {
608      const result = await next(e)
609      recordLive('Stop', '', startedAt, hookOutcome(result))
610      return result
611    } finally {
612      live.inflight -= 1
613    }
614  })
615
616  on('turn.complete', async ($, e, next) => {
617    if (e.agentId === undefined) await refreshFromText($, e.answer)
618    return next(e)
619  })
620
621  on('command.run', { command: ['clarity', 'cockpit'] }, async ($, e) => {
622    if (/^\s*reset\s*$/i.test(e.args)) {
623      stopCountdown()
624      await update($, meter, () => null)
625      await update($, interview, () => emptyInterview())
626      await update($, panel, () => IDLE_PANEL)
627      await update($, isBandHidden, () => false)
628      return { text: 'Questioning meter and interview cleared.' }
629    }
630    await update($, isBandHidden, () => false)
631    await update($, sections, () => ALL_OPEN)
632    await openPane($, true)
633    void tick($)
634    const current = await read($, meter)
635    return { text: current ? `Cockpit open. Clarity ${current.clarity}/10.` : 'Cockpit open.' }
636  })
637
638  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
639    if (e.props.hasSurvey || (await read($, isBandHidden))) return next(e)
640    const ui = $.ui.resolve(e)
641    const band = Band({ ui, data: await cockpitData($, 0), actions: cockpitActions($) })
642    return band ?? next(e)
643  })
644
645  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
646    host.lastRenderAt = Date.now()
647    const ui = $.ui.resolve(e)
648    return <Cockpit ui={ui} data={await cockpitData($, e.props.bodyColumns)} actions={cockpitActions($)} />
649  })
650}
651
hooks/feeds.ts 223 lines
1import type { Feed, HookRow, QuotaCli, QuotaSnapshot, QuotaWindow, SsaRun, SsaState } from '../types'
2
3export const HISTORY_MAX_RUNS = 100
4export const HISTORY_MAX_AGE_MS = 30 * 24 * 3600 * 1000
5export const QUOTA_MAX_AGE_MS = 5 * 60 * 1000
6export const STALE_RUN_MS = 6 * 3600 * 1000
7
8const isRecord = (value: unknown): value is Record<string, unknown> =>
9  typeof value === 'object' && value !== null && !Array.isArray(value)
10
11const str = (value: unknown): string => (typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value))
12
13const num = (value: unknown): number | null => (typeof value === 'number' && Number.isFinite(value) ? value : null)
14
15export const emptyFeed = <T>(): Feed<T> => ({ status: 'loading', data: null, error: '', lastOkAt: 0, lastAttemptAt: 0 })
16
17export const feedOk = <T>(previous: Feed<T>, data: T, now: number): Feed<T> => ({
18  ...previous,
19  status: 'ok',
20  data,
21  error: '',
22  lastOkAt: now,
23  lastAttemptAt: now,
24})
25
26export const feedError = <T>(previous: Feed<T>, error: string, now: number): Feed<T> => ({
27  ...previous,
28  status: 'error',
29  error: error.slice(0, 300),
30  lastAttemptAt: now,
31})
32
33export const parseQuota = (json: string): QuotaSnapshot => {
34  const raw: unknown = JSON.parse(json)
35  if (!isRecord(raw) || !Array.isArray(raw.clis)) throw new Error('ai-cli-usage output has no clis list')
36  const clis: QuotaCli[] = raw.clis.filter(isRecord).map(entry => ({
37    cli: str(entry.cli),
38    available: entry.available === true,
39    eligible: entry.eligible === true,
40    error: str(entry.error ?? entry.skip_reason),
41    windows: (Array.isArray(entry.windows) ? entry.windows : []).filter(isRecord).map(
42      (window): QuotaWindow => ({
43        name: str(window.name),
44        remainingPct: num(window.remaining_pct) === null ? null : Math.round((num(window.remaining_pct) ?? 0) * 10) / 10,
45        resetsAt: typeof window.resets_at === 'string' ? window.resets_at : null,
46        severity: str(window.severity) || 'unknown',
47      }),
48    ),
49  }))
50  const recommendation = isRecord(raw.recommendation) ? raw.recommendation : {}
51  return {
52    checkedAt: str(raw.checked_at),
53    clis,
54    primary: str(recommendation.primary_worker),
55    localLaborOk: recommendation.local_labor_ok === true,
56  }
57}
58
59export type LowestWindow = { cli: string; window: QuotaWindow }
60
61export const lowestWindow = (snapshot: QuotaSnapshot | null): LowestWindow | null => {
62  let lowest: LowestWindow | null = null
63  for (const cli of snapshot?.clis ?? []) {
64    if (!cli.available) continue
65    for (const window of cli.windows) {
66      if (window.remainingPct === null) continue
67      if (lowest === null || window.remainingPct < (lowest.window.remainingPct ?? Infinity)) lowest = { cli: cli.cli, window }
68    }
69  }
70  return lowest
71}
72
73export const tightestPct = (cli: QuotaCli): number | null =>
74  cli.windows.reduce<number | null>((min, window) => (window.remainingPct === null ? min : min === null ? window.remainingPct : Math.min(min, window.remainingPct)), null)
75
76export const quotaBlocksPanel = (feed: Feed<QuotaSnapshot>, now: number): string | null => {
77  if (feed.data === null) return 'no quota data yet'
78  if (now - feed.lastOkAt > QUOTA_MAX_AGE_MS) return 'quota data is older than 5 minutes'
79  if (!feed.data.clis.some(cli => cli.available && cli.eligible && (tightestPct(cli) ?? 100) > 5)) return 'no eligible worker has quota left'
80  return null
81}
82
83export const jsonLines = (text: string, isTail: boolean): unknown[] => {
84  const lines = text.split('\n')
85  if (isTail) lines.shift()
86  return lines.flatMap(line => {
87    if (!line.trim()) return []
88    try {
89      return [JSON.parse(line) as unknown]
90    } catch {
91      return []
92    }
93  })
94}
95
96const timeOf = (value: unknown): number => {
97  const parsed = Date.parse(str(value))
98  return Number.isNaN(parsed) ? 0 : parsed
99}
100
101export const jevRows = (entries: unknown[]): HookRow[] =>
102  entries.filter(isRecord).map(entry => ({
103    at: timeOf(entry.ts),
104    source: 'jev',
105    event: str(entry.event) || 'screen',
106    tool: str(entry.tool),
107    ms: num(entry.ms) ?? 0,
108    outcome: entry.deny === true ? 'deny' : 'allow',
109    detail: [str(entry.harness), num(entry.noul) !== null ? `p=${num(entry.noul)}` : ''].filter(Boolean).join(' '),
110  }))
111
112const firedNames = (fired: unknown): string[] =>
113  Array.isArray(fired) ? fired.map(entry => (isRecord(entry) ? str(entry.name ?? entry.gate ?? entry.kind) : str(entry))).filter(Boolean) : []
114
115export const gateRows = (entries: unknown[]): HookRow[] =>
116  entries.filter(isRecord).flatMap((entry): HookRow[] => {
117    const event = str(entry.event)
118    const base = { at: timeOf(entry.ts), source: 'gate' as const, tool: str(entry.harness), ms: num(entry.ms) ?? 0 }
119    if (event === 'prompt') {
120      const fired = firedNames(entry.fired)
121      const scores = ['labor', 'plan', 'substantial', 'unresolved'].filter(key => num(entry[key]) !== null).map(key => `${key}=${num(entry[key])}`)
122      return [{ ...base, event: 'gate', outcome: fired.length ? `fired ${fired.join(',')}` : 'quiet', detail: scores.join(' ') }]
123    }
124    if (event === 'interview-close' || event === 'skip' || event === 'stop') {
125      return [{ ...base, event, outcome: str(entry.how ?? entry.why ?? entry.resolution) || event, detail: '' }]
126    }
127    return []
128  })
129
130const PHASE_STATE: Record<string, SsaState> = {
131  running: 'running',
132  dispatched: 'running',
133  started: 'running',
134  queued: 'running',
135  verifying: 'running',
136  reviewing: 'running',
137  failed: 'failed',
138  error: 'failed',
139}
140
141export type TaskFiles = {
142  id: string
143  events: string
144  kind: string
145  model: string
146  worker: string
147  exitCode: string
148  startedAt: number
149  mtime: number
150}
151
152export const taskRun = (files: TaskFiles, now: number): SsaRun => {
153  const events = jsonLines(files.events, false).filter(isRecord)
154  const last = events[events.length - 1] ?? {}
155  const phase = str(last.phase) || (files.exitCode ? 'exited' : 'unknown')
156  const exitFile = files.exitCode.trim() === '' ? null : Number(files.exitCode.trim())
157  const exitCode = num(last.exit) ?? (exitFile !== null && Number.isFinite(exitFile) ? exitFile : null)
158  let state: SsaState = PHASE_STATE[phase] ?? (exitCode !== null && exitCode !== 0 ? 'failed' : 'done')
159  const updatedAt = Math.max(timeOf(last.ts), files.mtime)
160  if (state === 'running' && now - updatedAt > STALE_RUN_MS) state = 'expired'
161  return {
162    id: files.id,
163    type: 'task',
164    kind: files.kind.trim(),
165    worker: str(last.worker) || files.worker.trim(),
166    model: files.model.trim(),
167    state,
168    phase,
169    exitCode,
170    failureClass: str(last.failure_class),
171    detail: events.length ? `${events.length} events` : '',
172    startedAt: files.startedAt || timeOf(events[0]?.ts) || files.mtime,
173    updatedAt,
174  }
175}
176
177export type PlanFiles = { id: string; names: string[]; mtime: number; startedAt: number }
178
179export const planRun = (files: PlanFiles, now: number): SsaRun => {
180  const plans = files.names.filter(name => /^plan-\d+-.+\.md$/.test(name))
181  const planners = files.names.filter(name => /^planner-\d+\.pid$/.test(name)).length
182  const isDone = files.names.includes('panel-done.txt')
183  const workers = plans.map(name => name.replace(/^plan-\d+-[a-z]+-/, '').replace(/\.md$/, ''))
184  let state: SsaState = isDone ? 'done' : 'running'
185  if (state === 'running' && now - files.mtime > STALE_RUN_MS) state = 'expired'
186  return {
187    id: files.id,
188    type: 'plan',
189    kind: 'panel',
190    worker: workers.join(','),
191    model: '',
192    state,
193    phase: isDone ? 'done' : `planning ${plans.length}/${planners || 3}`,
194    exitCode: null,
195    failureClass: '',
196    detail: `${plans.length} of ${planners || 3} plans`,
197    startedAt: files.startedAt || files.mtime,
198    updatedAt: files.mtime,
199  }
200}
201
202export const mergeHistory = (history: readonly SsaRun[], seen: readonly SsaRun[], now: number): SsaRun[] => {
203  const byId = new Map<string, SsaRun>()
204  for (const run of history) byId.set(run.id, run)
205  const seenIds = new Set(seen.map(run => run.id))
206  for (const run of seen) byId.set(run.id, run)
207  for (const [id, run] of byId) {
208    if (!seenIds.has(id) && run.state === 'running') byId.set(id, { ...run, state: 'expired', detail: 'run folder gone before it finished' })
209  }
210  return [...byId.values()]
211    .filter(run => now - run.updatedAt <= HISTORY_MAX_AGE_MS)
212    .sort((a, b) => b.updatedAt - a.updatedAt)
213    .slice(0, HISTORY_MAX_RUNS)
214}
215
216export const isRunList = (value: unknown): value is SsaRun[] =>
217  Array.isArray(value) && value.every(entry => isRecord(entry) && typeof entry.id === 'string' && typeof entry.state === 'string')
218
219export const startedAtFromId = (id: string): number => {
220  const seconds = Number(id.replace(/^plan-/, '').split('-')[0])
221  return Number.isFinite(seconds) && seconds > 1e9 ? seconds * 1000 : 0
222}
223
hooks/format.ts 39 lines
1const TICKS = '▁▂▃▄▅▆▇█'
2
3export const sparkline = (values: readonly number[], max = 10): string =>
4  values.map(value => TICKS[Math.max(0, Math.min(TICKS.length - 1, Math.round((value / max) * (TICKS.length - 1))))]).join('')
5
6export const ago = (then: number, now: number): string => {
7  if (!then) return 'never'
8  const seconds = Math.max(0, Math.round((now - then) / 1000))
9  if (seconds < 60) return `${seconds}s ago`
10  const minutes = Math.round(seconds / 60)
11  if (minutes < 60) return `${minutes}m ago`
12  const hours = Math.round(minutes / 60)
13  if (hours < 48) return `${hours}h ago`
14  return `${Math.round(hours / 24)}d ago`
15}
16
17export const duration = (ms: number): string => {
18  const seconds = Math.max(0, Math.round(ms / 1000))
19  if (seconds < 60) return `${seconds}s`
20  const minutes = Math.floor(seconds / 60)
21  if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
22  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
23}
24
25export const until = (iso: string | null, now: number): string => {
26  if (!iso) return 'reset unknown'
27  const at = Date.parse(iso)
28  if (Number.isNaN(at)) return 'reset unknown'
29  const ms = at - now
30  if (ms <= 0) return 'resetting'
31  return `resets in ${duration(ms)}`
32}
33
34export const clock = (at: number): string => {
35  if (!at) return '--:--:--'
36  const date = new Date(at)
37  return [date.getHours(), date.getMinutes(), date.getSeconds()].map(part => String(part).padStart(2, '0')).join(':')
38}
39
hooks/interview.ts 385 lines
1import type { Impact, Interview, Lens, Meter, OpenItem, PlannedQuestion, Slot, SlotState } from '../types'
2
3export const LENSES: readonly Lens[] = ['User', 'Outcome', 'Scope', 'Execution', 'Constraint', 'Risk', 'Alternative', 'Contrarian']
4export const MOVES: readonly string[] = ['Clarify', 'Dig', 'Explore', 'Challenge', 'Brainstorm', 'Panel', 'Converge']
5export const EVENTS = ['start', 'question', 'stop_check', 'finish'] as const
6export const STOP_EVERY = 4
7const SLOT_STATES: readonly SlotState[] = ['open', 'partial', 'settled']
8const IMPACTS: readonly Impact[] = ['low', 'medium', 'high']
9const MAX_TEXT = 2000
10const MAX_ITEMS = 32
11
12export type MeterEvent = (typeof EVENTS)[number]
13
14export type MeterInput = {
15  event: MeterEvent
16  topic: string
17  phase: string
18  picture: string
19  buildNow: string
20  assuming: string[]
21  clarity: number
22  slots: Slot[]
23  openItems: OpenItem[]
24  lensesCovered: Lens[]
25  lensesThin: Lens[]
26  shifted: string
27  questions: PlannedQuestion[]
28}
29
30export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string }
31
32export type AskDecision = {
33  block: string | null
34  warnings: string[]
35  interview: Interview
36  fromStop: boolean
37}
38
39export const emptyInterview = (): Interview => ({
40  id: null,
41  topic: '',
42  permits: [],
43  permitFromStop: false,
44  answersSinceStop: 0,
45  totalAnswers: 0,
46  recentLenses: [],
47  handled: [],
48  warnings: [],
49  blocks: 0,
50  stopChecks: 0,
51  openStreak: {},
52  panelTried: [],
53})
54
55const isRecord = (value: unknown): value is Record<string, unknown> =>
56  typeof value === 'object' && value !== null && !Array.isArray(value)
57
58const text = (value: unknown, field: string, errors: string[], max = MAX_TEXT): string => {
59  if (value === undefined || value === null) return ''
60  if (typeof value !== 'string') {
61    errors.push(`${field} must be a string`)
62    return ''
63  }
64  return value.length > max ? value.slice(0, max) : value
65}
66
67const list = (value: unknown, field: string, errors: string[]): unknown[] => {
68  if (value === undefined || value === null) return []
69  if (!Array.isArray(value)) {
70    errors.push(`${field} must be an array`)
71    return []
72  }
73  if (value.length > MAX_ITEMS) errors.push(`${field} has more than ${MAX_ITEMS} entries`)
74  return value.slice(0, MAX_ITEMS)
75}
76
77const oneOf = <T extends string>(value: unknown, allowed: readonly T[], field: string, errors: string[]): T | null => {
78  const match = allowed.find(option => typeof value === 'string' && option.toLowerCase() === value.toLowerCase())
79  if (!match) errors.push(`${field} must be one of ${allowed.join(', ')}`)
80  return match ?? null
81}
82
83const lensList = (value: unknown, field: string, errors: string[]): Lens[] =>
84  list(value, field, errors)
85    .map((entry, index) => oneOf(entry, LENSES, `${field}[${index}]`, errors))
86    .filter((entry): entry is Lens => entry !== null)
87
88export const parseMeterInput = (input: Record<string, unknown>): Parsed<MeterInput> => {
89  const errors: string[] = []
90  const event = oneOf(input.event, EVENTS, 'event', errors)
91  const clarity = typeof input.clarity === 'number' && Number.isFinite(input.clarity) ? Math.max(0, Math.min(10, input.clarity)) : NaN
92  if (Number.isNaN(clarity)) errors.push('clarity must be a number from 0 to 10')
93
94  const slots = list(input.slots, 'slots', errors).flatMap((entry, index): Slot[] => {
95    if (!isRecord(entry)) {
96      errors.push(`slots[${index}] must be an object`)
97      return []
98    }
99    const state = oneOf(entry.state, SLOT_STATES, `slots[${index}].state`, errors)
100    const label = text(entry.label, `slots[${index}].label`, errors, 120)
101    const id = text(entry.id, `slots[${index}].id`, errors, 80) || label.toLowerCase()
102    return state && label ? [{ id, label, state, summary: text(entry.summary, `slots[${index}].summary`, errors, 400) }] : []
103  })
104
105  const openItems = list(input.openItems, 'openItems', errors).flatMap((entry, index): OpenItem[] => {
106    if (!isRecord(entry)) {
107      errors.push(`openItems[${index}] must be an object`)
108      return []
109    }
110    const id = text(entry.id, `openItems[${index}].id`, errors, 80)
111    if (!id) errors.push(`openItems[${index}].id is required`)
112    const impact = oneOf(entry.impact, IMPACTS, `openItems[${index}].impact`, errors)
113    return id && impact
114      ? [{
115          id,
116          slotId: text(entry.slotId, `openItems[${index}].slotId`, errors, 80),
117          question: text(entry.question, `openItems[${index}].question`, errors, 400),
118          impact,
119          costOfWrong: text(entry.costOfWrong, `openItems[${index}].costOfWrong`, errors, 400),
120        }]
121      : []
122  })
123
124  const questions = list(input.questions, 'questions', errors).flatMap((entry, index): PlannedQuestion[] => {
125    if (!isRecord(entry)) {
126      errors.push(`questions[${index}] must be an object`)
127      return []
128    }
129    const lens = oneOf(entry.lens, LENSES, `questions[${index}].lens`, errors)
130    const move = oneOf(entry.move, MOVES, `questions[${index}].move`, errors)
131    const questionText = text(entry.text, `questions[${index}].text`, errors, 1000)
132    return lens && move ? [{ text: questionText, lens, move }] : []
133  })
134
135  if ((event === 'question' || event === 'stop_check') && questions.length === 0) errors.push(`${event} needs 1 to 4 questions`)
136  if (questions.length > 4) errors.push('questions has more than 4 entries')
137  if (event === 'finish' && questions.length > 0) errors.push('finish takes no questions')
138
139  if (errors.length > 0 || event === null) return { ok: false, error: errors.join('; ') }
140  return {
141    ok: true,
142    value: {
143      event,
144      topic: text(input.topic, 'topic', errors, 240),
145      phase: text(input.phase, 'phase', errors, 40),
146      picture: text(input.picture, 'picture', errors),
147      buildNow: text(input.buildNow, 'buildNow', errors),
148      assuming: list(input.assuming, 'assuming', errors).filter((entry): entry is string => typeof entry === 'string'),
149      clarity,
150      slots,
151      openItems,
152      lensesCovered: lensList(input.lensesCovered, 'lensesCovered', errors),
153      lensesThin: lensList(input.lensesThin, 'lensesThin', errors),
154      shifted: text(input.shifted, 'shifted', errors),
155      questions: questions.slice(0, 4),
156    },
157  }
158}
159
160export const meterFromInput = (previous: Meter | null, input: MeterInput, interview: Interview): Meter => {
161  const status = input.event === 'finish' ? 'done' : 'interviewing'
162  const isNewInterview = input.event === 'start'
163  const priorHistory = isNewInterview || previous?.source !== 'tool' ? [] : previous.history
164  const history = input.event === 'question' ? priorHistory : [...priorHistory, input.clarity].slice(-20)
165  return {
166    answers: interview.totalAnswers,
167    phase: input.phase,
168    picture: input.picture,
169    buildNow: input.buildNow,
170    assuming: input.assuming.join(' · '),
171    clarity: input.clarity,
172    settled: input.slots.filter(slot => slot.state === 'settled').map(slot => slot.label),
173    open: input.slots.filter(slot => slot.state !== 'settled').map(slot => (slot.state === 'partial' ? `${slot.label} (partial)` : slot.label)),
174    lensesCovered: input.lensesCovered,
175    lensesThin: input.lensesThin,
176    costOfWrong: input.openItems.filter(item => item.costOfWrong).map(item => `${item.question || item.id} -> ${item.costOfWrong}`),
177    shifted: input.shifted,
178    status,
179    history: history.length > 0 ? history : [input.clarity],
180    source: 'tool',
181    topic: input.topic || previous?.topic || '',
182    slots: input.slots,
183    openItems: input.openItems,
184  }
185}
186
187export const applyMeter = (current: Interview, input: MeterInput, newId: string): Parsed<Interview> => {
188  if (input.event === 'start') {
189    return {
190      ok: true,
191      value: { ...emptyInterview(), id: newId, topic: input.topic, permits: input.questions, permitFromStop: false },
192    }
193  }
194  if (current.id === null) return { ok: false, error: 'no interview is open: call the meter with event "start" first' }
195  if (input.event === 'finish') return { ok: true, value: { ...current, id: null, permits: [], permitFromStop: false } }
196  if (input.event === 'question') return { ok: true, value: { ...current, permits: input.questions, permitFromStop: false } }
197
198  const stillOpen = input.openItems.filter(item => item.impact !== 'low')
199  const openStreak: Record<string, number> = {}
200  for (const item of stillOpen) openStreak[item.id] = (current.openStreak[item.id] ?? 0) + 1
201  return {
202    ok: true,
203    value: {
204      ...current,
205      permits: input.questions,
206      permitFromStop: true,
207      answersSinceStop: 0,
208      stopChecks: current.stopChecks + 1,
209      openStreak,
210    },
211  }
212}
213
214const lensStreak = (recent: readonly Lens[], incoming: readonly Lens[]): Lens | null => {
215  const sequence = [...recent, ...incoming]
216  for (let index = Math.max(2, recent.length); index < sequence.length; index += 1) {
217    const lens = sequence[index]
218    if (lens && sequence[index - 1] === lens && sequence[index - 2] === lens) return lens
219  }
220  return null
221}
222
223export const checkAsk = (current: Interview, meter: Meter | null, questionCount: number): AskDecision => {
224  if (current.id === null) return { block: null, warnings: [], interview: current, fromStop: false }
225
226  if (current.permits.length === 0) {
227    return {
228      block: 'Questioning enforcer: call mcp__claude-code-cockpit__meter (event "question" or "stop_check", with this question\'s lens and move) before AskUserQuestion.',
229      warnings: [],
230      interview: { ...current, blocks: current.blocks + 1 },
231      fromStop: false,
232    }
233  }
234
235  if (!current.permitFromStop && current.answersSinceStop + questionCount > STOP_EVERY) {
236    return {
237      block: `Questioning enforcer: ${current.answersSinceStop} answers since the last stop check. Print the stop check, call the meter with event "stop_check", then ask.`,
238      warnings: [],
239      interview: { ...current, blocks: current.blocks + 1 },
240      fromStop: false,
241    }
242  }
243
244  const incoming = current.permits.map(permit => permit.lens)
245  const warnings: string[] = []
246  const streak = lensStreak(current.recentLenses, incoming)
247  if (streak) warnings.push(`Three questions in a row under the ${streak} lens. Pivot to an unused high-impact lens next.`)
248  const highOpen = (meter?.openItems ?? []).some(item => item.impact === 'high')
249  if (!current.permitFromStop && meter !== null && meter.clarity >= 8 && !highOpen) {
250    warnings.push(`Clarity is ${meter.clarity}/10 with nothing high-impact open. Offer the stop check ("Enough, build it") instead of more questions.`)
251  }
252
253  return {
254    block: null,
255    warnings,
256    fromStop: current.permitFromStop,
257    interview: {
258      ...current,
259      permits: [],
260      permitFromStop: false,
261      recentLenses: [...current.recentLenses, ...incoming].slice(-10),
262      warnings: [...current.warnings, ...warnings].slice(-10),
263    },
264  }
265}
266
267export const countAnswered = (answers: unknown, response: unknown, questionCount: number): number => {
268  const filled = isRecord(answers)
269    ? Object.values(answers).filter(value => (typeof value === 'string' ? value.trim() !== '' : value !== null && value !== undefined)).length
270    : 0
271  if (filled > 0) return Math.min(filled, questionCount)
272  return typeof response === 'string' && response.trim() !== '' && questionCount === 1 ? 1 : 0
273}
274
275export const recordAnswers = (current: Interview, toolUseId: string, answered: number, fromStop: boolean): Interview => {
276  if (current.handled.includes(toolUseId) || answered === 0) return current
277  return {
278    ...current,
279    handled: [...current.handled, toolUseId].slice(-50),
280    totalAnswers: current.totalAnswers + answered,
281    answersSinceStop: fromStop ? current.answersSinceStop : current.answersSinceStop + answered,
282  }
283}
284
285const isPanelSlot = (item: OpenItem, slots: readonly Slot[]): boolean => {
286  const slot = slots.find(entry => entry.id === item.slotId)
287  return /approach|risk/i.test(`${item.slotId} ${slot?.label ?? ''}`)
288}
289
290export const panelCandidate = (current: Interview, meter: Meter | null): OpenItem | null => {
291  if (current.id === null || meter === null) return null
292  return (
293    (meter.openItems ?? []).find(
294      item =>
295        item.impact !== 'low' &&
296        (current.openStreak[item.id] ?? 0) >= 2 &&
297        !current.panelTried.includes(item.id) &&
298        isPanelSlot(item, meter.slots ?? []),
299    ) ?? null
300  )
301}
302
303export const panelGoal = (meter: Meter, item: OpenItem, decisionsPath: string): string =>
304  [
305    `# Panel: ${item.question || item.id}`,
306    '',
307    `Topic: ${meter.topic ?? ''}`,
308    '',
309    '## The open question',
310    item.question || item.id,
311    item.costOfWrong ? `Cost of guessing wrong: ${item.costOfWrong}` : '',
312    '',
313    '## Current picture',
314    meter.picture,
315    '',
316    `Build now: ${meter.buildNow}`,
317    `Assuming: ${meter.assuming}`,
318    `Settled: ${meter.settled.join(', ') || 'none'}`,
319    `Open: ${meter.open.join(', ') || 'none'}`,
320    '',
321    '## Decisions so far',
322    `Read ${decisionsPath} for every answer recorded in this interview.`,
323    '',
324    '## Deliverable',
325    'Two or three distinct option shapes for the open question, each with its tradeoff and the evidence behind it, then your recommendation.',
326  ]
327    .filter((line, index, lines) => line !== '' || lines[index - 1] !== '')
328    .join('\n')
329
330export const METER_SCHEMA: Record<string, unknown> = {
331  type: 'object',
332  required: ['event', 'clarity'],
333  properties: {
334    event: { type: 'string', enum: [...EVENTS], description: 'start opens an interview; question before each ordinary AskUserQuestion; stop_check with the stop check and its question; finish at wrap-up.' },
335    topic: { type: 'string' },
336    phase: { type: 'string', enum: ['exploring', 'probing', 'converging', 'wrapping up'] },
337    picture: { type: 'string' },
338    buildNow: { type: 'string' },
339    assuming: { type: 'array', items: { type: 'string' } },
340    clarity: { type: 'number', minimum: 0, maximum: 10 },
341    slots: {
342      type: 'array',
343      items: {
344        type: 'object',
345        required: ['label', 'state'],
346        properties: { id: { type: 'string' }, label: { type: 'string' }, state: { type: 'string', enum: [...SLOT_STATES] }, summary: { type: 'string' } },
347      },
348    },
349    openItems: {
350      type: 'array',
351      items: {
352        type: 'object',
353        required: ['id', 'impact'],
354        properties: {
355          id: { type: 'string' },
356          slotId: { type: 'string' },
357          question: { type: 'string' },
358          impact: { type: 'string', enum: [...IMPACTS] },
359          costOfWrong: { type: 'string' },
360        },
361      },
362    },
363    lensesCovered: { type: 'array', items: { type: 'string', enum: [...LENSES] } },
364    lensesThin: { type: 'array', items: { type: 'string', enum: [...LENSES] } },
365    shifted: { type: 'string' },
366    questions: {
367      type: 'array',
368      maxItems: 4,
369      items: {
370        type: 'object',
371        required: ['text', 'lens', 'move'],
372        properties: { text: { type: 'string' }, lens: { type: 'string', enum: [...LENSES] }, move: { type: 'string', enum: [...MOVES] } },
373      },
374    },
375  },
376}
377
378export const METER_DESCRIPTION = [
379  'Report the questioning interview state to the cockpit. Call it from the questioning skill only.',
380  'event "start" when an interview opens; "question" right before every ordinary AskUserQuestion, listing that call\'s questions with lens and move;',
381  '"stop_check" when you print the stop check, listing the stop-check question; "finish" at wrap-up.',
382  'Always send the full current picture: clarity, slots with state, open items with id and impact.',
383  'Await the result before calling AskUserQuestion. Once started, AskUserQuestion is blocked without a fresh meter call, and after 4 answers without a stop check.',
384].join(' ')
385
hooks/meter.ts 156 lines
1import type { InterviewStatus, Meter } from '../types'
2
3type Field =
4  | 'progress'
5  | 'picture'
6  | 'buildNow'
7  | 'assuming'
8  | 'clarity'
9  | 'settled'
10  | 'open'
11  | 'lenses'
12  | 'costOfWrong'
13  | 'shifted'
14
15const LABELS: ReadonlyArray<[RegExp, Field]> = [
16  [/^progress\b\s*[·:-]?\s*/i, 'progress'],
17  [/^picture:\s*/i, 'picture'],
18  [/^build now:\s*/i, 'buildNow'],
19  [/^assuming:\s*/i, 'assuming'],
20  [/^clarity:\s*/i, 'clarity'],
21  [/^settled:\s*/i, 'settled'],
22  [/^open:\s*/i, 'open'],
23  [/^lenses:\s*/i, 'lenses'],
24  [/^cost of guessing wrong:\s*/i, 'costOfWrong'],
25  [/^shifted:\s*/i, 'shifted'],
26]
27
28const STATUS = /^\s*Questioning:\s*(interviewing|done|skipped)\b/im
29const FENCE = /^\s*```/
30
31const stripMarkdown = (line: string): string =>
32  line.replace(/^\s*[>*-]\s+/, '').replace(/\*\*/g, '').replace(/`/g, '')
33
34const labelOf = (line: string): [Field, string] | null => {
35  const bare = stripMarkdown(line).trim()
36  for (const [pattern, field] of LABELS) {
37    const match = bare.match(pattern)
38    if (match) return [field, bare.slice(match[0].length).trim()]
39  }
40  return null
41}
42
43export const splitTop = (value: string): string[] => {
44  const parts: string[] = []
45  let depth = 0
46  let current = ''
47  for (const char of value) {
48    if (char === '(' || char === '[') depth += 1
49    if (char === ')' || char === ']') depth = Math.max(0, depth - 1)
50    if (char === ',' && depth === 0) {
51      parts.push(current)
52      current = ''
53      continue
54    }
55    current += char
56  }
57  parts.push(current)
58  return parts.map(part => part.trim()).filter(part => part && !/^(none|-|n\/a)$/i.test(part))
59}
60
61const fieldsOf = (text: string): Map<Field, string[]> => {
62  const fields = new Map<Field, string[]>()
63  let current: Field | null = null
64  for (const line of text.split('\n')) {
65    if (FENCE.test(line) || STATUS.test(line)) {
66      current = null
67      continue
68    }
69    const labelled = labelOf(line)
70    if (labelled) {
71      const [field, rest] = labelled
72      current = field
73      fields.set(field, rest ? [rest] : [])
74      continue
75    }
76    if (!current) continue
77    const trimmed = stripMarkdown(line).trim()
78    if (!trimmed) {
79      current = null
80      continue
81    }
82    if (/^\s/.test(line) || current === 'costOfWrong') fields.get(current)?.push(trimmed)
83    else current = null
84  }
85  return fields
86}
87
88const joined = (fields: Map<Field, string[]>, field: Field): string => (fields.get(field) ?? []).join(' ').trim()
89
90const lensesOf = (value: string): { covered: string[]; thin: string[] } => {
91  const covered = value.match(/covered\s+(.*?)(?:\s+[·|;]\s+|\s+thin\b|$)/i)?.[1] ?? ''
92  const thin = value.match(/thin\s+(.*)$/i)?.[1] ?? ''
93  return { covered: splitTop(covered), thin: splitTop(thin) }
94}
95
96export const parseStatus = (text: string): InterviewStatus | null => {
97  const match = text.match(STATUS)
98  return match ? (match[1]!.toLowerCase() as InterviewStatus) : null
99}
100
101export type ParsedMeter = Omit<Meter, 'status' | 'history'>
102
103export const parseMeter = (text: string): ParsedMeter | null => {
104  const fields = fieldsOf(text)
105  const clarityText = joined(fields, 'clarity')
106  const clarity = clarityText.match(/(\d+(?:\.\d+)?)\s*\/\s*10/)
107  if (!clarity) return null
108  const progress = joined(fields, 'progress')
109  const lenses = lensesOf(joined(fields, 'lenses'))
110  return {
111    answers: Number(progress.match(/(\d+)\s+answers?/i)?.[1] ?? NaN) || null,
112    phase: progress.match(/phase:\s*([a-z ]+?)\s*$/i)?.[1]?.trim() ?? '',
113    picture: joined(fields, 'picture'),
114    buildNow: joined(fields, 'buildNow'),
115    assuming: joined(fields, 'assuming'),
116    clarity: Math.max(0, Math.min(10, Number(clarity[1]))),
117    settled: splitTop(joined(fields, 'settled')),
118    open: splitTop(joined(fields, 'open')),
119    lensesCovered: lenses.covered,
120    lensesThin: lenses.thin,
121    costOfWrong: (fields.get('costOfWrong') ?? []).filter(Boolean),
122    shifted: joined(fields, 'shifted').replace(/^(none|-|n\/a)\.?$/i, ''),
123  }
124}
125
126export const absorb = (previous: Meter | null, text: string): Meter | null => {
127  const parsed = parseMeter(text)
128  const status = parseStatus(text)
129  if (!parsed && !status) return previous
130  if (!parsed) return previous ? { ...previous, status: status ?? previous.status } : null
131  const history = [...(previous?.history ?? []), parsed.clarity].slice(-20)
132  return { ...parsed, status: status ?? previous?.status ?? 'interviewing', history }
133}
134
135export const bar = (clarity: number, width = 10): string => {
136  const filled = Math.round((clarity / 10) * width)
137  return '█'.repeat(filled) + '░'.repeat(width - filled)
138}
139
140export const trend = (history: number[]): string => {
141  if (history.length < 2) return ''
142  const delta = history[history.length - 1]! - history[history.length - 2]!
143  if (delta > 0) return `▲${delta}`
144  if (delta < 0) return `▼${-delta}`
145  return '='
146}
147
148export const textOfBlocks = (content: ReadonlyArray<{ type: string; text?: string }>): string =>
149  content
150    .filter(block => block.type === 'text' && typeof block.text === 'string')
151    .map(block => block.text as string)
152    .join('\n')
153
154export const replay = (texts: ReadonlyArray<string>): Meter | null =>
155  texts.reduce<Meter | null>((current, text) => absorb(current, text), null)
156
hooks/components.tsx 483 lines
1import type { Elements, RenderChildren, RenderElement, RenderNode, RenderSurface } from 'claude-code'
2
3import type { Feed, HookRow, HooksSnapshot, Interview, Meter, OpenItem, Panel, QuotaSnapshot, Sections, Slot, SsaRun, SsaSnapshot } from '../types'
4import type { Tone } from './charts'
5import { clarityChart, clarityTone, hooksChart, hookTone, pctTone, progressBar, quotaChart, quotaRows, runStrip, runTone } from './charts'
6import { lowestWindow } from './feeds'
7import { ago, clock, duration, sparkline, until } from './format'
8import { STOP_EVERY } from './interview'
9import { bar, trend } from './meter'
10
11export type Ui = Elements[RenderSurface]
12
13export type SectionKey = keyof Sections
14
15export type CockpitActions = {
16  toggle: (key: SectionKey) => void
17  cancelPanel: () => void
18  refresh: () => void
19  openPane: () => void
20  hideBand: () => void
21}
22
23export type CockpitData = {
24  now: number
25  width: number
26  meter: Meter | null
27  interview: Interview
28  panel: Panel
29  quota: Feed<QuotaSnapshot>
30  hooks: Feed<HooksSnapshot>
31  ssa: Feed<SsaSnapshot>
32  open: Sections
33}
34
35const TEXT_COLOR: Record<Tone, string> = { good: 'green', warn: 'yellow', bad: 'red', info: 'cyan', muted: 'gray' }
36
37const IMPACT_TONE: Record<string, Tone> = { high: 'bad', medium: 'warn', low: 'muted' }
38
39const SLOT_MARK: Record<Slot['state'], { mark: string; tone: Tone }> = {
40  settled: { mark: '✓', tone: 'good' },
41  partial: { mark: '◐', tone: 'warn' },
42  open: { mark: '○', tone: 'muted' },
43}
44
45const chartWidth = (columns: number): number => Math.max(260, Math.min(620, Math.round(columns * 7.2) - 40))
46
47const pips = (filled: number, total: number): string => '●'.repeat(Math.min(filled, total)) + '○'.repeat(Math.max(0, total - filled))
48
49function Pill({ ui, tone, label, key }: { ui: Ui; tone: Tone; label: string; key?: string }): RenderNode {
50  const { Box, Text } = ui
51  return (
52    <Box key={key} borderStyle="round" borderColor={TEXT_COLOR[tone]} paddingX={1}>
53      <Text color={TEXT_COLOR[tone]} wrap="truncate-end">{label}</Text>
54    </Box>
55  )
56}
57
58function Chart({ ui, source, alt, width, fallback }: { ui: Ui; source: string; alt: string; width: number; fallback: RenderChildren }): RenderNode {
59  const { Box } = ui
60  if ('Svg' in ui) {
61    const { Svg } = ui
62    return <Svg source={source} alt={alt} width={width} />
63  }
64  return <Box flexDirection="column">{fallback}</Box>
65}
66
67function Caption({ ui, children }: { ui: Ui; children: RenderChildren }): RenderNode {
68  const { Text } = ui
69  return <Text dimColor wrap="wrap">{children}</Text>
70}
71
72function Label({ ui, children }: { ui: Ui; children: RenderChildren }): RenderNode {
73  const { Text } = ui
74  return <Text bold>{children}</Text>
75}
76
77function FeedNotice({ ui, feed, now }: { ui: Ui; feed: Feed<unknown>; now: number }): RenderNode | null {
78  const { Box, Text } = ui
79  if (feed.status === 'loading') return <Caption ui={ui}>Loading…</Caption>
80  if (feed.status !== 'error') return null
81  return (
82    <Box borderStyle="round" borderColor="red" paddingX={1} flexDirection="column">
83      <Text color="red" bold>Feed error</Text>
84      <Text color="red" wrap="wrap">{feed.error}</Text>
85      {feed.lastOkAt ? <Caption ui={ui}>Showing data from {ago(feed.lastOkAt, now)}.</Caption> : null}
86    </Box>
87  )
88}
89
90function Card({
91  ui,
92  sectionKey,
93  title,
94  summary,
95  tone,
96  isOpen,
97  onToggle,
98  children,
99}: {
100  ui: Ui
101  sectionKey: SectionKey
102  title: string
103  summary: string
104  tone: Tone
105  isOpen: boolean
106  onToggle: () => void
107  children: RenderChildren
108}): RenderNode {
109  const { Box, Button, Text } = ui
110  return (
111    <Box key={`card-${sectionKey}`} flexDirection="column" borderStyle="round" borderColor={isOpen ? TEXT_COLOR[tone] : 'gray'} borderDimColor={!isOpen} paddingX={1} marginTop={1}>
112      <Box flexDirection="row" justifyContent="space-between" gap={1}>
113        <Button key={`toggle-${sectionKey}`} label={`${isOpen ? '▾' : '▸'}  ${title}`} plain onPress={onToggle} />
114        <Text color={TEXT_COLOR[tone]} wrap="truncate-end">● {summary}</Text>
115      </Box>
116      {isOpen ? (
117        <Box flexDirection="column" gap={1} paddingTop={1} paddingBottom={1}>
118          {children}
119        </Box>
120      ) : null}
121    </Box>
122  )
123}
124
125function Tile({ ui, label, value, tone, key }: { ui: Ui; label: string; value: string; tone: Tone; key: string }): RenderNode {
126  const { Box, Text } = ui
127  return (
128    <Box key={key} flexDirection="column" flexGrow={1} minWidth={16} borderStyle="round" borderColor={TEXT_COLOR[tone]} paddingX={1}>
129      <Text dimColor>{label}</Text>
130      <Text bold color={TEXT_COLOR[tone]} wrap="truncate-end">{value}</Text>
131    </Box>
132  )
133}
134
135export const questioningTone = (meter: Meter | null): Tone =>
136  meter === null ? 'muted' : meter.status === 'interviewing' ? clarityTone(meter.clarity) : 'muted'
137
138export const quotaTone = (feed: Feed<QuotaSnapshot>): Tone => {
139  if (feed.status === 'error') return 'bad'
140  const lowest = lowestWindow(feed.data)
141  return lowest ? pctTone(lowest.window.remainingPct) : 'muted'
142}
143
144export const hooksTone = (feed: Feed<HooksSnapshot>, now: number): Tone => {
145  if (feed.status === 'error') return 'bad'
146  const block = feed.data?.lastBlock
147  if (block && now - block.at < 10 * 60 * 1000) return 'bad'
148  return (feed.data?.inflight ?? 0) > 0 ? 'info' : 'good'
149}
150
151export const ssaTone = (feed: Feed<SsaSnapshot>): Tone => {
152  if (feed.status === 'error') return 'bad'
153  if ((feed.data?.running.length ?? 0) > 0) return 'info'
154  return feed.data?.recent[0]?.state === 'failed' ? 'warn' : 'good'
155}
156
157function Overview({ ui, data }: { ui: Ui; data: CockpitData }): RenderNode {
158  const { Box } = ui
159  const { meter, quota, hooks, ssa, now } = data
160  const lowest = lowestWindow(quota.data)
161  const running = ssa.data?.running.length ?? 0
162  return (
163    <Box flexDirection="row" flexWrap="wrap" gap={1}>
164      <Tile ui={ui} key="tile-q" label="Clarity" value={meter ? `${meter.clarity}/10 ${meter.status === 'interviewing' ? trend(meter.history) : meter.status}` : 'no interview'} tone={questioningTone(meter)} />
165      <Tile ui={ui} key="tile-quota" label="Tightest quota" value={lowest ? `${lowest.cli} ${lowest.window.remainingPct}%` : quota.status} tone={quotaTone(quota)} />
166      <Tile ui={ui} key="tile-hooks" label="Hooks" value={hooks.data?.lastBlock && now - hooks.data.lastBlock.at < 600000 ? `blocked ${ago(hooks.data.lastBlock.at, now)}` : `${hooks.data?.inflight ?? 0} running`} tone={hooksTone(hooks, now)} />
167      <Tile ui={ui} key="tile-ssa" label="Subagents" value={running ? `${running} running` : `${ssa.data?.recent.length ?? 0} recent`} tone={ssaTone(ssa)} />
168    </Box>
169  )
170}
171
172function SlotChips({ ui, slots, settled, open }: { ui: Ui; slots: readonly Slot[]; settled: readonly string[]; open: readonly string[] }): RenderNode {
173  const { Box } = ui
174  const items: { label: string; state: Slot['state'] }[] = slots.length
175    ? slots.map(slot => ({ label: slot.label, state: slot.state }))
176    : [...settled.map(label => ({ label, state: 'settled' as const })), ...open.map(label => ({ label, state: 'open' as const }))]
177  return (
178    <Box flexDirection="row" flexWrap="wrap" gap={1}>
179      {items.map((item, index) => (
180        <Pill ui={ui} key={`slot-${index}`} tone={SLOT_MARK[item.state].tone} label={`${SLOT_MARK[item.state].mark} ${item.label}`} />
181      ))}
182    </Box>
183  )
184}
185
186function OpenItems({ ui, items, costs }: { ui: Ui; items: readonly OpenItem[]; costs: readonly string[] }): RenderNode | null {
187  const { Box, Text } = ui
188  if (items.length === 0 && costs.length === 0) return null
189  return (
190    <Box flexDirection="column">
191      <Label ui={ui}>Open questions</Label>
192      {items.length
193        ? items.map((item, index) => (
194            <Box key={`item-${index}`} flexDirection="row" gap={1}>
195              <Text color={TEXT_COLOR[IMPACT_TONE[item.impact] ?? 'muted']} bold>{item.impact.toUpperCase().padEnd(6)}</Text>
196              <Box flexDirection="column" flexShrink={1}>
197                <Text wrap="wrap">{item.question || item.id}</Text>
198                {item.costOfWrong ? <Caption ui={ui}>If wrong: {item.costOfWrong}</Caption> : null}
199              </Box>
200            </Box>
201          ))
202        : costs.map((cost, index) => <Text key={`cost-${index}`} wrap="wrap">• {cost}</Text>)}
203    </Box>
204  )
205}
206
207function PanelBox({ ui, panel, now, width, onCancel }: { ui: Ui; panel: Panel; now: number; width: number; onCancel: () => void }): RenderNode | null {
208  const { Box, Button, Text } = ui
209  if (panel.phase === 'idle') return null
210  const tone: Tone = panel.phase === 'failed' ? 'bad' : panel.phase === 'countdown' ? 'warn' : panel.phase === 'running' ? 'info' : panel.phase === 'done' ? 'good' : 'muted'
211  const remaining = panel.phase === 'countdown' ? Math.max(0, panel.deadline - now) : 0
212  return (
213    <Box flexDirection="column" borderStyle="round" borderColor={TEXT_COLOR[tone]} paddingX={1}>
214      <Box flexDirection="row" justifyContent="space-between" gap={1}>
215        <Text bold color={TEXT_COLOR[tone]}>Auto panel · {panel.phase}</Text>
216        {panel.phase === 'countdown' ? <Button key="cancel-panel" label="Cancel" hotkey="x" variant="primary" onPress={onCancel} /> : null}
217      </Box>
218      <Text wrap="wrap">{panel.label}</Text>
219      {panel.phase === 'countdown' ? (
220        <Chart ui={ui} source={progressBar(1 - remaining / 30000, 'warn', width - 24)} alt={`Starts in ${Math.ceil(remaining / 1000)} seconds`} width={width - 24} fallback={<Text color="yellow">{bar(10 - remaining / 3000)} {Math.ceil(remaining / 1000)}s</Text>} />
221      ) : null}
222      {panel.phase === 'running' && panel.startedAt ? <Caption ui={ui}>Running for {duration(now - panel.startedAt)}</Caption> : null}
223      <Caption ui={ui}>{panel.message}</Caption>
224    </Box>
225  )
226}
227
228function QuestioningSection({ ui, data, actions }: { ui: Ui; data: CockpitData; actions: CockpitActions }): RenderNode {
229  const { Box, Text } = ui
230  const { meter, interview, panel, now, width, open } = data
231  const tone = questioningTone(meter)
232  const summary = meter ? `${meter.clarity}/10 · ${meter.status}${meter.source === 'text' ? ' (read from text)' : ''}` : 'no interview'
233  const body = meter
234    ? [
235        meter.topic || meter.phase ? (
236          <Box key="q-head" flexDirection="row" flexWrap="wrap" gap={1}>
237            {meter.topic ? <Text bold>{meter.topic}</Text> : null}
238            {meter.phase ? <Pill ui={ui} key="phase" tone="info" label={meter.phase} /> : null}
239          </Box>
240        ) : null,
241        <Chart
242          ui={ui}
243          key="q-chart"
244          source={clarityChart(meter.clarity, meter.history, width)}
245          alt={`Clarity ${meter.clarity} of 10, history ${meter.history.join(', ')}`}
246          width={width}
247          fallback={<Text color={TEXT_COLOR[tone]}>{bar(meter.clarity, 20)} {meter.clarity}/10 {trend(meter.history)} {sparkline(meter.history)}</Text>}
248        />,
249        meter.picture ? (
250          <Box key="q-picture" flexDirection="column">
251            <Label ui={ui}>Picture</Label>
252            <Text wrap="wrap">{meter.picture}</Text>
253          </Box>
254        ) : null,
255        meter.buildNow || meter.assuming ? (
256          <Box key="q-build" flexDirection="column">
257            {meter.buildNow ? <Label ui={ui}>Would build now</Label> : null}
258            {meter.buildNow ? <Text wrap="wrap">{meter.buildNow}</Text> : null}
259            {meter.assuming ? <Caption ui={ui}>Assuming {meter.assuming}</Caption> : null}
260          </Box>
261        ) : null,
262        <Box key="q-slots" flexDirection="column">
263          <Label ui={ui}>Slots</Label>
264          <SlotChips ui={ui} slots={meter.slots ?? []} settled={meter.settled} open={meter.open} />
265        </Box>,
266        <OpenItems ui={ui} key="q-items" items={meter.openItems ?? []} costs={meter.costOfWrong} />,
267        meter.lensesCovered.length || meter.lensesThin.length ? (
268          <Box key="q-lenses" flexDirection="row" flexWrap="wrap" gap={1}>
269            {meter.lensesCovered.map((lens, index) => <Pill ui={ui} key={`lc-${index}`} tone="good" label={lens} />)}
270            {meter.lensesThin.map((lens, index) => <Pill ui={ui} key={`lt-${index}`} tone="warn" label={`${lens} (thin)`} />)}
271          </Box>
272        ) : null,
273        meter.shifted ? <Caption ui={ui} key="q-shifted">Shifted: {meter.shifted}</Caption> : null,
274        <Box key="q-enforcer" flexDirection="column">
275          <Label ui={ui}>Enforcer</Label>
276          <Text>
277            {interview.id ? `${pips(interview.answersSinceStop, STOP_EVERY)}  ${interview.answersSinceStop}/${STOP_EVERY} answers to the next stop check` : 'Idle until a meter "start"'}
278          </Text>
279          <Caption ui={ui}>
280            {interview.totalAnswers} answers · {interview.stopChecks} stop checks · {interview.blocks} blocked
281          </Caption>
282          {interview.warnings.slice(-2).map((warning, index) => (
283            <Text key={`warn-${index}`} color="yellow" wrap="wrap">⚠ {warning}</Text>
284          ))}
285        </Box>,
286        <PanelBox ui={ui} key="q-panel" panel={panel} now={now} width={width} onCancel={actions.cancelPanel} />,
287      ]
288    : [<Caption ui={ui} key="q-empty">No interview yet. Run /questioning and the meter fills in here as questions go out.</Caption>]
289  return (
290    <Card ui={ui} sectionKey="questioning" title="Questioning" summary={summary} tone={tone} isOpen={open.questioning} onToggle={() => actions.toggle('questioning')}>
291      {body}
292    </Card>
293  )
294}
295
296function QuotaSection({ ui, data, actions }: { ui: Ui; data: CockpitData; actions: CockpitActions }): RenderNode {
297  const { Box, Text } = ui
298  const { quota, now, width, open } = data
299  const snapshot = quota.data
300  const rows = snapshot ? quotaRows(snapshot.clis, snapshot.primary, iso => until(iso, now)) : []
301  const lowest = lowestWindow(snapshot)
302  const summary = lowest ? `tightest ${lowest.cli} ${lowest.window.remainingPct}%` : quota.status
303  return (
304    <Card ui={ui} sectionKey="quota" title="Quota" summary={summary} tone={quotaTone(quota)} isOpen={open.quota} onToggle={() => actions.toggle('quota')}>
305      <FeedNotice ui={ui} feed={quota} now={now} />
306      {rows.length ? (
307        <Chart
308          ui={ui}
309          source={quotaChart(rows, width)}
310          alt={rows.map(row => `${row.cli} ${row.pct ?? '?'}%`).join(', ')}
311          width={width}
312          fallback={rows.map((row, index) => (
313            <Text key={`qr-${index}`} color={TEXT_COLOR[row.available ? pctTone(row.pct) : 'muted']} wrap="truncate-end">
314              {row.cli.padEnd(9)} {row.available ? `${bar((row.pct ?? 0) / 10)} ${row.pct ?? '?'}% ${row.window} ${row.reset}` : row.note}
315            </Text>
316          ))}
317        />
318      ) : null}
319      {snapshot ? (
320        <Box flexDirection="row" flexWrap="wrap" gap={1}>
321          <Pill ui={ui} key="primary" tone="info" label={`next worker: ${snapshot.primary || 'none'}`} />
322          <Pill ui={ui} key="local" tone={snapshot.localLaborOk ? 'good' : 'warn'} label={snapshot.localLaborOk ? 'local labor ok' : 'local labor tight'} />
323          <Pill ui={ui} key="checked" tone="muted" label={`checked ${ago(quota.lastOkAt, now)}`} />
324        </Box>
325      ) : null}
326    </Card>
327  )
328}
329
330function HookTable({ ui, rows, prefix }: { ui: Ui; rows: readonly HookRow[]; prefix: string }): RenderNode {
331  const { Box, Text } = ui
332  return (
333    <Box flexDirection="column">
334      {rows.map((row, index) => (
335        <Box key={`${prefix}-${index}`} flexDirection="row" gap={1}>
336          <Text dimColor>{clock(row.at)}</Text>
337          <Text color={TEXT_COLOR[hookTone(row.outcome)]}>●</Text>
338          <Box flexGrow={1} flexShrink={1}>
339            <Text wrap="truncate-end">
340              {row.event}
341              {row.tool ? ` · ${row.tool}` : ''}
342              {row.detail ? `  ${row.detail}` : ''}
343            </Text>
344          </Box>
345          {row.ms ? <Text dimColor>{row.ms} ms</Text> : null}
346          <Text color={TEXT_COLOR[hookTone(row.outcome)]}>{row.outcome}</Text>
347        </Box>
348      ))}
349    </Box>
350  )
351}
352
353function HooksSection({ ui, data, actions }: { ui: Ui; data: CockpitData; actions: CockpitActions }): RenderNode {
354  const { Box, Text } = ui
355  const { hooks, now, width, open } = data
356  const snapshot = hooks.data
357  const liveRows = snapshot?.live ?? []
358  const avg = liveRows.length ? Math.round(liveRows.reduce((sum, row) => sum + row.ms, 0) / liveRows.length) : 0
359  const summary = `${snapshot?.inflight ?? 0} running · avg ${avg} ms`
360  return (
361    <Card ui={ui} sectionKey="hooks" title="Hooks" summary={summary} tone={hooksTone(hooks, now)} isOpen={open.hooks} onToggle={() => actions.toggle('hooks')}>
362      <FeedNotice ui={ui} feed={hooks} now={now} />
363      {snapshot?.lastBlock ? (
364        <Box borderStyle="round" borderColor="red" paddingX={1} flexDirection="column">
365          <Text color="red" bold>Last block · {ago(snapshot.lastBlock.at, now)}</Text>
366          <Text wrap="wrap">
367            {snapshot.lastBlock.source} {snapshot.lastBlock.event} {snapshot.lastBlock.tool} {snapshot.lastBlock.outcome} {snapshot.lastBlock.detail}
368          </Text>
369        </Box>
370      ) : null}
371      <Chart
372        ui={ui}
373        source={hooksChart(liveRows, width)}
374        alt={`${liveRows.length} recent hook runs, average ${avg} ms`}
375        width={width}
376        fallback={<Text dimColor>{liveRows.length} hook runs · avg {avg} ms</Text>}
377      />
378      <Box flexDirection="column">
379        <Label ui={ui}>Live</Label>
380        {liveRows.length ? <HookTable ui={ui} rows={liveRows.slice(-6).reverse()} prefix="live" /> : <Caption ui={ui}>Waiting for the first hook run.</Caption>}
381      </Box>
382      <Box flexDirection="column">
383        <Label ui={ui}>Gate and Jev log</Label>
384        {(snapshot?.logs.length ?? 0) > 0 ? <HookTable ui={ui} rows={(snapshot?.logs ?? []).slice(-6).reverse()} prefix="log" /> : <Caption ui={ui}>No log rows yet.</Caption>}
385      </Box>
386    </Card>
387  )
388}
389
390function RunRow({ ui, run, now, key }: { ui: Ui; run: SsaRun; now: number; key: string }): RenderNode {
391  const { Box, Text } = ui
392  const tone = runTone(run.state)
393  return (
394    <Box key={key} flexDirection="row" gap={1}>
395      <Text color={TEXT_COLOR[tone]}>●</Text>
396      <Box flexGrow={1} flexShrink={1} flexDirection="column">
397        <Text wrap="truncate-end">
398          <Text bold>{run.type === 'plan' ? 'panel' : run.kind || 'task'}</Text>
399          {` · ${run.worker || run.model || 'worker?'} · ${run.phase}`}
400          {run.failureClass ? ` · ${run.failureClass}` : ''}
401        </Text>
402        <Text dimColor wrap="truncate-end">{run.id}</Text>
403      </Box>
404      <Text color={TEXT_COLOR[tone]}>{run.state === 'running' ? duration(now - run.startedAt) : ago(run.updatedAt, now)}</Text>
405    </Box>
406  )
407}
408
409function SubagentsSection({ ui, data, actions }: { ui: Ui; data: CockpitData; actions: CockpitActions }): RenderNode {
410  const { Box, Text } = ui
411  const { ssa, now, width, open } = data
412  const running = ssa.data?.running ?? []
413  const recent = ssa.data?.recent ?? []
414  const summary = running.length ? `${running.length} running` : `${recent.length} recent`
415  return (
416    <Card ui={ui} sectionKey="subagents" title="Subagents" summary={summary} tone={ssaTone(ssa)} isOpen={open.subagents} onToggle={() => actions.toggle('subagents')}>
417      <FeedNotice ui={ui} feed={ssa} now={now} />
418      <Box flexDirection="column">
419        <Label ui={ui}>Running now</Label>
420        {running.length ? running.map((run, index) => <RunRow ui={ui} key={`run-${index}`} run={run} now={now} />) : <Caption ui={ui}>Nothing running.</Caption>}
421      </Box>
422      {recent.length ? (
423        <Chart
424          ui={ui}
425          source={runStrip(recent, width)}
426          alt={`${recent.length} recent runs`}
427          width={width}
428          fallback={<Text>{recent.slice(0, 30).reverse().map(run => (run.state === 'failed' ? '✗' : run.state === 'expired' ? '·' : '✓')).join('')}</Text>}
429        />
430      ) : null}
431      <Box flexDirection="column">
432        <Label ui={ui}>History</Label>
433        {recent.length ? recent.slice(0, 8).map((run, index) => <RunRow ui={ui} key={`hist-${index}`} run={run} now={now} />) : <Caption ui={ui}>No runs recorded yet.</Caption>}
434      </Box>
435    </Card>
436  )
437}
438
439export function Cockpit({ ui, data, actions }: { ui: Ui; data: CockpitData; actions: CockpitActions }): RenderElement {
440  const { Box, Button, Text } = ui
441  const sized: CockpitData = { ...data, width: chartWidth(data.width) }
442  return (
443    <Box flexDirection="column">
444      <Box flexDirection="row" justifyContent="space-between" gap={1}>
445        <Text bold>Cockpit</Text>
446        <Box flexDirection="row" gap={1}>
447          <Text dimColor>{clock(data.now)}</Text>
448          <Button key="refresh" label="Refresh" hotkey="r" plain onPress={actions.refresh} />
449        </Box>
450      </Box>
451      <Overview ui={ui} data={sized} />
452      <QuestioningSection ui={ui} data={sized} actions={actions} />
453      <QuotaSection ui={ui} data={sized} actions={actions} />
454      <HooksSection ui={ui} data={sized} actions={actions} />
455      <SubagentsSection ui={ui} data={sized} actions={actions} />
456    </Box>
457  )
458}
459
460export function Band({ ui, data, actions }: { ui: Ui; data: CockpitData; actions: CockpitActions }): RenderElement | null {
461  const { Box, Button, Text } = ui
462  const { meter, quota, ssa, hooks, panel, now } = data
463  const lowest = lowestWindow(quota.data)
464  const running = ssa.data?.running ?? []
465  const block = hooks.data?.lastBlock ?? null
466  const showBlock = block !== null && now - block.at < 10 * 60 * 1000
467  const showClarity = meter !== null && meter.status === 'interviewing'
468  if (!showClarity && lowest === null && running.length === 0 && !showBlock && panel.phase !== 'countdown') return null
469  const parts: RenderNode[] = []
470  if (showClarity && meter) parts.push(<Text key="b-q" color={TEXT_COLOR[clarityTone(meter.clarity)]}>◆ {meter.clarity}/10 {trend(meter.history)}</Text>)
471  if (panel.phase === 'countdown') parts.push(<Text key="b-p" color="yellow">⏱ panel in {Math.max(0, Math.ceil((panel.deadline - now) / 1000))}s</Text>)
472  if (lowest) parts.push(<Text key="b-quota" color={TEXT_COLOR[pctTone(lowest.window.remainingPct)]}>▮ {lowest.cli} {lowest.window.remainingPct}%</Text>)
473  if (running.length) parts.push(<Text key="b-ssa" color="cyan">⟳ {running.map(run => `${run.worker || run.kind} ${duration(now - run.startedAt)}`).join(', ')}</Text>)
474  if (showBlock && block) parts.push(<Text key="b-block" color="red" wrap="truncate-end">⛔ {block.source} {block.outcome} {block.tool}</Text>)
475  return (
476    <Box flexDirection="row" gap={2} alignItems="center">
477      {parts}
478      <Button key="open" label="Cockpit" hotkey="c" plain onPress={actions.openPane} />
479      <Button key="hide" label="Hide" hotkey="h" plain dimColor onPress={actions.hideBand} />
480    </Box>
481  )
482}
483
hooks/charts.ts 180 lines
1import type { HookRow, QuotaCli, SsaRun } from '../types'
2
3export type Tone = 'good' | 'warn' | 'bad' | 'info' | 'muted'
4
5type Shade = { light: string; dark: string }
6
7const PALETTE: Record<Tone | 'track' | 'ink' | 'sub' | 'grid', Shade> = {
8  good: { light: '#2f9e63', dark: '#4cc38a' },
9  warn: { light: '#c27c0e', dark: '#e5b546' },
10  bad: { light: '#cf4545', dark: '#f07474' },
11  info: { light: '#3478c0', dark: '#6aaee8' },
12  muted: { light: '#9aa0a8', dark: '#6b7079' },
13  track: { light: '#e7e9ec', dark: '#2b2f35' },
14  grid: { light: '#d4d7dc', dark: '#3a3f46' },
15  ink: { light: '#1f2328', dark: '#e6e8eb' },
16  sub: { light: '#6b7079', dark: '#9aa1ab' },
17}
18
19const FILL_CLASSES = Object.keys(PALETTE) as (keyof typeof PALETTE)[]
20
21const STYLE = [
22  '<style>',
23  'text{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}',
24  ...FILL_CLASSES.map(name => `.f-${name}{fill:${PALETTE[name].light}}.s-${name}{stroke:${PALETTE[name].light}}`),
25  '@media (prefers-color-scheme: dark){',
26  ...FILL_CLASSES.map(name => `.f-${name}{fill:${PALETTE[name].dark}}.s-${name}{stroke:${PALETTE[name].dark}}`),
27  '}',
28  '</style>',
29].join('')
30
31export const escapeXml = (value: string): string =>
32  value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
33
34const svg = (width: number, height: number, body: string): string =>
35  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${STYLE}${body}</svg>`
36
37const round = (value: number): number => Math.round(value * 10) / 10
38
39export const clarityTone = (clarity: number): Tone => (clarity >= 8 ? 'good' : clarity >= 5 ? 'warn' : 'bad')
40
41export const pctTone = (pct: number | null): Tone => (pct === null ? 'muted' : pct < 20 ? 'bad' : pct < 50 ? 'warn' : 'good')
42
43export const runTone = (state: string): Tone =>
44  state === 'running' ? 'info' : state === 'failed' ? 'bad' : state === 'expired' ? 'muted' : 'good'
45
46export const hookTone = (outcome: string): Tone =>
47  outcome === 'deny' || outcome === 'block' || outcome.startsWith('fired') ? 'bad' : outcome === 'ask' || outcome === 'context' ? 'warn' : 'info'
48
49const arc = (cx: number, cy: number, r: number, from: number, to: number): string => {
50  const point = (t: number) => {
51    const angle = Math.PI * (1 - t)
52    return `${round(cx + r * Math.cos(angle))} ${round(cy - r * Math.sin(angle))}`
53  }
54  return `M ${point(from)} A ${r} ${r} 0 0 1 ${point(to)}`
55}
56
57export const clarityChart = (clarity: number, history: readonly number[], width = 340): string => {
58  const height = 96
59  const tone = clarityTone(clarity)
60  const t = Math.max(0, Math.min(1, clarity / 10))
61  const gauge = [
62    `<path d="${arc(52, 70, 40, 0, 1)}" class="s-track" stroke-width="10" fill="none" stroke-linecap="round"/>`,
63    t > 0 ? `<path d="${arc(52, 70, 40, 0, t)}" class="s-${tone}" stroke-width="10" fill="none" stroke-linecap="round"/>` : '',
64    `<text x="52" y="66" text-anchor="middle" font-size="22" font-weight="600" class="f-ink">${round(clarity)}</text>`,
65    `<text x="52" y="86" text-anchor="middle" font-size="10" class="f-sub">clarity / 10</text>`,
66  ].join('')
67
68  const left = 118
69  const right = width - 8
70  const top = 14
71  const bottom = 78
72  const y = (value: number) => round(bottom - (Math.max(0, Math.min(10, value)) / 10) * (bottom - top))
73  const points = history.slice(-20)
74  const step = points.length > 1 ? (right - left) / (points.length - 1) : 0
75  const xy = points.map((value, index) => `${round(points.length > 1 ? left + index * step : (left + right) / 2)},${y(value)}`)
76  const trend = [
77    `<line x1="${left}" x2="${right}" y1="${y(8)}" y2="${y(8)}" class="s-good" stroke-width="1" stroke-dasharray="3 3" opacity="0.7"/>`,
78    `<text x="${right}" y="${y(8) - 4}" text-anchor="end" font-size="9" class="f-sub">ready at 8</text>`,
79    `<line x1="${left}" x2="${right}" y1="${bottom}" y2="${bottom}" class="s-grid" stroke-width="1"/>`,
80    points.length > 1 ? `<polyline points="${xy.join(' ')}" fill="none" class="s-${tone}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>` : '',
81    ...xy.map((pair, index) => {
82      const [cx, cy] = pair.split(',')
83      const isLast = index === xy.length - 1
84      return `<circle cx="${cx}" cy="${cy}" r="${isLast ? 3.5 : 2}" class="f-${isLast ? tone : 'sub'}"/>`
85    }),
86    `<text x="${left}" y="92" font-size="9" class="f-sub">${points.length} stop check${points.length === 1 ? '' : 's'}</text>`,
87  ].join('')
88
89  return svg(width, height, gauge + trend)
90}
91
92export type QuotaRow = { cli: string; pct: number | null; window: string; reset: string; available: boolean; isPrimary: boolean; note: string }
93
94export const quotaRows = (clis: readonly QuotaCli[], primary: string, describeReset: (iso: string | null) => string): QuotaRow[] =>
95  clis.map(cli => {
96    const tightest = cli.windows
97      .filter(window => window.remainingPct !== null)
98      .sort((a, b) => (a.remainingPct ?? 0) - (b.remainingPct ?? 0))[0]
99    return {
100      cli: cli.cli,
101      pct: tightest?.remainingPct ?? null,
102      window: tightest?.name.replace(/_/g, ' ') ?? '',
103      reset: tightest ? describeReset(tightest.resetsAt) : '',
104      available: cli.available,
105      isPrimary: cli.cli === primary,
106      note: cli.available ? (cli.eligible ? '' : 'not eligible') : cli.error || 'unavailable',
107    }
108  })
109
110export const quotaChart = (rows: readonly QuotaRow[], width = 340): string => {
111  const rowHeight = 30
112  const height = rows.length * rowHeight + 6
113  const labelWidth = 74
114  const barLeft = labelWidth
115  const barRight = width - 8
116  const barWidth = barRight - barLeft
117  const body = rows
118    .map((row, index) => {
119      const top = 4 + index * rowHeight
120      const tone = row.available ? pctTone(row.pct) : 'muted'
121      const fill = row.pct === null ? 0 : Math.max(0, Math.min(100, row.pct)) / 100
122      const caption = row.available
123        ? row.pct === null
124          ? ['no usage window reported', row.note].filter(Boolean).map(escapeXml).join(' · ')
125          : `${round(row.pct)}% left · ${escapeXml(row.window)}${row.reset ? ` · ${escapeXml(row.reset)}` : ''}${row.note ? ` · ${escapeXml(row.note)}` : ''}`
126        : escapeXml(row.note)
127      return [
128        `<text x="0" y="${top + 11}" font-size="11.5" font-weight="${row.isPrimary ? 700 : 500}" class="f-${row.available ? 'ink' : 'muted'}">${escapeXml(row.cli)}${row.isPrimary ? ' ★' : ''}</text>`,
129        `<rect x="${barLeft}" y="${top + 3}" width="${barWidth}" height="8" rx="4" class="f-track"/>`,
130        fill > 0 ? `<rect x="${barLeft}" y="${top + 3}" width="${round(Math.max(8, barWidth * fill))}" height="8" rx="4" class="f-${tone}"/>` : '',
131        `<text x="${barLeft}" y="${top + 24}" font-size="9.5" class="f-sub">${caption}</text>`,
132      ].join('')
133    })
134    .join('')
135  return svg(width, height, body)
136}
137
138export const hooksChart = (rows: readonly HookRow[], width = 340): string => {
139  const height = 64
140  const recent = rows.slice(-40)
141  const maxMs = Math.max(50, ...recent.map(row => row.ms))
142  const scale = (ms: number) => Math.log10(1 + ms) / Math.log10(1 + maxMs)
143  const top = 6
144  const bottom = 48
145  const slot = (width - 8) / Math.max(24, recent.length)
146  const bars = recent
147    .map((row, index) => {
148      const h = Math.max(2, scale(row.ms) * (bottom - top))
149      return `<rect x="${round(4 + index * slot + slot * 0.15)}" y="${round(bottom - h)}" width="${round(slot * 0.7)}" height="${round(h)}" rx="1.5" class="f-${hookTone(row.outcome)}"><title>${escapeXml(`${row.event} ${row.tool} ${row.ms}ms ${row.outcome}`)}</title></rect>`
150    })
151    .join('')
152  const axis = [
153    `<line x1="4" x2="${width - 4}" y1="${bottom}" y2="${bottom}" class="s-grid" stroke-width="1"/>`,
154    `<text x="4" y="61" font-size="9" class="f-sub">last ${recent.length} hook runs · tallest ${maxMs} ms (log scale)</text>`,
155  ].join('')
156  return svg(width, height, recent.length ? bars + axis : `<text x="4" y="30" font-size="11" class="f-sub">No hook runs seen yet</text>`)
157}
158
159export const runStrip = (runs: readonly SsaRun[], width = 340): string => {
160  const recent = runs.slice(0, 30).reverse()
161  const size = 12
162  const gap = 4
163  const height = 34
164  const cells = recent
165    .map(
166      (run, index) =>
167        `<rect x="${index * (size + gap)}" y="2" width="${size}" height="${size}" rx="3" class="f-${runTone(run.state)}"><title>${escapeXml(`${run.id} ${run.state} ${run.worker || run.kind}`)}</title></rect>`,
168    )
169    .join('')
170  const counts = (['done', 'failed', 'expired'] as const).map(state => `${runs.filter(run => run.state === state).length} ${state}`).join(' · ')
171  return svg(width, height, `${cells}<text x="0" y="30" font-size="9.5" class="f-sub">oldest → newest · ${counts}</text>`)
172}
173
174export const progressBar = (fraction: number, tone: Tone, width = 340): string =>
175  svg(
176    width,
177    8,
178    `<rect x="0" y="1" width="${width}" height="6" rx="3" class="f-track"/><rect x="0" y="1" width="${round(Math.max(0, Math.min(1, fraction)) * width)}" height="6" rx="3" class="f-${tone}"/>`,
179  )
180
types/index.d.ts 121 lines
1export type InterviewStatus = 'interviewing' | 'done' | 'skipped' | 'unknown'
2
3export type Lens = 'User' | 'Outcome' | 'Scope' | 'Execution' | 'Constraint' | 'Risk' | 'Alternative' | 'Contrarian'
4
5export type SlotState = 'open' | 'partial' | 'settled'
6
7export type Slot = { id: string; label: string; state: SlotState; summary: string }
8
9export type Impact = 'low' | 'medium' | 'high'
10
11export type OpenItem = { id: string; slotId: string; question: string; impact: Impact; costOfWrong: string }
12
13export type PlannedQuestion = { text: string; lens: Lens; move: string }
14
15export type Meter = {
16  answers: number | null
17  phase: string
18  picture: string
19  buildNow: string
20  assuming: string
21  clarity: number
22  settled: string[]
23  open: string[]
24  lensesCovered: string[]
25  lensesThin: string[]
26  costOfWrong: string[]
27  shifted: string
28  status: InterviewStatus
29  history: number[]
30  source?: 'tool' | 'text'
31  topic?: string
32  slots?: Slot[]
33  openItems?: OpenItem[]
34}
35
36export type Interview = {
37  id: string | null
38  topic: string
39  permits: PlannedQuestion[]
40  permitFromStop: boolean
41  answersSinceStop: number
42  totalAnswers: number
43  recentLenses: Lens[]
44  handled: string[]
45  warnings: string[]
46  blocks: number
47  stopChecks: number
48  openStreak: Record<string, number>
49  panelTried: string[]
50}
51
52export type PanelPhase = 'idle' | 'countdown' | 'running' | 'done' | 'failed' | 'cancelled' | 'skipped'
53
54export type Panel = {
55  phase: PanelPhase
56  itemId: string
57  label: string
58  deadline: number
59  message: string
60  startedAt: number
61  goalFile: string
62}
63
64export type FeedStatus = 'loading' | 'ok' | 'error'
65
66export type Feed<T> = {
67  status: FeedStatus
68  data: T | null
69  error: string
70  lastOkAt: number
71  lastAttemptAt: number
72}
73
74export type QuotaWindow = { name: string; remainingPct: number | null; resetsAt: string | null; severity: string }
75
76export type QuotaCli = { cli: string; available: boolean; eligible: boolean; error: string; windows: QuotaWindow[] }
77
78export type QuotaSnapshot = { checkedAt: string; clis: QuotaCli[]; primary: string; localLaborOk: boolean }
79
80export type HookSource = 'live' | 'jev' | 'gate'
81
82export type HookRow = { at: number; source: HookSource; event: string; tool: string; ms: number; outcome: string; detail: string }
83
84export type HooksSnapshot = { live: HookRow[]; logs: HookRow[]; inflight: number; lastBlock: HookRow | null }
85
86export type SsaState = 'running' | 'done' | 'failed' | 'expired'
87
88export type SsaRun = {
89  id: string
90  type: 'task' | 'plan'
91  kind: string
92  worker: string
93  model: string
94  state: SsaState
95  phase: string
96  exitCode: number | null
97  failureClass: string
98  detail: string
99  startedAt: number
100  updatedAt: number
101}
102
103export type SsaSnapshot = { running: SsaRun[]; recent: SsaRun[] }
104
105export type Sections = { questioning: boolean; quota: boolean; hooks: boolean; subagents: boolean }
106
107declare module 'claude-code' {
108  interface PluginState {
109    'claude-code-cockpit': {
110      meter: Meter | null
111      isBandHidden: boolean
112      interview: Interview
113      panel: Panel
114      quota: Feed<QuotaSnapshot>
115      hooks: Feed<HooksSnapshot>
116      ssa: Feed<SsaSnapshot>
117      sections: Sections
118    }
119  }
120}
121