Dotfiles – (neo)vim, tmux and zsh configuration

Managed with chezmoi. Shell, terminals, editors, git, and the Claude Code setup I actually work in.
brew install chezmoi
chezmoi init --apply rcliao
That prompts for three things and writes them to ~/.config/chezmoi/chezmoi.toml, which stays local to the machine and is never committed:
| Prompt | Why it is per-machine |
|---|---|
| Full name | git author name |
| Git email | so a work laptop and a personal box can share this repo without cross-signing commits |
| Ghost hooks | see Ghost below |
Re-running chezmoi init later is non-interactive — it keeps whatever is already set. To change an answer, edit ~/.config/chezmoi/chezmoi.toml and chezmoi apply. For an unattended install, chezmoi init --apply --promptDefaults takes the personal identity with Ghost off.
The first apply runs brew bundle for everything below. A package failure warns rather than aborting, so the dotfiles land either way.
chezmoi diff # what would change
chezmoi apply # everything
chezmoi apply ~/.zshrc # just one target
chezmoi add ~/.tmux.conf # pull a local edit back into the repo
chezmoi update # git pull + apply
This repo is edited from more than one machine, so read chezmoi diff before applying. Whichever machine committed last is not automatically the machine that is more correct.
Shell — .zshrc (zsh, pure prompt, shared history across panes, cached compinit), .gitconfig (delta pager, zdiff3 conflicts, rerere, auto-set upstream on push), .config/git/ignore, .ssh/config (keychain-backed agent loading — this is what replaces the ssh-add -A that used to run on every shell start).
Terminals — ghostty.
Multiplexers — herdr (primary, keys mapped to match tmux muscle memory), tmux as the fallback. The old zellij config is kept under archive/, which is not deployed.
Editors — neovim (0.12+: one init.lua, plugins via the built-in vim.pack, LSP for Go, TypeScript, Rust, Python and Terraform from Brewfile-installed servers), emacs.
Runtimes — mise, activated at the end of .zshrc so it wins over the PATH exports above it. Homebrew still provides the global node, go, and python; mise only takes over inside a directory that pins a version in mise.toml or .tool-versions.
Window management — aerospace.
Claude Code — CLAUDE.md, settings.json, and the hook scripts under .claude/hooks. settings.json is a template: hooks belonging to tools that may not be installed (herdr, Zero) are only wired in when their script is actually present, so a machine never ends up pointing at a hook that does not exist.
Other — bat, yazi.
The ghost-* hooks drive the Ghost MCP memory server. A machine without Ghost has no use for them, so they are off by default: neither the scripts nor their settings.json entries are installed.
To turn them on, set ghost = true in ~/.config/chezmoi/chezmoi.toml and chezmoi apply. To turn them off again, flip it back and apply.
Homebrew refuses casks from untrusted taps, and trusting one is a decision worth making deliberately, so the bundle does not install aerospace:
brew trust --cask nikitabobko/tap/aerospace # this one cask, not the whole tap
brew install --cask nikitabobko/tap/aerospace
Anything regenerated, tool-managed, or machine-specific — Claude Code session transcripts and caches, plugin directories, herdr's own integration hook, and ~/.config/chezmoi/chezmoi.toml itself. See .chezmoiignore, which lists each exclusion explicitly so chezmoi add ~/.claude can never sweep one in.
This repo is public, so nothing employer-specific goes in it.
hooks/register.tsx 741 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Brief, CtxSample, Decision, Job, LedgerEntry, TurnSample } from '../types'
5import type { BandMode, SessionRecord } from './brief.ts'
6import {
7 allViewText,
8 appendDecisions,
9 queueText,
10 settleDecisions,
11 BAND_MODES,
12 openDecisions,
13 briefMarkdown,
14 glanceRows,
15 nextBrief,
16 parseSummary,
17 SUMMARIZE_SYSTEM,
18 summarizePrompt,
19 validateInput,
20} from './brief.ts'
21import {
22 appendEntries,
23 appendSample,
24 appendTurn,
25 classifyBash,
26 classifyMcp,
27 ctxView,
28 FILE_TOOLS,
29 ledgerMarkdown,
30 summaryLine,
31} from './ledger.ts'
32import {
33 bandRows,
34 duration,
35 lastLineOf,
36 parseBackgroundStart,
37 parseProgress,
38 parseTaskEnds,
39 progressText,
40 pushHistory,
41 shortLabel,
42 trimJobs,
43 visibleLines,
44} from './progress.ts'
45import type { TaskEnd } from './progress.ts'
46
47// --- state (the facts layer keeps run-ledger's and jobs' shapes) ---
48
49const entriesAtom = atom({ plugin: 'brief', key: 'entries' } as const, [] as LedgerEntry[])
50const ctxAtom = atom({ plugin: 'brief', key: 'ctx' } as const, [] as CtxSample[])
51const failsAtom = atom({ plugin: 'brief', key: 'fails' } as const, 0)
52const turnsAtom = atom({ plugin: 'brief', key: 'turns' } as const, [] as TurnSample[])
53const limitsAtom = atom({ plugin: 'brief', key: 'limits' } as const, null as null | { window: number; threshold?: number; isAutoCompact: boolean })
54const jobsAtom = atom({ plugin: 'brief', key: 'jobs' } as const, [] as Job[])
55const lastPromptAtom = atom({ plugin: 'brief', key: 'lastPromptAt' } as const, 0)
56const briefAtom = atom({ plugin: 'brief', key: 'brief' } as const, null as Brief | null)
57const decisionsAtom = atom({ plugin: 'brief', key: 'decisions' } as const, [] as Decision[])
58const workingAtom = atom({ plugin: 'brief', key: 'isWorking' } as const, false)
59
60const BRIEF_TOOL = 'mcp__brief__brief'
61const REPORT_TOOL = 'mcp__brief__report'
62const PANE = 'brief'
63const ALL_PANE = 'brief-all'
64const DATA_DIR = '/.claude/mods-data/brief'
65const FALLBACK_MODEL = 'haiku'
66// Context grows inside one long autonomous turn, so sample between tool calls too, at most this often.
67const SAMPLE_EVERY_MS = 15_000
68const POLL_MS = 5_000
69const HEARTBEAT_MS = 60_000
70const TAIL_BYTES = '16384'
71
72// Without a prompt rule a model-facing tool goes unused (run-ledger's eval: 0/5 → 3/3).
73const BRIEF_RULE = `Keep the person's glance view current with ${BRIEF_TOOL}. They run many sessions, check each for a few seconds, and want you to keep going. Call it when your phase of work changes, and as your last tool call before you end any turn in which you used tools, with: status, one line of at most 80 characters on where the work stands; asks, only what truly blocks you (an outward or destructive action such as push, deploy, delete or spend; access or a credential; a choice where a wrong guess would waste most of the remaining work), each naming the exact choice with your recommended option first marked (recommended), or an empty list; decided, each new judgment call you made on their behalf so you could keep going (what you chose, the alternatives, whether it is cheap to undo). The tool answers with the numbered queue: to revise a queued decision send it again with its n; when the person agrees in chat, or it no longer applies, list it in settled (accepted or withdrawn); when they change it, settle the old one and act on the change. Do not stop for a decision you can make reasonably and undo cheaply: decide, record it in decided, and continue. Before an end-of-run report (Changed / Blocked on me / Found), call ${REPORT_TOOL}: build Changed from its facts, Blocked on me from its open asks, and list its open decisions under Blocked on me as 'Decided for you (change if wrong)'.`
74const JOBS_RULE =
75 'When you start a long-running command with run_in_background and you control its output (a script or loop you write), have it print progress lines `::progress <done>/<total> <what>` (or `::progress <n>%`), and `::status <text>` on a phase change; the user sees them live in the brief band and pane.'
76
77// The module's own variables start over on a hot reload; a lost turn id only skips one fallback.
78let lastSampleAt = 0
79let currentTurnId: string | undefined
80let briefTurnId: string | undefined
81// Whether /brief's pane is open (so /brief toggles it), and how much the band shows: full, min (status,
82// asks, and only red context or failed jobs), or off. The mode is kept across sessions in $.store.
83let paneOpen = false
84let bandMode: BandMode = 'full'
85
86// --- facts layer ---
87
88async function sampleContext($: EngineInterface): Promise<void> {
89 const usage = await $.session.usage()
90 const { tokens, percent } = usage.context
91 // Absent before the window's first response and right after a compaction.
92 if (tokens === undefined || percent === undefined) return
93 const sample: CtxSample = { percent, tokens, usd: usage.cost?.usd, at: await $.clock.now() }
94 await update($, ctxAtom, prev => appendSample(prev, sample))
95}
96
97// Once per main-loop turn: the turn's end size (growth per turn) and the compaction threshold.
98async function sampleTurn($: EngineInterface): Promise<void> {
99 const usage = await $.session.usage({ breakdown: 'summary' })
100 const { tokens, window, breakdown } = usage.context
101 const limits =
102 breakdown?.autoCompactThreshold === undefined
103 ? { window, isAutoCompact: breakdown?.isAutoCompactEnabled ?? false }
104 : { window, threshold: breakdown.autoCompactThreshold, isAutoCompact: breakdown.isAutoCompactEnabled }
105 await update($, limitsAtom, () => limits)
106 if (tokens === undefined) return
107 const turn: TurnSample = { tokens, at: await $.clock.now() }
108 await update($, turnsAtom, prev => appendTurn(prev, turn))
109}
110
111// Working: a main-loop turn is running, or a background shell or agent it started still is. A turn that
112// ended while its subagents run must not read as done.
113// The decision queue outlives the process: kept per session in $.store, so a resumed session (claude --resume,
114// claude -p --resume) gets back what the person has not reviewed yet. /clear drops it; entries expire after a while.
115const DECISIONS_KEY = 'decisions:'
116const DECISIONS_TTL_MS = 14 * 86_400_000
117
118async function changeDecisions($: EngineInterface, fn: (prev: Decision[]) => Decision[]): Promise<void> {
119 await update($, decisionsAtom, prev => fn(prev ?? []))
120 try {
121 await $.store.set(DECISIONS_KEY + (await $.session.id()), { at: await $.clock.now(), list: await read($, decisionsAtom) })
122 } catch {
123 // best effort: the queue still works for this process
124 }
125}
126
127async function loadDecisions($: EngineInterface): Promise<void> {
128 try {
129 const now = await $.clock.now()
130 const saved = (await $.store.get(DECISIONS_KEY + (await $.session.id()))) as { at?: number; list?: Decision[] } | undefined
131 if (Array.isArray(saved?.list)) await update($, decisionsAtom, () => saved.list as Decision[])
132 for (const key of await $.store.keys()) {
133 if (!key.startsWith(DECISIONS_KEY)) continue
134 const v = (await $.store.get(key)) as { at?: number } | undefined
135 if (typeof v?.at !== 'number' || now - v.at > DECISIONS_TTL_MS) await $.store.delete(key)
136 }
137 } catch {
138 // no store, or an unreadable entry: start with an empty queue
139 }
140}
141
142async function isBusy($: EngineInterface): Promise<boolean> {
143 return (await read($, workingAtom)) || (await read($, jobsAtom)).some(j => j.status === 'running' && j.kind !== 'watch')
144}
145
146async function redraw($: EngineInterface): Promise<void> {
147 $.ui.invalidate('ui.render')
148}
149
150async function endJobs($: EngineInterface, ends: readonly TaskEnd[]): Promise<void> {
151 if (ends.length === 0) return
152 const now = await $.clock.now()
153 const done: Job[] = []
154 await update($, jobsAtom, prev => {
155 const list = (prev ?? []).map(job => {
156 const end = ends.find(x => x.id === job.id)
157 if (end === undefined || job.status !== 'running') return job
158 const next: Job = { ...job, status: end.status, endedAt: now }
159 if (end.exitCode !== undefined) next.exitCode = end.exitCode
160 done.push(next)
161 return next
162 })
163 return trimJobs(list)
164 })
165 // Only what you'd otherwise miss gets a toast; a completed job is a dim band row.
166 for (const job of done) if (job.status !== 'completed') $.ui.toast(`${job.label}: ${progressText(job, now)}`)
167 await redraw($)
168}
169
170async function addJob($: EngineInterface, job: Job): Promise<void> {
171 await update($, jobsAtom, prev => trimJobs([...(prev ?? []).filter(j => j.id !== job.id), job]))
172 await redraw($)
173}
174
175// One pass over running jobs: tail logs that grew, ask the engine about agents.
176async function poll($: EngineInterface): Promise<void> {
177 const jobs = await read($, jobsAtom)
178 const running = jobs.filter(j => j.status === 'running')
179 // Still refresh when idle, so a finished job leaves the band once it is no longer recent.
180 if (running.length === 0) return redraw($)
181 const now = await $.clock.now()
182 const changes = new Map<string, Partial<Job>>()
183
184 for (const job of running) {
185 if (job.file === undefined) continue
186 try {
187 const stat = await $.fs.stat(job.file)
188 if (stat.size === job.size) continue
189 const tail = await $.process.run(['tail', '-c', TAIL_BYTES, job.file], { timeoutMs: 3_000 })
190 const lines = visibleLines(tail.stdout)
191 const progress = parseProgress(lines)
192 const change: Partial<Job> = { size: stat.size, changedAt: now, history: pushHistory(job.history, now, progress?.pct) }
193 const last = lastLineOf(lines)
194 if (last !== undefined) change.lastLine = last
195 if (progress !== undefined) change.progress = progress
196 changes.set(job.id, change)
197 } catch {
198 // A log that vanished or cannot be read leaves the job as it was.
199 }
200 }
201
202 const ends: TaskEnd[] = []
203 if (running.some(j => j.kind === 'agent')) {
204 const agents = await $.agent.list()
205 for (const job of running.filter(j => j.kind === 'agent')) {
206 const agent = agents.find(a => a.id === job.id)
207 if (agent === undefined || agent.status === 'running') continue
208 ends.push({ id: job.id, status: agent.status === 'completed' ? 'completed' : agent.status === 'killed' ? 'killed' : 'failed' })
209 }
210 }
211
212 if (changes.size > 0) {
213 await update($, jobsAtom, prev => (prev ?? []).map(j => ({ ...j, ...(changes.get(j.id) ?? {}) })))
214 }
215 await endJobs($, ends)
216 await redraw($)
217}
218
219// --- narrative layer ---
220
221async function sessionFile($: EngineInterface, sessionId?: string): Promise<string | undefined> {
222 const home = await $.env.get('HOME')
223 if (home === undefined) return undefined
224 return `${home}${DATA_DIR}/${sessionId ?? (await $.session.id())}.json`
225}
226
227// Mirror the brief for other sessions' /brief all. Best effort: a failed write changes nothing here.
228async function writeSessionFile($: EngineInterface): Promise<void> {
229 try {
230 const path = await sessionFile($)
231 const brief = await read($, briefAtom)
232 if (path === undefined || brief === null) return
233 const cwd = await $.session.cwd()
234 const repo = await $.session.repo()
235 const base = (repo?.root ?? cwd).split('/').pop() ?? cwd
236 const record: SessionRecord = {
237 sessionId: await $.session.id(),
238 label: repo !== null && cwd !== repo.root ? `${base}/${cwd.split('/').pop()}` : base,
239 cwd,
240 status: brief.status,
241 asks: brief.asks,
242 isWorking: await isBusy($),
243 at: await $.clock.now(),
244 briefAt: brief.at,
245 decided: openDecisions(await read($, decisionsAtom)).map(d => ({ n: d.n, text: d.text })),
246 }
247 await $.fs.write(path, JSON.stringify(record))
248 } catch {
249 // ignore
250 }
251}
252
253async function readSessionFiles($: EngineInterface): Promise<SessionRecord[]> {
254 const home = await $.env.get('HOME')
255 if (home === undefined) return []
256 const dir = `${home}${DATA_DIR}`
257 if (!(await $.fs.exists(dir))) return []
258 const out: SessionRecord[] = []
259 for (const entry of await $.fs.list(dir)) {
260 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
261 try {
262 const r = JSON.parse(await $.fs.read(`${dir}/${entry.name}`)) as Partial<SessionRecord> | null
263 // Valid JSON of another shape (an older version, a stray file) is skipped like unparsable JSON.
264 if (r !== null && typeof r === 'object' && typeof r.sessionId === 'string' && typeof r.status === 'string' && typeof r.at === 'number' && Array.isArray(r.asks)) {
265 out.push(r as SessionRecord)
266 }
267 } catch {
268 // a file mid-write or not ours
269 }
270 }
271 return out
272}
273
274async function setBrief($: EngineInterface, brief: Brief): Promise<void> {
275 await update($, briefAtom, () => brief)
276 await redraw($)
277 await writeSessionFile($)
278}
279
280// TurnFallback: the turn ended without a brief call, so haiku reads the final answer.
281// Started without awaiting; a result that lands after the next turn began is dropped.
282async function fallback($: EngineInterface, turnId: string, answer: string): Promise<void> {
283 const prev = await read($, briefAtom)
284 const reply = await $.model.complete({ model: FALLBACK_MODEL, system: SUMMARIZE_SYSTEM, prompt: summarizePrompt(prev, answer), maxTokens: 600 })
285 if (!reply.isAnswered) {
286 $.ui.log(`brief: turn-end summary failed (${reply.reason})`)
287 return
288 }
289 const parsed = parseSummary(reply.text)
290 if (parsed === undefined) {
291 $.ui.log('brief: turn-end summary was not JSON')
292 return
293 }
294 if (currentTurnId !== turnId || briefTurnId === turnId) return
295 await setBrief($, nextBrief(await read($, briefAtom), parsed, 'fallback', await $.clock.now()))
296}
297
298async function reportText($: EngineInterface): Promise<string> {
299 const now = await $.clock.now()
300 const jobs = await read($, jobsAtom)
301 const running = jobs.filter(j => j.status === 'running')
302 const jobLines =
303 jobs.length === 0
304 ? []
305 : ['### Background jobs', ...jobs.map(j => `- ${j.status === 'running' ? 'running' : j.status} ${j.label} ${duration((j.endedAt ?? now) - j.startedAt)} ${progressText(j, now)}`)]
306 const ledger = ledgerMarkdown(
307 await read($, entriesAtom),
308 await read($, ctxAtom),
309 await read($, failsAtom),
310 await read($, turnsAtom),
311 await read($, limitsAtom),
312 )
313 const note = running.length > 0 ? `\n\n${running.length} background job(s) still running.` : ''
314 return [briefMarkdown(await read($, briefAtom), now, await read($, decisionsAtom)), ledger, ...jobLines].join('\n\n') + note
315}
316
317async function briefCommand($: EngineInterface, args: string): Promise<{ text: string }> {
318 const [verb, ...rest] = args.trim().split(/\s+/).filter(Boolean)
319 const now = await $.clock.now()
320
321 if (verb !== undefined && (BAND_MODES as readonly string[]).includes(verb)) {
322 bandMode = verb as BandMode
323 await $.store.set('bandMode', bandMode)
324 await redraw($)
325 return { text: bandMode === 'off' ? 'Brief band off (it stays off in new sessions). /brief full or /brief min turns it back on; /brief still opens the pane.' : `Brief band: ${bandMode}.` }
326 }
327 if (verb === 'ok') {
328 const which = rest[0] === undefined ? undefined : Number(rest[0])
329 let count = 0
330 await changeDecisions($, prev => prev.map(d => (d.status === 'open' && (which === undefined || d.n === which) ? (count++, { ...d, status: 'accepted' as const }) : d)))
331 await redraw($)
332 return { text: count === 0 ? (which === undefined ? 'No open decisions.' : `No open decision ${which}.`) : `Accepted ${count} decision(s).` }
333 }
334 if (verb === 'change') {
335 const which = Number(rest[0])
336 const answer = rest.slice(1).join(' ')
337 const d = openDecisions(await read($, decisionsAtom)).find(x => x.n === which)
338 if (d === undefined || answer === '') return { text: 'Usage: /brief change <N> <what to do instead> (N from the decided list)' }
339 await changeDecisions($, prev => prev.map(x => (x.n === which ? { ...x, status: 'changed' as const, change: answer } : x)))
340 await redraw($)
341 // A user turn of its own once the session is idle. Submitting from inside this command would wait on the
342 // turn the command holds (the engine refuses it), so it goes out from a timer just after.
343 const text = `Change to decision ${which} (you chose: ${d.text}): ${answer}`
344 $.clock.after(50, () => {
345 void $.prompt.submit({ text }).catch(err => $.ui.log(`brief: could not send the change (${String(err)})`))
346 })
347 return { text: `Sent to Claude: decision ${which} → ${answer}` }
348 }
349 if (verb === 'all') {
350 await $.ui.open({ id: ALL_PANE, title: 'All sessions' })
351 return { text: allViewText(await readSessionFiles($), await $.session.id(), now) }
352 }
353 if (verb === 'reset') {
354 // A new baseline for a new task in a long session; context figures are the session's and stay.
355 await update($, entriesAtom, () => [])
356 await update($, failsAtom, () => 0)
357 await update($, briefAtom, () => null)
358 await changeDecisions($, () => [])
359 // A turn-end summary still running must not bring the old brief back.
360 briefTurnId = currentTurnId
361 await redraw($)
362 return { text: 'Brief reset: status, asks, changes, outward actions and failures start from zero.' }
363 }
364 if (verb === 'watch') {
365 const [file, ...label] = rest
366 if (file === undefined) return { text: 'Usage: /brief watch <file> [label]' }
367 if (!(await $.fs.exists(file))) return { text: `No such file: ${file}` }
368 await addJob($, {
369 id: `watch:${file}`,
370 kind: 'watch',
371 label: shortLabel(label.length > 0 ? label.join(' ') : (file.split('/').pop() ?? file)),
372 file,
373 startedAt: now,
374 status: 'running',
375 history: [],
376 })
377 void poll($)
378 return { text: `Watching ${file}.` }
379 }
380 if (verb === 'unwatch') {
381 const key = rest.join(' ')
382 await update($, jobsAtom, prev => (prev ?? []).filter(j => !(j.kind === 'watch' && (j.label === key || j.file === key))))
383 await redraw($)
384 return { text: `Stopped watching ${key}.` }
385 }
386 if (verb === 'clear') {
387 await update($, jobsAtom, prev => (prev ?? []).filter(j => j.status === 'running'))
388 await redraw($)
389 return { text: 'Cleared finished jobs.' }
390 }
391
392 // /brief with no argument toggles the pane.
393 if (paneOpen) {
394 await $.ui.close({ id: PANE })
395 paneOpen = false
396 return { text: 'Closed the brief pane.' }
397 }
398 await $.ui.open({ id: PANE, title: 'Brief' })
399 paneOpen = true
400 return { text: await reportText($) }
401}
402export const register: Register = on => {
403 on('session.start', async ($, e, next) => {
404 await $.command.register({
405 name: 'brief',
406 description: 'Toggle the brief pane (full|min|off: how much the band shows; all: every live session; reset: new baseline)',
407 argumentHint: '[ok [N] | change N <answer> | full | min | off | all | reset | watch <file> [label] | unwatch <label> | clear]',
408 immediate: true,
409 })
410 // Aliases for the commands of the two mods brief replaced. While those mods are still loaded the engine may let
411 // either answer (hook order decides); a refusal (a built-in name) leaves brief running without the alias.
412 for (const name of ['ledger', 'jobs']) {
413 await $.command
414 .register({ name, description: `Alias of /brief (was the ${name} mod)`, argumentHint: '[reset | watch <file> [label] | unwatch <label> | clear]', immediate: true })
415 .catch(() => undefined)
416 }
417 await $.tool.register({
418 name: 'brief',
419 description:
420 "Set the person's glance view of this session: a one-line status, the complete list of what blocks you (replaces the previous asks; [] means nothing blocks), and any new decisions you made for them so you could keep going (queued for their review).",
421 inputSchema: {
422 type: 'object',
423 properties: {
424 status: { type: 'string', description: 'Where the work stands, at most 80 characters' },
425 asks: {
426 type: 'array',
427 description: 'Only what blocks you (outward or destructive action, access, or a choice a wrong guess would waste most work on), most blocking first; [] when nothing',
428 items: {
429 type: 'object',
430 properties: {
431 text: { type: 'string', description: 'Starts with a verb and names the exact choice or action, your recommended option first marked (recommended); at most 120 characters' },
432 kind: { type: 'string', enum: ['decision', 'approval', 'action', 'access', 'review', 'info'] },
433 },
434 required: ['text', 'kind'],
435 },
436 },
437 decided: {
438 type: 'array',
439 description: 'New judgment calls you made for the person so you could keep going; each joins a review queue. [] when none',
440 items: {
441 type: 'object',
442 properties: {
443 text: { type: 'string', description: 'What you chose, at most 120 characters' },
444 alternatives: { type: 'string', description: 'What you did not choose' },
445 reversible: { type: 'boolean', description: 'false when it would be costly to undo' },
446 n: { type: 'number', description: 'To revise a queued decision instead of adding one: its number' },
447 },
448 required: ['text'],
449 },
450 },
451 settled: {
452 type: 'array',
453 description: 'Queued decisions to take off the queue: accepted when the person agreed in chat, withdrawn when no longer relevant',
454 items: {
455 type: 'object',
456 properties: { n: { type: 'number' }, as: { type: 'string', enum: ['accepted', 'withdrawn'] } },
457 required: ['n', 'as'],
458 },
459 },
460 },
461 required: ['status', 'asks'],
462 },
463 })
464 await $.tool.register({
465 name: 'report',
466 description:
467 "This session's facts (PRs, commits, tags, publishes, paid calls, outward writes, files edited, failures, background jobs, context use) and its open asks. Read it before writing the end-of-run Changed / Blocked on me / Found report.",
468 inputSchema: { type: 'object', properties: {} },
469 })
470 await loadDecisions($)
471 const saved = await $.store.get('bandMode')
472 if (typeof saved === 'string' && (BAND_MODES as readonly string[]).includes(saved)) bandMode = saved as BandMode
473 $.clock.every(POLL_MS, () => {
474 void poll($)
475 })
476 // Heartbeat for /brief all: a session that stops writing (crashed, closed) drops out after LIVE_MS.
477 $.clock.every(HEARTBEAT_MS, () => {
478 void writeSessionFile($)
479 })
480 return next(e)
481 })
482
483 // /clear and /resume end this conversation while the process lives on: forget its brief, and drop any
484 // turn-end summary still running, so nothing of it is shown or written under the next session's id.
485 on('session.end', async ($, e, next) => {
486 currentTurnId = undefined
487 briefTurnId = undefined
488 await update($, briefAtom, () => null)
489 // The queue stays in $.store for a resume; /clear ends the conversation, so it goes.
490 await update($, decisionsAtom, () => [])
491 if (e.reason === 'clear') await Promise.resolve($.store.delete(DECISIONS_KEY + e.sessionId)).catch(() => undefined)
492 const path = await sessionFile($, e.sessionId)
493 if (path !== undefined) await $.process.run(['rm', '-f', path], { timeoutMs: 3_000 }).catch(() => undefined)
494 return next(e)
495 })
496
497 // The pane closed by its × or Esc, or by an unload: the next /brief opens it again.
498 on('ui.close', async ($, e, next) => {
499 if (e.id === PANE) paneOpen = false
500 return next(e)
501 }).catch(($, e, next) => next(e))
502
503 on('turn.start', async ($, e, next) => {
504 currentTurnId = e.turnId
505 await update($, workingAtom, () => true)
506 await redraw($)
507 return next(e)
508 })
509
510 on('turn.complete', async ($, e, next) => {
511 const done = await next(e)
512 // Subagent turns fire this too; only the main loop's window and brief are the ones on screen.
513 if (e.agentId !== undefined) return done
514 await update($, workingAtom, () => false)
515 lastSampleAt = await $.clock.now()
516 await sampleContext($)
517 await sampleTurn($)
518 await writeSessionFile($)
519 // The fallback runs when the turn did not END on a brief call (one earlier in the turn does not count).
520 if (briefTurnId !== e.turnId && !e.isAborted && e.answer.trim() !== '') {
521 void fallback($, e.turnId, e.answer).catch(err => $.ui.log(`brief: turn-end summary failed (${String(err)})`))
522 }
523 await redraw($)
524 return done
525 })
526
527 on('tool.call', async ($, e, next) => {
528 if (e.tool === BRIEF_TOOL) {
529 // The glance view is the main session's; a subagent's status would overwrite it.
530 if (e.agentId !== undefined) return { result: 'Not recorded: only the main session sets the brief.' }
531 const checked = validateInput(e)
532 if (!checked.ok) return { isError: true, result: checked.error, text: checked.error }
533 briefTurnId = currentTurnId
534 const now = await $.clock.now()
535 if (checked.value.settled.length > 0 || checked.value.decided.length > 0) {
536 await changeDecisions($, prev => appendDecisions(settleDecisions(prev, checked.value.settled), checked.value.decided, now))
537 }
538 await setBrief($, nextBrief(await read($, briefAtom), checked.value, 'tool', now))
539 return { result: queueText(await read($, decisionsAtom)) }
540 }
541 if (e.tool === REPORT_TOOL) return { result: await reportText($) }
542 // Work after a brief call makes that brief possibly stale: the fallback summarizes the turn's end unless
543 // the brief tool is called again.
544 if (e.agentId === undefined) briefTurnId = undefined
545
546 const ran = await next(e)
547 if (ran.deny !== undefined) return ran
548 const now = await $.clock.now()
549
550 if (e.tool === 'Bash') {
551 if (ran.isError === true) await update($, failsAtom, n => (n ?? 0) + 1)
552 const found = classifyBash(e.command, ran.text ?? '', now, ran.isError === true)
553 if (found.length > 0) await update($, entriesAtom, prev => appendEntries(prev, found))
554 // Only a call that asked for the background: a foreground command whose output merely quotes the
555 // engine's notice (cat of a test fixture, a log) is not a job.
556 const started = ran.isError === true || e.run_in_background !== true ? undefined : parseBackgroundStart(ran.text ?? '')
557 if (started !== undefined) {
558 const job: Job = { id: started.id, kind: 'shell', label: shortLabel(e.description ?? e.command), startedAt: now, status: 'running', history: [] }
559 if (started.file !== undefined) job.file = started.file
560 await addJob($, job)
561 }
562 } else if (ran.isError !== true) {
563 if (e.tool === 'Agent') {
564 const result = ran.result as { status?: string; agentId?: string; description?: string } | undefined
565 if (result?.status === 'async_launched' && result.agentId !== undefined) {
566 await addJob($, { id: result.agentId, kind: 'agent', label: shortLabel(`agent: ${result.description ?? 'background'}`), startedAt: now, status: 'running', history: [] })
567 }
568 } else if (e.tool === 'TaskStop') {
569 const id = e.task_id ?? e.shell_id
570 if (id !== undefined) await endJobs($, [{ id, status: 'killed' }])
571 }
572 const filePath = FILE_TOOLS.has(String(e.tool)) ? (e as { file_path?: unknown }).file_path : undefined
573 const found =
574 typeof filePath === 'string' ? { kind: 'file' as const, label: filePath, at: now } : classifyMcp(String(e.tool), now)
575 if (found !== undefined) await update($, entriesAtom, prev => appendEntries(prev, [found]))
576 }
577
578 if (now - lastSampleAt >= SAMPLE_EVERY_MS) {
579 lastSampleAt = now
580 await sampleContext($)
581 }
582 return ran
583 }).catch(($, e, next) => next(e))
584
585 // Completion: the engine appends a <task-notification> row (and may deliver one) when a task ends.
586 on('session.append', async ($, e, next) => {
587 const text = JSON.stringify(e.message.content ?? '')
588 if (text.includes('task-notification')) await endJobs($, parseTaskEnds(text.replace(/\\n/g, '\n')))
589 return next(e)
590 }).catch(($, e, next) => next(e))
591
592 on('session.receive', async ($, e, next) => {
593 if (e.text.includes('task-notification')) await endJobs($, parseTaskEnds(e.text))
594 return next(e)
595 }).catch(($, e, next) => next(e))
596
597 // Failed job rows stay until the person's next prompt; asks written before it turn dim.
598 on('prompt.submit', async ($, e, next) => {
599 const now = await $.clock.now()
600 await update($, lastPromptAtom, () => now)
601 return next(e)
602 }).catch(($, e, next) => next(e))
603
604 on('prompt.compose', async ($, e, next) => {
605 const composed = await next(e)
606 const sections = [...composed.sections]
607 if (e.tools.includes(BRIEF_TOOL)) sections.push({ id: 'brief:rule', text: BRIEF_RULE, scope: 'session' })
608 if (e.tools.includes('Bash')) sections.push({ id: 'brief:progress', text: JOBS_RULE, scope: 'session' })
609 return { sections }
610 })
611
612 // /ledger and /jobs are the old mods' commands, kept as aliases of /brief.
613 for (const command of ['brief', 'ledger', 'jobs'] as const) {
614 on('command.run', { command }, async ($, e) => briefCommand($, e.args)).catch(() => ({
615 text: 'brief: the command failed; see the debug log.',
616 }))
617 }
618
619 // The band: glance rows (status, asks), then the ledger line, then job rows, then whatever is beneath.
620 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
621 const below = await next(e)
622 if (e.props.hasSurvey || bandMode === 'off') return below
623 const now = await $.clock.now()
624 const columns = Math.max(20, e.props.bodyColumns - 1)
625 const glance = glanceRows(await read($, briefAtom), await isBusy($), now, columns, await read($, lastPromptAtom), await read($, decisionsAtom))
626 const minimal = bandMode === 'min'
627 const summary = minimal ? '' : summaryLine(await read($, entriesAtom))
628 const view = ctxView(await read($, ctxAtom), await read($, turnsAtom), await read($, limitsAtom))
629 const jobRows = bandRows(await read($, jobsAtom), now, columns, await read($, lastPromptAtom)).filter(r => !minimal || r.tone === 'failed')
630 const ctx = minimal && view?.level !== 'hot' ? undefined : view?.text
631 if (glance.length === 0 && ctx === undefined && summary === '' && jobRows.length === 0) return below
632 const ctxColor = view?.level === 'hot' ? 'red' : view?.level === 'warn' ? 'yellow' : undefined
633 const status = glance.filter(r => r.tone === 'status' || r.tone === 'age')
634 const asks = glance.find(r => r.tone === 'ask' || r.tone === 'ask-seen')
635 const decided = glance.find(r => r.tone === 'decided')
636 const { Box, Text } = $.ui.resolve(e)
637 return (
638 <Box flexDirection="column">
639 {status.length === 0 ? null : (
640 <Box flexDirection="row">
641 {status.map(r => (r.tone === 'age' ? <Text key={r.key} dimColor>{r.text}</Text> : <Text key={r.key} wrap="truncate-end">{r.text}</Text>))}
642 </Box>
643 )}
644 {asks === undefined ? null : asks.tone === 'ask' ? (
645 <Text key="asks" color="yellow" wrap="truncate-end">{asks.text}</Text>
646 ) : (
647 <Text key="asks" dimColor wrap="truncate-end">{asks.text}</Text>
648 )}
649 {decided === undefined ? null : <Text key="decided" dimColor wrap="truncate-end">{decided.text}</Text>}
650 {ctx === undefined && summary === '' ? null : (
651 <Box flexDirection="row">
652 {ctx === undefined ? null : ctxColor === undefined ? <Text dimColor>{ctx}</Text> : <Text color={ctxColor}>{ctx}</Text>}
653 {summary === '' ? null : <Text dimColor>{`${ctx === undefined ? '' : ' '}${summary}`}</Text>}
654 </Box>
655 )}
656 {jobRows.map(row =>
657 row.tone === 'failed' ? (
658 <Text key={row.key} color="red">{row.text}</Text>
659 ) : (
660 <Text key={row.key} dimColor={row.tone === 'done'}>{row.text}</Text>
661 ),
662 )}
663 {below}
664 </Box>
665 )
666 })
667
668 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
669 const { Box, Text, Button } = $.ui.resolve(e)
670 const now = await $.clock.now()
671 const width = Math.max(20, e.props.bodyColumns - 2)
672 const brief = await read($, briefAtom)
673 const jobs = await read($, jobsAtom)
674 const ordered = [...jobs.filter(j => j.status === 'running'), ...jobs.filter(j => j.status !== 'running').reverse()]
675 const view = ctxView(await read($, ctxAtom), await read($, turnsAtom), await read($, limitsAtom))
676 const summary = summaryLine(await read($, entriesAtom))
677
678 return (
679 <Box flexDirection="column">
680 <Text bold wrap="wrap">{brief === null ? 'No brief yet.' : brief.status}</Text>
681 {brief === null ? null : <Text dimColor>{`${brief.source === 'tool' ? 'from Claude' : 'summarized at turn end'} · ${duration(now - brief.at)} ago`}</Text>}
682 <Box flexDirection="column" marginTop={1}>
683 <Text bold>Needs you</Text>
684 {brief === null || brief.asks.length === 0 ? (
685 <Text dimColor>nothing</Text>
686 ) : (
687 brief.asks.map((a, i) => <Text key={`ask:${i}`} wrap="wrap">{`- ${a.text} (${a.kind}, ${duration(now - a.since)})`}</Text>)
688 )}
689 </Box>
690 <Box flexDirection="column" marginTop={1}>
691 <Text bold>Facts</Text>
692 {view === undefined ? null : <Text dimColor>{view.text}</Text>}
693 <Text dimColor wrap="wrap">{summary === '' ? 'Nothing changed or sent out yet.' : summary}</Text>
694 </Box>
695 <Box flexDirection="column" marginTop={1}>
696 <Text bold>Background jobs</Text>
697 {ordered.length === 0 ? <Text dimColor>none</Text> : null}
698 {ordered.map(job => (
699 <Box key={job.id} flexDirection="column">
700 <Box flexDirection="row">
701 <Text bold={job.status === 'running'} dimColor={job.status !== 'running'} color={job.status === 'failed' ? 'red' : undefined} wrap="truncate-end">
702 {`${job.label} ${duration((job.endedAt ?? now) - job.startedAt)} ${progressText(job, now)} `}
703 </Text>
704 {job.status === 'running' && job.kind !== 'watch' ? (
705 <Button key={`stop:${job.id}`} label="stop" onPress={async () => {
706 await $.tool.call({ tool: 'TaskStop', task_id: job.id })
707 await endJobs($, [{ id: job.id, status: 'killed' }])
708 }} />
709 ) : null}
710 {job.kind === 'watch' && job.status === 'running' ? (
711 <Button key={`unwatch:${job.id}`} label="unwatch" onPress={async () => {
712 await update($, jobsAtom, prev => (prev ?? []).filter(j => j.id !== job.id))
713 await redraw($)
714 }} />
715 ) : null}
716 </Box>
717 {job.lastLine === undefined ? null : <Text dimColor wrap="truncate-end">{job.lastLine.slice(0, width)}</Text>}
718 </Box>
719 ))}
720 </Box>
721 </Box>
722 )
723 })
724
725 on('ui.render', { component: 'Pane', requestId: ALL_PANE }, async ($, e) => {
726 const { Box, Text } = $.ui.resolve(e)
727 const text = allViewText(await readSessionFiles($), await $.session.id(), await $.clock.now())
728 return (
729 <Box flexDirection="column">
730 {text.split('\n').map((line, i) =>
731 line.startsWith(' needs you') ? (
732 <Text key={`l${i}`} color="yellow" wrap="truncate-end">{line}</Text>
733 ) : (
734 <Text key={`l${i}`} wrap="truncate-end">{line}</Text>
735 ),
736 )}
737 </Box>
738 )
739 })
740}
741hooks/brief.ts 268 lines1// The narrative layer: the model's own status and what it waits on the person for.
2// Pure functions only, so the replay eval (evals/replay) runs the same prompt and parser.
3import type { Ask, AskKind, Brief, Decision } from '../types'
4
5export const MAX_STATUS = 80
6export const MAX_ASKS = 5
7export const MAX_ASK = 120
8export const ASK_KINDS: readonly AskKind[] = ['decision', 'approval', 'action', 'access', 'review', 'info']
9/** A status older than this, while idle, shows its age. */
10export const STALE_MS = 120_000
11/** Other sessions' files older than this are not live (each live session rewrites its file every minute). */
12export const LIVE_MS = 5 * 60_000
13
14/** How much the band shows. */
15export type BandMode = 'full' | 'min' | 'off'
16export const BAND_MODES: readonly BandMode[] = ['full', 'min', 'off']
17
18export type AskInput = { text: string; kind: AskKind }
19/** `n` revises that open decision in place instead of queueing a new one. */
20export type DecisionInput = { text: string; alternatives?: string; reversible: boolean; n?: number }
21/** The model settling a queued decision: the person agreed in chat, or it no longer applies. */
22export type SettleInput = { n: number; as: 'accepted' | 'withdrawn' }
23export type BriefInput = { status: string; asks: AskInput[]; decided: DecisionInput[]; settled: SettleInput[] }
24/** Open decisions kept; past that the oldest open ones fold into accepted. */
25export const MAX_OPEN_DECISIONS = 12
26
27const clean = (s: string): string => s.replace(/\s+/g, ' ').trim()
28const norm = (s: string): string => clean(s).toLowerCase().replace(/[^a-z0-9 ]/g, '')
29
30/** A list field, also when a client sent it JSON-encoded (a tool schema older than the field does that). */
31const listOf = (v: unknown): unknown => {
32 if (typeof v !== 'string') return v ?? []
33 try {
34 return JSON.parse(v)
35 } catch {
36 return v
37 }
38}
39
40/** The brief tool's input, checked. Errors name the limit so the model can retry. */
41export const validateInput = (input: unknown): { ok: true; value: BriefInput } | { ok: false; error: string } => {
42 const o = (input ?? {}) as { status?: unknown; asks?: unknown; decided?: unknown; settled?: unknown }
43 if (typeof o.status !== 'string' || clean(o.status) === '') return { ok: false, error: 'status is required: one line on where the work stands' }
44 const status = clean(o.status)
45 if (status.length > MAX_STATUS) return { ok: false, error: `status is ${status.length} chars; keep it to ${MAX_STATUS}` }
46 const raw = listOf(o.asks)
47 if (!Array.isArray(raw)) return { ok: false, error: 'asks must be a list (empty when you need nothing)' }
48 if (raw.length > MAX_ASKS) return { ok: false, error: `${raw.length} asks; list at most ${MAX_ASKS}, the most blocking first` }
49 const asks: AskInput[] = []
50 for (const a of raw) {
51 const item = (typeof a === 'string' ? { text: a } : (a ?? {})) as { text?: unknown; kind?: unknown }
52 if (typeof item.text !== 'string' || clean(item.text) === '') return { ok: false, error: 'each ask needs text' }
53 const text = clean(item.text)
54 if (text.length > MAX_ASK) return { ok: false, error: `ask "${text.slice(0, 30)}…" is ${text.length} chars; keep each to ${MAX_ASK}` }
55 const kind = ASK_KINDS.includes(item.kind as AskKind) ? (item.kind as AskKind) : 'decision'
56 asks.push({ text, kind })
57 }
58 const rawDecided = listOf(o.decided)
59 if (!Array.isArray(rawDecided)) return { ok: false, error: 'decided must be a list (empty when you decided nothing new)' }
60 if (rawDecided.length > MAX_ASKS) return { ok: false, error: `${rawDecided.length} decisions in one call; send at most ${MAX_ASKS}` }
61 const decided: DecisionInput[] = []
62 for (const d of rawDecided) {
63 const item = (typeof d === 'string' ? { text: d } : (d ?? {})) as { text?: unknown; alternatives?: unknown; reversible?: unknown; n?: unknown }
64 if (typeof item.text !== 'string' || clean(item.text) === '') return { ok: false, error: 'each decision needs text: what you chose' }
65 const text = clean(item.text)
66 if (text.length > MAX_ASK) return { ok: false, error: `decision "${text.slice(0, 30)}…" is ${text.length} chars; keep each to ${MAX_ASK}` }
67 const alt = typeof item.alternatives === 'string' && clean(item.alternatives) !== '' ? fit(clean(item.alternatives), MAX_ASK) : undefined
68 const entry: DecisionInput = alt === undefined ? { text, reversible: item.reversible !== false } : { text, alternatives: alt, reversible: item.reversible !== false }
69 if (typeof item.n === 'number' && Number.isInteger(item.n) && item.n > 0) entry.n = item.n
70 decided.push(entry)
71 }
72 const rawSettled = listOf(o.settled)
73 if (!Array.isArray(rawSettled)) return { ok: false, error: 'settled must be a list of {n, as: accepted|withdrawn}' }
74 const settled: SettleInput[] = []
75 for (const x of rawSettled) {
76 const item = (x ?? {}) as { n?: unknown; as?: unknown }
77 if (typeof item.n !== 'number' || (item.as !== 'accepted' && item.as !== 'withdrawn')) return { ok: false, error: 'each settled entry is {n: <decision number>, as: "accepted" or "withdrawn"}' }
78 settled.push({ n: item.n, as: item.as })
79 }
80 return { ok: true, value: { status, asks, decided, settled } }
81}
82
83/** The whole list replaces the old one; an ask whose text is unchanged keeps its age. */
84export const mergeAsks = (prev: readonly Ask[] | undefined, next: readonly AskInput[], now: number): Ask[] =>
85 next.map(a => ({ ...a, since: (prev ?? []).find(p => norm(p.text) === norm(a.text))?.since ?? now }))
86
87/** New decisions join the queue (an open one with the same text is not repeated); numbers only grow. */
88export const appendDecisions = (prev: readonly Decision[] | undefined, add: readonly DecisionInput[], now: number): Decision[] => {
89 const list = [...(prev ?? [])]
90 let n = list.reduce((m, d) => Math.max(m, d.n), 0)
91 for (const d of add) {
92 const { n: target, ...fields } = d
93 const at = target === undefined ? -1 : list.findIndex(x => x.n === target && x.status === 'open')
94 if (at >= 0) {
95 // A revision: same number, new content, back to the top of the person's attention.
96 list[at] = { ...fields, n: target as number, at: now, status: 'open' }
97 continue
98 }
99 if (list.some(x => x.status === 'open' && norm(x.text) === norm(fields.text))) continue
100 list.push({ ...fields, n: ++n, at: now, status: 'open' })
101 }
102 const open = list.filter(d => d.status === 'open')
103 const overflow = new Set(open.slice(0, Math.max(0, open.length - MAX_OPEN_DECISIONS)).map(d => d.n))
104 return list.map(d => (overflow.has(d.n) ? { ...d, status: 'accepted' as const } : d)).slice(-40)
105}
106
107export const settleDecisions = (prev: readonly Decision[] | undefined, settled: readonly SettleInput[]): Decision[] =>
108 (prev ?? []).map(d => {
109 const s = settled.find(x => x.n === d.n)
110 return s === undefined || d.status !== 'open' ? d : { ...d, status: s.as }
111 })
112
113/** What the tool answers, so the model knows the numbers to revise or settle. */
114export const queueText = (list: readonly Decision[] | undefined): string => {
115 const open = openDecisions(list)
116 return open.length === 0 ? 'ok; no queued decisions' : `ok; queued for the person to review: ${open.map(d => `${d.n}. ${d.text}`).join('; ')}`
117}
118
119export const openDecisions = (list: readonly Decision[] | undefined): Decision[] => (list ?? []).filter(d => d.status === 'open')
120
121export const nextBrief = (prev: Brief | null | undefined, input: BriefInput, source: Brief['source'], now: number): Brief => ({
122 status: input.status,
123 asks: mergeAsks(prev?.asks, input.asks, now),
124 source,
125 at: now,
126})
127
128export const glyph = (brief: Brief | null | undefined, isWorking: boolean): string =>
129 isWorking ? '◐' : (brief?.asks.length ?? 0) > 0 ? '⚠' : '✓'
130
131const ago = (ms: number): string => (ms < 3_600_000 ? `${Math.max(1, Math.round(ms / 60_000))}m` : `${Math.round(ms / 3_600_000)}h`)
132
133const fit = (s: string, width: number): string => (s.length <= width ? s : `${s.slice(0, Math.max(1, width - 1))}…`)
134
135export type GlanceRow = { key: string; text: string; tone: 'status' | 'age' | 'ask' | 'ask-seen' | 'decided' }
136
137/**
138 * The two glance rows: status, then what the session needs from the person.
139 * Asks written before the person's last prompt draw dim ("ask-seen"): they may be answered.
140 */
141export const glanceRows = (
142 brief: Brief | null | undefined,
143 isWorking: boolean,
144 now: number,
145 columns: number,
146 lastPromptAt = 0,
147 decisions: readonly Decision[] = [],
148): GlanceRow[] => {
149 if (brief === null || brief === undefined) return []
150 const age = !isWorking && now - brief.at > STALE_MS ? ` ${ago(now - brief.at)} ago` : ''
151 const rows: GlanceRow[] = [{ key: 'status', text: fit(`${glyph(brief, isWorking)} ${brief.status}`, columns - age.length), tone: 'status' }]
152 if (age !== '') rows.push({ key: 'age', text: age, tone: 'age' })
153 if (brief.asks.length > 0) {
154 const head = `needs you (${brief.asks.length}): `
155 rows.push({
156 key: 'asks',
157 text: fit(head + brief.asks.map(a => a.text).join(' · '), columns),
158 tone: brief.at < lastPromptAt ? 'ask-seen' : 'ask',
159 })
160 }
161 const open = openDecisions(decisions)
162 if (open.length > 0) {
163 const head = `decided for you (${open.length}): `
164 const tail = ' /brief ok · /brief change N …'
165 rows.push({ key: 'decided', text: fit(head + open.map(d => `${d.n}. ${d.text}`).join(' · ') + tail, columns), tone: 'decided' })
166 }
167 return rows
168}
169
170export const decisionsMarkdown = (decisions: readonly Decision[] | undefined): string[] => {
171 const open = openDecisions(decisions)
172 if (open.length === 0) return []
173 return ['Decided for you (change if wrong; /brief ok [N] accepts, /brief change N <answer> sends Claude the change):', ...open.map(d => `${d.n}. ${d.text}${d.alternatives === undefined ? '' : ` (instead of: ${d.alternatives})`}${d.reversible ? '' : ' [costly to undo]'}`)]
174}
175
176export const briefMarkdown = (brief: Brief | null | undefined, now: number, decisions: readonly Decision[] = []): string => {
177 if (brief === null || brief === undefined) return ['### Status\nNo brief yet.', ...decisionsMarkdown(decisions)].join('\n')
178 const lines = [`### Status\n${brief.status} (${brief.source === 'tool' ? 'from Claude' : 'summarized at turn end'}, ${ago(now - brief.at)} ago)`]
179 lines.push('### Blocked on me')
180 if (brief.asks.length === 0) lines.push('nothing')
181 for (const a of brief.asks) lines.push(`- [${a.kind}] ${a.text} (open ${ago(now - a.since)})`)
182 lines.push(...decisionsMarkdown(decisions))
183 return lines.join('\n')
184}
185
186// --- TurnFallback: haiku summarizes a turn's final answer when the model skipped the tool ---
187
188export const FALLBACK_MAX_ANSWER = 6_000
189
190export const SUMMARIZE_SYSTEM = `You keep a glance view for a developer who runs many coding-agent sessions at once and checks each one for a few seconds. Read the agent's latest message and reply with JSON only:
191{"status": "<one line>", "asks": [{"text": "<one line>", "kind": "decision|approval|action|access|review|info"}]}
192
193status: under 70 characters (count them). Where the work stands right now, concretely: what is done, what is next, or what it is waiting on. No filler ("Working on the task"), no praise.
194
195asks: what the agent is waiting on the developer for before it goes on: a question it asked, a choice between options, approval for a risky or outward action (push, merge, deploy, delete, spend), a step it handed to the developer to do (merge a PR, sign in, change a setting and retry, run a command: kind action), a credential or access, a review it asked for.
196- How the message closes counts: when it ends by proposing the next step and waiting for a go ("Shall I go ahead?", "Say the word if you want me to commit it", "If you want, I can write the one-pager and start on #1–#3", "Want me to file those?"), that is an ask ("Say whether to commit the config change").
197- Skip asides: options mentioned in passing ("a cheaper option is…"), offers in the middle of the message, contingencies ("if X is missing, we could…"), and steps that wait on nobody (a later apply, a deploy that follows a merge).
198- One ask per decision: if one reply settles several items (run the script and approve its spend), write one ask.
199- Each at most ${MAX_ASK} characters, starting with a verb, naming the exact options or the exact thing, the agent's recommended option first ("Pick key name: timeoutMs (recommended) or requestTimeoutMs", "Approve force-push to feat/x").
200- A "Blocked on me: nothing" section, or a message that needs nothing, gives []. At most ${MAX_ASKS}, most blocking first.
201
202The new message is the source of truth. A previous glance view, if given, only helps you word things: keep a previous ask only if the new message still treats it as open. Drop every previous ask the new message does not raise again.`
203
204export const summarizePrompt = (prev: Brief | null | undefined, answer: string): string => {
205 const body = answer.length > FALLBACK_MAX_ANSWER ? `…${answer.slice(-FALLBACK_MAX_ANSWER)}` : answer
206 const before =
207 prev === null || prev === undefined
208 ? 'Previous glance view: none.'
209 : `Previous glance view: ${JSON.stringify({ status: prev.status, asks: prev.asks.map(a => ({ text: a.text, kind: a.kind })) })}`
210 return `${before}\n\nThe agent's latest message:\n<message>\n${body}\n</message>`
211}
212
213/** The reply's JSON, held to the tool's limits (clamped, not refused); undefined when there is none. */
214export const parseSummary = (reply: string): BriefInput | undefined => {
215 const start = reply.indexOf('{')
216 const end = reply.lastIndexOf('}')
217 if (start < 0 || end <= start) return undefined
218 let o: { status?: unknown; asks?: unknown }
219 try {
220 o = JSON.parse(reply.slice(start, end + 1))
221 } catch {
222 return undefined
223 }
224 if (typeof o.status !== 'string' || clean(o.status) === '') return undefined
225 const asks = (Array.isArray(o.asks) ? o.asks : [])
226 .map(a => (typeof a === 'string' ? { text: a } : (a ?? {})) as { text?: unknown; kind?: unknown })
227 .filter(a => typeof a.text === 'string' && clean(a.text) !== '')
228 .slice(0, MAX_ASKS)
229 .map(a => ({
230 text: fit(clean(a.text as string), MAX_ASK),
231 kind: ASK_KINDS.includes(a.kind as AskKind) ? (a.kind as AskKind) : ('decision' as AskKind),
232 }))
233 return { status: fit(clean(o.status), MAX_STATUS), asks, decided: [], settled: [] }
234}
235
236// --- SessionFile / AllView ---
237
238export type SessionRecord = {
239 sessionId: string
240 label: string
241 cwd: string
242 status: string
243 asks: { text: string; kind: AskKind; since: number }[]
244 isWorking: boolean
245 /** when the session last wrote this file (heartbeat) */
246 at: number
247 /** when the brief itself was written */
248 briefAt?: number
249 /** open queued decisions */
250 decided?: { n: number; text: string }[]
251}
252
253export const allViewRows = (records: readonly SessionRecord[], self: string, now: number): SessionRecord[] =>
254 records
255 .filter(r => now - r.at < LIVE_MS)
256 .sort((a, b) => b.asks.length - a.asks.length || (a.sessionId === self ? 1 : 0) - (b.sessionId === self ? 1 : 0) || b.at - a.at)
257
258export const allViewText = (records: readonly SessionRecord[], self: string, now: number): string => {
259 const rows = allViewRows(records, self, now)
260 if (rows.length === 0) return 'No live sessions with a brief.'
261 return rows
262 .map(r => {
263 const head = `${r.isWorking ? '◐' : r.asks.length > 0 ? '⚠' : '✓'} ${r.label}${r.sessionId === self ? ' (this)' : ''} ${r.status} · ${ago(now - (r.briefAt ?? r.at))} ago`
264 return [head, ...r.asks.map(a => ` needs you: ${a.text}`), ...(r.decided ?? []).map(d => ` decided: ${d.n}. ${d.text}`)].join('\n')
265 })
266 .join('\n')
267}
268hooks/ledger.ts 240 lines1// Pure helpers: classify tool calls into ledger entries and format the ledger. No `$`.
2import type { CtxLimits, CtxSample, LedgerEntry, LedgerKind, TurnSample } from '../types'
3
4export const MAX_ENTRIES = 300
5export const MAX_SAMPLES = 60
6export const CTX_WARN = 70
7export const CTX_HOT = 85
8
9const firstMatch = (re: RegExp, text: string): string | undefined => re.exec(text)?.[1]
10
11// One Bash command can do several things (`git commit … && git push`), so return a list.
12// Called on errored commands too: a chain can fail after its commit or charge already landed,
13// so anything outward is recorded only on evidence in the output (SHA, PR url, "Paid", "[new tag]").
14export const classifyBash = (command: string, output: string, at: number, isError = false): LedgerEntry[] => {
15 const out: LedgerEntry[] = []
16 const add = (kind: LedgerKind, label: string, detail?: string) =>
17 out.push(detail === undefined ? { kind, label, at } : { kind, label, at, detail })
18 // Each command of a chain, so a flag or token is read only from the command it belongs to.
19 const segments = command.split(/&&|\|\||;|\|/).map(seg => seg.trim())
20 const has = (re: RegExp) => segments.some(seg => re.test(seg))
21 const seg = (re: RegExp) => segments.find(x => re.test(x))
22
23 if (has(/^git\b.*\bcommit\b/)) {
24 for (const m of output.matchAll(/\[[^\]\s]+(?: \([^)]*\))? ([0-9a-f]{7,40})\]/g)) if (m[1]) add('commit', m[1])
25 }
26 if (has(/^gh pr create\b/)) {
27 const url = firstMatch(/(https:\/\/github\.com\/\S+\/pull\/\d+)/, output)
28 if (url !== undefined) add('pr', `PR #${url.split('/').pop()}`, url)
29 }
30
31 const push = seg(/^git push\b/)
32 if (push !== undefined) {
33 const tags = [...output.matchAll(/\* \[new tag\]\s+(\S+)/g)].map(m => m[1] ?? '')
34 for (const tag of tags) add('tag', tag)
35 const isForced = /\(forced update\)/.test(output) || /(--force\b|--force-with-lease\b|\s-f\b)/.test(push)
36 const branch = firstMatch(/(?:\* \[new branch\]|[0-9a-f]+\.\.\.?[0-9a-f]+)\s+\S+\s+->\s+(\S+)/, output)
37 if (branch !== undefined) add(isForced ? 'force-push' : 'push', branch)
38 }
39
40 // A payment CLI's own receipt line. Commands that only display text (grep, cat, echo…) can print one too.
41 const isDisplayOnly = has(/^(grep|rg|cat|bat|sed|awk|head|tail|less|echo|printf|jq)\b/)
42 if (!isDisplayOnly) for (const m of output.matchAll(/^Paid ([\d.]+ [A-Z][A-Z0-9]{1,9}) via \S+/gm)) add('paid', `${m[1]}${isError ? ' (call errored)' : ''}`)
43 // Live finding: inline scripts (heredocs, python -, node -e) carry these commands as text,
44 // so kinds with no output evidence are recorded only for real, successful commands.
45 if (isError || /<<|\bpython3? -|\bnode -e\b|\bcat >/.test(command)) return out
46 if (has(/^tempo request\b/)) add('paid', 'tempo request')
47
48 const merged = seg(/^gh pr merge\b/)
49 if (merged !== undefined && /merged pull request/i.test(output)) add('merge', `merge${firstMatch(/merge\s+#?(\d+)/, merged)?.replace(/^/, ' #') ?? ''}`)
50 const commented = /^gh (pr|issue) (comment|review)\b\s*#?(\d+)?/.exec(seg(/^gh (pr|issue) (comment|review)\b/) ?? '')
51 if (commented && /#(issuecomment|pullrequestreview)-\d+/.test(output)) add('comment', `gh ${commented[1]} ${commented[2]}${commented[3] ? ` #${commented[3]}` : ''}`)
52 const published = firstMatch(/^\+ (\S+@\d\S*)/m, output)
53 if (has(/^(npm|pnpm|yarn) publish\b/) && published !== undefined) add('publish', published)
54 const deploy = seg(/^(wrangler (deploy|publish)|gcloud functions deploy|render deploy|kubectl apply)\b/)
55 if (deploy !== undefined) add('deploy', deploy.split(/\s+/).slice(0, 3).join(' '))
56 const secret = seg(/^doppler secrets set\b.*(-c|--config)[ =]prd\b/)
57 if (secret !== undefined) add('prod-secret', `doppler prd: ${firstMatch(/\s([A-Z][A-Z0-9_]+)=/, secret) ?? 'secret'}`)
58 return out
59}
60
61const MCP_KINDS: ReadonlyArray<[RegExp, LedgerKind]> = [
62 [/^mcp__linear-server__(save_issue|save_comment|save_status_update|save_document|save_project)$/, 'linear'],
63 [/^mcp__plugin_slack_slack__slack_(send_message|schedule_message|update_canvas|create_canvas)$/, 'slack'],
64 [/^mcp__notion__notion-(create-pages|update-page|create-comment)$/, 'notion'],
65]
66
67export const classifyMcp = (tool: string, at: number): LedgerEntry | undefined => {
68 for (const [re, kind] of MCP_KINDS) {
69 if (re.test(tool)) return { kind, label: tool.replace(/^mcp__[^_]+(?:_[^_]+)*?__/, ''), at }
70 }
71 return undefined
72}
73
74export const FILE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
75
76export const appendEntries = (prev: readonly LedgerEntry[] | undefined, add: readonly LedgerEntry[]) => {
77 const next = [...(prev ?? [])]
78 for (const entry of add) {
79 if (entry.kind === 'file' && next.some(x => x.kind === 'file' && x.label === entry.label)) continue
80 next.push(entry)
81 }
82 return next.slice(-MAX_ENTRIES)
83}
84
85export const appendSample = (prev: readonly CtxSample[] | undefined, s: CtxSample) => {
86 const list = [...(prev ?? [])]
87 const last = list[list.length - 1]
88 // Keep one sample per whole-percent change; refresh cost on the last one otherwise.
89 if (last !== undefined && last.percent === s.percent) list[list.length - 1] = { ...last, usd: s.usd, at: s.at }
90 else list.push(s)
91 return list.slice(-MAX_SAMPLES)
92}
93
94const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
95
96export const countBy = (entries: readonly LedgerEntry[]) => {
97 const counts = new Map<LedgerKind, number>()
98 for (const e of entries) counts.set(e.kind, (counts.get(e.kind) ?? 0) + 1)
99 return counts
100}
101
102export const OUTWARD: ReadonlySet<LedgerKind> = new Set([
103 'merge', 'force-push', 'tag', 'publish', 'deploy', 'paid', 'comment', 'linear', 'slack', 'notion', 'prod-secret',
104])
105
106export const MAX_TURNS = 40
107// Turns-to-compact thresholds for the context figure's color.
108export const TURNS_WARN = 15
109export const TURNS_HOT = 5
110
111export const fmtTokens = (n: number): string =>
112 n >= 1_000_000 ? `${Number((n / 1_000_000).toFixed(1))}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
113
114export const appendTurn = (prev: readonly TurnSample[] | undefined, t: TurnSample) => [...(prev ?? []), t].slice(-MAX_TURNS)
115
116// Mean growth over the last 5 turns since the last compaction (a drop starts the series over).
117export const growthPerTurn = (turns: readonly TurnSample[]): number | undefined => {
118 let start = 0
119 for (let i = 1; i < turns.length; i++) if ((turns[i]?.tokens ?? 0) < (turns[i - 1]?.tokens ?? 0)) start = i
120 const since = turns.slice(start).slice(-6)
121 if (since.length < 2) return undefined
122 const first = since[0]
123 const last = since[since.length - 1]
124 if (first === undefined || last === undefined) return undefined
125 return Math.max(0, (last.tokens - first.tokens) / (since.length - 1))
126}
127
128export type CtxView = { text: string; level: 'ok' | 'warn' | 'hot' }
129
130export const ctxView = (
131 samples: readonly CtxSample[],
132 turns: readonly TurnSample[],
133 limits: CtxLimits | null,
134): CtxView | undefined => {
135 const last = samples[samples.length - 1]
136 if (last === undefined) return undefined
137 const window = limits?.window
138 const parts = [window ? `ctx ${fmtTokens(last.tokens)}/${fmtTokens(window)} ${last.percent}%` : `ctx ${last.percent}%`]
139 const growth = growthPerTurn(turns)
140 let level: CtxView['level'] = last.percent >= CTX_HOT ? 'hot' : last.percent >= CTX_WARN ? 'warn' : 'ok'
141 if (growth !== undefined && growth > 0) {
142 parts.push(`+${fmtTokens(Math.round(growth))}/turn`)
143 const limit = limits?.isAutoCompact && limits.threshold ? limits.threshold : window
144 if (limit !== undefined) {
145 const left = Math.max(0, Math.floor((limit - last.tokens) / growth))
146 parts.push(`~${left} turns to ${limits?.isAutoCompact && limits.threshold ? 'compact' : 'full'}`)
147 level = left <= TURNS_HOT ? 'hot' : left <= TURNS_WARN ? 'warn' : level
148 }
149 }
150 // Cost stays in /ledger: not worth a constant place in the band.
151 return { text: parts.join(' '), level }
152}
153
154const NOTABLE: Partial<Record<LedgerKind, (label: string) => string>> = {
155 pr: l => l,
156 merge: l => l,
157 tag: l => l,
158 'force-push': l => `force-push ${l}`,
159 publish: l => `published ${l}`,
160 deploy: () => 'deploy',
161 // paid: kept in /ledger only, too routine for the band
162 comment: l => l,
163 linear: () => 'linear write',
164 slack: () => 'slack message',
165 notion: () => 'notion write',
166 'prod-secret': l => l,
167}
168
169// The rest of the band: the latest outward actions by name, then files edited. Failures stay in /ledger.
170export const summaryLine = (entries: readonly LedgerEntry[]): string => {
171 const named = entries.flatMap(e => {
172 const f = NOTABLE[e.kind]
173 return f === undefined ? [] : [f(e.label)]
174 })
175 const parts: string[] = []
176 if (named.length > 0) parts.push(named.slice(-3).reverse().join(' · ') + (named.length > 3 ? ` +${named.length - 3}` : ''))
177 const files = countBy(entries).get('file') ?? 0
178 if (files > 0) parts.push(plural(files, 'file'))
179 return parts.join(' ')
180}
181
182const LABELS: Record<LedgerKind, string> = {
183 pr: 'PRs opened',
184 merge: 'PRs merged',
185 commit: 'Commits',
186 push: 'Pushes',
187 'force-push': 'Force pushes',
188 tag: 'Tags pushed',
189 publish: 'Packages published',
190 deploy: 'Deploys',
191 paid: 'Paid calls',
192 comment: 'GitHub comments/reviews',
193 linear: 'Linear writes',
194 slack: 'Slack writes',
195 notion: 'Notion writes',
196 'prod-secret': 'Prod secret writes',
197 file: 'Files edited',
198}
199
200const section = (title: string, kinds: readonly LedgerKind[], entries: readonly LedgerEntry[]) => {
201 const lines: string[] = []
202 for (const kind of kinds) {
203 const of = entries.filter(e => e.kind === kind)
204 if (of.length === 0) continue
205 const items = of.map(e => (e.detail ? `${e.label} (${e.detail})` : e.label))
206 lines.push(`- ${LABELS[kind]} (${of.length}): ${items.slice(-12).join(', ')}${of.length > 12 ? ', …' : ''}`)
207 }
208 return lines.length === 0 ? [] : [`### ${title}`, ...lines, '']
209}
210
211export const ledgerMarkdown = (
212 entries: readonly LedgerEntry[],
213 samples: readonly CtxSample[],
214 fails: number,
215 turns: readonly TurnSample[] = [],
216 limits: CtxLimits | null = null,
217) => {
218 const last = samples[samples.length - 1]
219 const peak = samples.reduce((m, s) => Math.max(m, s.percent), 0)
220 const ctx =
221 last === undefined
222 ? ['Context: no API response yet this window.']
223 : [
224 `Context: ${last.percent}% (${Math.round(last.tokens / 1000)}k tokens), peak ${peak}%` +
225 (last.usd === undefined ? '' : `, session cost $${last.usd.toFixed(2)}`),
226 `Outlook: ${ctxView(samples, turns, limits)?.text ?? 'n/a'}`,
227 ]
228 const body = [
229 ...section('Changed', ['pr', 'commit', 'push', 'file'], entries),
230 ...section('Outward-facing', ['merge', 'tag', 'force-push', 'publish', 'deploy', 'comment', 'linear', 'slack', 'notion', 'prod-secret', 'paid'], entries),
231 ]
232 return [
233 '## Run ledger',
234 ...ctx,
235 '',
236 ...(body.length === 0 ? ['Nothing changed or sent out yet.', ''] : body),
237 `Failed Bash calls: ${fails}`,
238 ].join('\n')
239}
240hooks/progress.ts 214 lines1// Pure helpers: parse progress out of a log tail, read the engine's background texts, format. No `$`.
2import type { Job, JobProgress, JobStatus } from '../types'
3
4export const MAX_FINISHED = 8
5// A finished job stays in the status line this long.
6export const RECENT_MS = 120_000
7// Output that has not grown for this long is called out as quiet (possible stall).
8export const QUIET_MS = 180_000
9
10// eslint-disable-next-line no-control-regex
11const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g
12
13// A progress bar redraws one line with \r: the visible line is the segment after the last \r.
14export const visibleLines = (tail: string): string[] =>
15 tail
16 .replace(ANSI, '')
17 .split('\n')
18 .map(line => (line.split('\r').filter(s => s.trim() !== '').pop() ?? '').trim())
19 .filter(line => line !== '')
20
21const UNIT: Record<string, number> = { b: 1, kb: 1e3, mb: 1e6, gb: 1e9, tb: 1e12, kib: 1024, mib: 1024 ** 2, gib: 1024 ** 3 }
22const SIZE = /([\d.]+)\s*(B|KB|MB|GB|TB|KiB|MiB|GiB)\s*(?:\/|of)\s*([\d.]+)\s*(B|KB|MB|GB|TB|KiB|MiB|GiB)\b/i
23const PERCENT = /(?<![\d.])(\d{1,3}(?:\.\d+)?)\s?%/
24const COUNT = /(?<![\d./:-])(\d+)\s*(?:\/|\bof\b)\s*(\d+)(?![\d./:-])/
25
26const clampPct = (n: number) => Math.max(0, Math.min(100, n))
27
28// Lines a job prints to report on itself: `::progress 12/40 uploading`, `::progress 64%`, `::status waiting on CI`.
29const MARK_PROGRESS = /^::progress\s+(\S+)\s*(.*)$/
30const MARK_STATUS = /^::status\s+(.+)$/
31export const isMarker = (line: string): boolean => line.startsWith('::progress ') || line.startsWith('::status ')
32
33const specPct = (spec: string): number | undefined => {
34 const pct = /^(\d{1,3}(?:\.\d+)?)%$/.exec(spec)
35 if (pct?.[1] !== undefined) return clampPct(Number(pct[1]))
36 const count = /^(\d+)\/(\d+)$/.exec(spec)
37 if (count && Number(count[2]) > 0) return clampPct((Number(count[1]) / Number(count[2])) * 100)
38 const size = /^([\d.]+)(B|KB|MB|GB|TB|KiB|MiB|GiB)\/([\d.]+)(B|KB|MB|GB|TB|KiB|MiB|GiB)$/i.exec(spec)
39 if (size) {
40 const done = Number(size[1]) * (UNIT[(size[2] ?? 'b').toLowerCase()] ?? 1)
41 const total = Number(size[3]) * (UNIT[(size[4] ?? 'b').toLowerCase()] ?? 1)
42 if (total > 0) return clampPct((done / total) * 100)
43 }
44 return undefined
45}
46
47// The job's own report wins over anything guessed: the newest ::progress, plus a newer ::status.
48const parseMarkers = (lines: readonly string[]): JobProgress | undefined => {
49 let progress: { at: number; spec: string; msg: string } | undefined
50 let status: { at: number; msg: string } | undefined
51 for (let i = lines.length - 1; i >= 0 && (progress === undefined || status === undefined); i--) {
52 const line = lines[i] ?? ''
53 const p = progress === undefined ? MARK_PROGRESS.exec(line) : null
54 if (p) progress = { at: i, spec: p[1] ?? '', msg: (p[2] ?? '').trim() }
55 const s = status === undefined ? MARK_STATUS.exec(line) : null
56 if (s) status = { at: i, msg: (s[1] ?? '').trim() }
57 }
58 if (progress === undefined && status === undefined) return undefined
59 const msg = status !== undefined && (progress === undefined || status.at > progress.at) ? status.msg : progress?.msg ?? ''
60 const pct = progress === undefined ? undefined : specPct(progress.spec)
61 const text = [progress?.spec, msg].filter(Boolean).join(' ')
62 return pct === undefined ? { text } : { pct, text }
63}
64
65// Newest line first; within a line an explicit % wins, then byte pairs, then N/M counts.
66export const parseProgress = (lines: readonly string[]): JobProgress | undefined => {
67 const marked = parseMarkers(lines)
68 if (marked !== undefined) return marked
69 for (let i = lines.length - 1; i >= Math.max(0, lines.length - 20); i--) {
70 const line = lines[i] ?? ''
71 const pct = PERCENT.exec(line)
72 if (pct?.[1] !== undefined && Number(pct[1]) <= 100) return { pct: clampPct(Number(pct[1])), text: `${Math.round(Number(pct[1]))}%` }
73 const size = SIZE.exec(line)
74 if (size) {
75 const done = Number(size[1]) * (UNIT[(size[2] ?? 'b').toLowerCase()] ?? 1)
76 const total = Number(size[3]) * (UNIT[(size[4] ?? 'b').toLowerCase()] ?? 1)
77 if (total > 0 && done <= total) return { pct: clampPct((done / total) * 100), text: `${size[1]} ${size[2]} / ${size[3]} ${size[4]}` }
78 }
79 const count = COUNT.exec(line)
80 if (count) {
81 const done = Number(count[1])
82 const total = Number(count[2])
83 if (total > 1 && done <= total) return { pct: clampPct((done / total) * 100), text: `${done}/${total}` }
84 }
85 }
86 return undefined
87}
88
89export const lastLineOf = (lines: readonly string[]): string | undefined => {
90 const line = lines.filter(l => !isMarker(l)).pop()
91 if (line === undefined) return undefined
92 return line.length > 160 ? `${line.slice(0, 159)}…` : line
93}
94
95// "Command running in background with ID: bpvctn69i. Output is being written to: /…/bpvctn69i.output."
96export const parseBackgroundStart = (text: string): { id: string; file?: string } | undefined => {
97 const id = /running in background with ID: ([\w-]+)/.exec(text)?.[1]
98 if (id === undefined) return undefined
99 const file = /Output is being written to: (\S+?\.output)\b/.exec(text)?.[1]
100 return file === undefined ? { id } : { id, file }
101}
102
103export type TaskEnd = { id: string; status: JobStatus; exitCode?: number }
104
105// <task-notification><task-id>…</task-id>…<status>completed</status><summary>… (exit code 0)</summary>
106export const parseTaskEnds = (text: string): TaskEnd[] => {
107 const ends: TaskEnd[] = []
108 for (const block of text.split('<task-notification>').slice(1)) {
109 const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(block)?.[1]
110 const raw = /<status>\s*([^<\s]+)\s*<\/status>/.exec(block)?.[1]
111 if (id === undefined || raw === undefined) continue
112 const code = /exit code (-?\d+)/.exec(block)?.[1]
113 const status: JobStatus =
114 raw === 'killed' || raw === 'stopped' ? 'killed' : raw === 'failed' || (code !== undefined && code !== '0') ? 'failed' : 'completed'
115 ends.push(code === undefined ? { id, status } : { id, status, exitCode: Number(code) })
116 }
117 return ends
118}
119
120export const duration = (ms: number): string => {
121 const s = Math.max(0, Math.round(ms / 1000))
122 if (s < 60) return `${s}s`
123 if (s < 600) return s % 60 === 0 ? `${s / 60}m` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
124 const m = Math.round(s / 60)
125 if (m < 60) return `${m}m`
126 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
127}
128
129// Rate over the last few minutes of samples; undefined until there is a measurable rise.
130export const eta = (history: Job['history'], now: number): string | undefined => {
131 const recent = history.filter(p => now - p.at <= 600_000)
132 const first = recent[0]
133 const last = recent[recent.length - 1]
134 if (first === undefined || last === undefined || last.pct <= first.pct || last.at <= first.at) return undefined
135 const perMs = (last.pct - first.pct) / (last.at - first.at)
136 return `~${duration((100 - last.pct) / perMs)}`
137}
138
139export const pushHistory = (history: Job['history'], at: number, pct: number | undefined): Job['history'] => {
140 if (pct === undefined) return history
141 const prev = history[history.length - 1]
142 if (prev !== undefined && pct < prev.pct) return [{ at, pct }] // restarted, or a new phase
143 if (prev !== undefined && pct === prev.pct) return history
144 return [...history, { at, pct }].slice(-30)
145}
146
147export const progressText = (job: Job, now: number): string => {
148 if (job.status !== 'running') {
149 const took = duration((job.endedAt ?? now) - job.startedAt)
150 const code = job.exitCode !== undefined && job.exitCode !== 0 ? ` (exit ${job.exitCode})` : ''
151 return `${job.status === 'completed' ? 'done' : job.status}${code} in ${took}`
152 }
153 const parts: string[] = []
154 if (job.progress) parts.push(job.progress.text)
155 const left = job.progress?.pct === undefined ? undefined : eta(job.history, now)
156 if (left !== undefined) parts.push(left)
157 if (job.changedAt !== undefined && now - job.changedAt >= QUIET_MS) parts.push(`quiet ${duration(now - job.changedAt)}`)
158 return parts.join(' ')
159}
160
161export const statusLine = (jobs: readonly Job[], now: number): string | undefined => {
162 const running = jobs.filter(j => j.status === 'running')
163 const shown = running.slice(0, 3).map(j => [j.label, progressText(j, now)].filter(Boolean).join(' '))
164 if (running.length > 3) shown.push(`+${running.length - 3}`)
165 if (shown.length === 0) {
166 const recent = jobs.filter(j => j.status !== 'running' && now - (j.endedAt ?? 0) <= RECENT_MS)
167 for (const j of recent.slice(-2)) shown.push(`${j.label} ${progressText(j, now)}`)
168 }
169 // The engine already prefixes a mod's status with its name.
170 return shown.length === 0 ? undefined : shown.join(' · ')
171}
172
173export const shortLabel = (text: string): string => {
174 const one = text.trim().split('\n')[0] ?? ''
175 return one.length > 28 ? `${one.slice(0, 27)}…` : one
176}
177
178// Keep every running job and the most recent finished ones.
179export const trimJobs = (jobs: readonly Job[]): Job[] => {
180 const finished = jobs.filter(j => j.status !== 'running')
181 const keep = new Set(finished.slice(-MAX_FINISHED))
182 return jobs.filter(j => j.status === 'running' || keep.has(j))
183}
184
185export type BandRow = { key: string; text: string; tone: 'running' | 'done' | 'failed' }
186
187const pad = (text: string, n: number) => (text.length >= n ? text.slice(0, n) : text + ' '.repeat(n - text.length))
188
189// One row per running job, then the recently finished; aligned columns, cut to the band's width.
190// A completed job shows for RECENT_MS; a failed or killed one stays until the person's next prompt.
191export const isShownFinished = (job: Job, now: number, lastPromptAt: number): boolean =>
192 job.status === 'completed'
193 ? now - (job.endedAt ?? 0) <= RECENT_MS
194 : job.status !== 'running' && ((job.endedAt ?? 0) > lastPromptAt || now - (job.endedAt ?? 0) <= RECENT_MS)
195
196export const bandRows = (jobs: readonly Job[], now: number, columns: number, lastPromptAt = 0, max = 4): BandRow[] => {
197 const running = jobs.filter(j => j.status === 'running')
198 const recent = jobs.filter(j => isShownFinished(j, now, lastPromptAt)).reverse()
199 const all = [...running, ...recent]
200 const rows: BandRow[] = all.slice(0, max).map(job => {
201 const word = job.status === 'running' ? 'run ' : job.status === 'completed' ? 'done' : job.status === 'failed' ? 'fail' : 'kill'
202 const elapsed = duration((job.endedAt ?? now) - job.startedAt)
203 const detail = job.status === 'running' ? progressText(job, now) : job.exitCode !== undefined && job.exitCode !== 0 ? `exit ${job.exitCode}` : ''
204 const text = `${word} ${pad(job.label, 28)} ${pad(elapsed, 6)} ${detail}`.trimEnd()
205 return {
206 key: job.id,
207 text: text.length > columns ? `${text.slice(0, Math.max(0, columns - 1))}…` : text,
208 tone: job.status === 'running' ? 'running' : job.status === 'completed' ? 'done' : 'failed',
209 }
210 })
211 if (all.length > max) rows.push({ key: 'more', text: ` +${all.length - max} more · /brief`, tone: 'done' })
212 return rows
213}
214types/index.d.ts 104 lines1// --- facts layer: outward actions and context (was run-ledger) ---
2
3export type LedgerKind =
4 | 'pr'
5 | 'merge'
6 | 'commit'
7 | 'push'
8 | 'force-push'
9 | 'tag'
10 | 'publish'
11 | 'deploy'
12 | 'paid'
13 | 'comment'
14 | 'linear'
15 | 'slack'
16 | 'notion'
17 | 'prod-secret'
18 | 'file'
19
20export type LedgerEntry = { kind: LedgerKind; label: string; at: number; detail?: string }
21
22export type CtxSample = { percent: number; tokens: number; usd?: number; at: number }
23
24/** Context tokens at the end of each main-loop turn, for growth per turn. */
25export type TurnSample = { tokens: number; at: number }
26
27/** The window and where auto-compaction fires, from the engine's local estimate. */
28export type CtxLimits = { window: number; threshold?: number; isAutoCompact: boolean }
29
30// --- facts layer: background jobs (was jobs) ---
31
32export type JobKind = 'shell' | 'agent' | 'watch'
33
34export type JobStatus = 'running' | 'completed' | 'failed' | 'killed'
35
36export type JobProgress = { pct?: number; text: string }
37
38export type Job = {
39 id: string
40 kind: JobKind
41 label: string
42 file?: string
43 startedAt: number
44 status: JobStatus
45 endedAt?: number
46 exitCode?: number
47 progress?: JobProgress
48 lastLine?: string
49 /** when the log last grew, for "quiet 4m" stall hints */
50 changedAt?: number
51 size?: number
52 /** (time, pct) points for the ETA */
53 history: { at: number; pct: number }[]
54}
55
56// --- narrative layer: what the model says it is doing and needs ---
57
58export type AskKind = 'decision' | 'approval' | 'action' | 'access' | 'review' | 'info'
59
60export type Ask = {
61 text: string
62 kind: AskKind
63 /** when this ask (same text) first appeared */
64 since: number
65}
66
67/** A judgment call the model made for the person so it could keep going, queued for review. */
68export type Decision = {
69 n: number
70 text: string
71 alternatives?: string
72 /** false: costly to undo, review soon */
73 reversible: boolean
74 at: number
75 status: 'open' | 'accepted' | 'changed' | 'withdrawn'
76 change?: string
77}
78
79export type Brief = {
80 status: string
81 asks: Ask[]
82 /** who wrote it: the model through the brief tool, or the turn-end fallback */
83 source: 'tool' | 'fallback'
84 at: number
85}
86
87declare module 'claude-code' {
88 interface PluginState {
89 brief: {
90 entries: LedgerEntry[]
91 ctx: CtxSample[]
92 fails: number
93 turns: TurnSample[]
94 limits: CtxLimits | null
95 jobs: Job[]
96 lastPromptAt: number
97 brief: Brief | null
98 decisions: Decision[]
99 /** whether a main-loop turn is running, for the band's glyph */
100 isWorking: boolean
101 }
102 }
103}
104