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

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.
| Clarity | Quota |
|---|---|
| Hook runs | Subagent history |
The images above come from hooks/charts.ts with sample data, rendered by scripts/render-docs.ts.
Cockpit pane. A row of status tiles, then one collapsible card per section:
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:
AskUserQuestion that has no fresh meter call,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.
~/.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.~/.claude/cache/jev-hooks.jsonl and ~/.claude/cache/delegation-gate.jsonl from your own hooks. When absent, the Hooks card shows live runs only.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.
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.
event | When |
|---|---|
start | The interview opens. Arms the enforcer. |
question | Before every ordinary AskUserQuestion, listing its questions with lens and move. |
stop_check | When you show the stop check (progress summary), listing its question. Resets the 4-answer counter. |
finish | At 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.
| Path | What it holds |
|---|---|
hooks/register.tsx | Hook wiring: tools, enforcer, feeds and timers, auto panel, render hooks |
hooks/components.tsx | The Cockpit, its section cards and the band |
hooks/charts.ts | SVG chart builders with a light and dark palette |
hooks/interview.ts | Meter schema, interview state machine and enforcer rules |
hooks/feeds.ts | Parsers for quota, hook logs and subagent run folders |
hooks/meter.ts | Fallback parser for a stop check printed as text |
types/index.d.ts | The mod's state contract |
tests/ | Enforcer, feed, chart and render tests |
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.
MIT
hooks/register.tsx 651 lines1import { 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}
651hooks/feeds.ts 223 lines1import 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}
223hooks/format.ts 39 lines1const 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}
39hooks/interview.ts 385 lines1import 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(' ')
385hooks/meter.ts 156 lines1import 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)
156hooks/components.tsx 483 lines1import 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}
483hooks/charts.ts 180 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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 )
180types/index.d.ts 121 lines1export 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