SLOPSHOPPER

brief

Dotfiles – (neo)vim, tmux and zsh configuration

newpanebandguardcommandtoast
v?no licenseupdated 2026-10-12rcliao/dotfiles/dot_claude/mods/brief
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · brief
│ ┃ Brief ✕ › fix the failing auth test and add an audit log call │ ┃ No brief yet. │ ┃ ● brief: brief: turn-end summary was not JSON │ ┃ Needs you ⏺ Read(src/auth.ts) │ ┃ nothing ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Facts ⎿ Added 2 lines, removed 1 line │ ┃ ctx 97k/200k 49% ⏺ Bash(bun test) │ ┃ 3 files ⎿ 3 pass, 1 fail │ ┃ │ ┃ Background jobs ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ none │ ✻ Worked for 42s · done 4:20 PM │ │ › /brief │ ⎿ brief: ### Status │ ⎿ brief: No brief yet. │ ⎿ brief: │ ⎿ brief: ## Run ledger │ ⎿ brief: Context: 49% (97k tokens), peak 49%, session cost $0.42 │ ⎿ brief: Outlook: ctx 97k/200k 49% │ │ ctx 97k/200k 49% 3 files ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx 97k/200k 49% 3 files ⟨Claude Code's own drawing⟩
Pane · Brief
No brief yet. Needs you nothing Facts ctx 97k/200k 49% 3 files Background jobs none
Pane · brief-all
No live sessions with a brief.
README

Eric's Dotfiles

Managed with chezmoi. Shell, terminals, editors, git, and the Claude Code setup I actually work in.

Set up a new machine

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:

PromptWhy it is per-machine
Full namegit author name
Git emailso a work laptop and a personal box can share this repo without cross-signing commits
Ghost hookssee 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.

Day to day

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.

What is in here

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.

Ghost memory hooks (opt-in)

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.

aerospace

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

What is deliberately not tracked

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.

Source 5 files
hooks/register.tsx 741 lines
1import { 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}
741
hooks/brief.ts 268 lines
1// 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}
268
hooks/ledger.ts 240 lines
1// 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}
240
hooks/progress.ts 214 lines
1// 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}
214
types/index.d.ts 104 lines
1// --- 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