SLOPSHOPPER

Code Buddy

Holds risky tool calls (production domains, live Stripe keys, protected-branch pushes, deploys, destructive commands) for your OK, replays each turn's edits…

newpanebandguardcommandtoast
v1.1.0Apache-2.0updated 2026-10-10RuahTechSolutions/code-buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · code-buddy
│ ┃ code-buddy-replay ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ▶ REPLAY step 1 of 3 · 3 edits in 3 … │ code-buddy │ │ ┃ ● · · ⏺ Read(src/auth.ts) │ Code Buddy: refused (refused: no answer in │ │ ┃ src/auth.ts ⎿ Read 6 lines │ 10 min) │ │ ┃ Edit +2 −1 ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ 0 --- a/src/auth.ts ⎿ Added 2 lines, removed 1 line │ ┃ 0 +++ b/src/auth.ts ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ 2 ⎿ Denied by code-buddy: Code Buddy held this call and did n │ ┃ 3 export async function refresh(token: │ ┃ 4 const claims = await verify(token) ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 5 - if (!claims) throw new Error('inval │ ┃ 5 + if (!claims || claims.exp < Date.no ✻ Worked for 42s · done 4:20 PM │ ┃ 6 + await audit('refresh', claims.sub) │ ┃ 7 return issue(claims.sub) › /code-buddy │ ┃ 8 } ⎿ code-buddy: Code Buddy · guard on · 1 held this session │ ┃ ⎿ code-buddy: Production: (none) │ ┃ [ ◀ Prev ] [ Next ▶ ] [ Close ] ⎿ code-buddy: Staging: (none) (production wins on overlap) │ ⎿ code-buddy: Protected: main │ ⎿ code-buddy: Subagents: budget off · auto-open on │ ⎿ code-buddy: │ │ ≋ Code Buddy ▶ Replay ready · 3 edits in 3 files [ Replay ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
≋ Code Buddy ▶ Replay ready · 3 edits in 3 files [ Replay ]
Pane · code-buddy-replay
▶ REPLAY step 1 of 3 · 3 edits in 3 files ● · · src/auth.ts Edit +2 −1 0 --- a/src/auth.ts 0 +++ b/src/auth.ts 2 3 export async function refresh(token: string) { 4 const claims = await verify(token) 5 - if (!claims) throw new Error('invalid token') 5 + if (!claims || claims.exp < Date.now() / 1000) thro 6 + await audit('refresh', claims.sub) 7 return issue(claims.sub) 8 } [ ◀ Prev ] [ Next ▶ ] [ Close ]
Pane · code-buddy-agents
≋ AGENTS 0 of 0 finished [ Compact ] ░░░░░░░░░░░░░░░░ cost ≈$0.00 · tokens 0 · time 0:00 No subagents yet this session. Each one shows here as soon as it starts.
Pane · code-buddy-hold
Nothing is held.
README

Code Buddy

A Claude Code mod that keeps an eye on what Claude does for you. One plugin, four features:

  1. Staging guard. Holds a tool call before it runs when it targets one of your production domains, carries a live Stripe key, pushes or merges into your protected branch, or deploys (vercel --prod, terraform apply, cdk deploy, kubectl apply, docker push).
  2. Blast radius. Holds destructive shell commands (rm -r/-f, git reset --hard, git checkout -- ., git restore ., git clean, force pushes, database migrations) and shows what they would delete or change before you decide.
  3. Replay. Records every file edit in a turn without changing it. After the turn, step through the diffs with Prev, Next and Close.
  4. Agents panel. Tracks every subagent: description, model, status, elapsed time, tokens and estimated cost, with a progress bar of finished out of total. Each running subagent shows what it is doing right now (Read · src/api/routes.ts, step and tool counts). The panel opens by itself when a second subagent starts in a turn, has a compact one-line-per-subagent view, and can warn you when subagents pass a cost budget.

It draws in the terminal and in the Claude desktop app's Code tab, in light and dark mode.

<img alt="Code Buddy's hold pane, replay stepper, agents panel and status band" src="docs/preview-light.png" width="720">

Install

In a Claude Code terminal session:

/plugin install code-buddy --marketplace RuahTechSolutions/code-buddy

Answer y to add the marketplace, pick a scope (user scope loads it everywhere, including the desktop app's Code tab), and fill in the settings screen. You can change any setting later with /code-buddy config.

Settings

SettingWhat it doesDefault
Production domainsComma-separated hosts. *.example.com matches every subdomain. Shell commands, web requests and browser calls that target one are held.empty (rule off)
Staging domainsHosts that are let through. Production wins on overlap: only an exact host listed here (no wildcard) is let through.empty
Protected branchPushes to it and merges into it are held. Comma-separated; * matches within a name.main
GuardTurns holds on or off. Replay and the agents panel keep working either way.on
Subagent budgetEstimated subagent spend per session, in USD. Past it you get one toast, and the agents bar, the panel's budget tile and the idle band turn coral. 0 or off turns it off.off
Auto-open agents panelOpens the agents panel by itself when a second subagent starts in a turn, once per turn, so closing it sticks.on
/code-buddy config production example.com, *.example.com
/code-buddy config staging staging.example.com
/code-buddy config branch main, release/*
/code-buddy config guard off
/code-buddy config budget 2.50
/code-buddy config autoopen off

Commands

CommandWhat it does
/code-buddyStatus, settings, and this session's held calls with their outcomes.
/code-buddy replayOpens the replay of the last turn's edits.
/code-buddy agentsOpens or closes the agents panel (also the band's Agents button, hotkey a). Inside it, Compact / Expand (hotkey x) switches between full rows and one line per subagent.
/code-buddy configShows the settings, or changes one.

How a hold works

  • One pane for the guard and blast radius: the rule that matched, the command, what it would affect, and Proceed and Cancel. Cancel has the focus.
  • Holds queue, so only one shows at a time.
  • A held call is refused if nobody answers within 10 minutes, if the turn is interrupted, or straight away in a non-interactive run (such as claude -p).
  • Claude is told why a call was refused and not to retry it unless you ask.

The band above the prompt

One band, shared by all four features, shows the most important thing first: a waiting hold, then the agents progress bar, then the replay hint, then Code Buddy · guard on · N held.

Privacy

  • No network requests, no model calls, no environment variables. Everything it measures comes from local commands such as git status, git log, git clean -n and du. Migrations are listed from files on disk, never by connecting to the database.
  • Keys are masked. Stripe, GitHub, Slack, AWS, Google, npm and model-provider keys, bearer tokens, JWTs, private keys and secret=/token= values show only their first 8 characters, on screen and in anything stored.
  • Costs are estimates from each model's list price. The engine reports tokens, not money.

Development

git clone https://github.com/RuahTechSolutions/code-buddy
claude --plugin-dir ./code-buddy
  • claude plugin test ./code-buddy runs the tests (109 across 7 files).
  • claude plugin validate ./code-buddy checks the manifest, the marketplace file and the hooks module.

The plugin is a Claude Code function-hooks module: hooks/register.tsx is the only file that calls the engine. guard.ts, blast.ts, diff.ts and mask.ts are pure logic. hold.tsx, replay.tsx, agents.tsx and theme.ts draw.

Credits and licence

Apache License 2.0; see LICENSE. Code Buddy adapts code and ideas from blast-radius and replay-theater (Apache-2.0, Anthropic PBC) and savvy-progress (MIT). See NOTICE for what was adapted and where.


Made by Ruah Tech Solutions. Powered by www.Ruah.ai.

Source 11 files
hooks/register.tsx 610 lines
1// Code Buddy: one plugin, four features.
2//   1. Staging guard  (guard.ts)  holds calls that reach production
3//   2. Blast radius   (blast.ts)  holds destructive shell commands
4//   3. Replay         (replay.tsx, diff.ts)  steps through a turn's edits
5//   4. Agents panel   (agents.tsx)  tracks every subagent
6// Features 1 and 2 share the hold pane (hold.tsx); all four share the band.
7// Only this file calls the engine (`$`); the others are pure logic and drawing.
8// No network requests, no model calls, no environment variables.
9
10import type { EngineInterface, Register } from 'claude-code'
11
12import type { AgentRun, AgentsUi, HeldRecord, HoldOutcome, HoldView, Replay, ReplayStep } from '../types'
13import {
14  AGENTS_PANE,
15  activityOf,
16  agentsConfigOf,
17  agentsPaneArgs,
18  agentsRows,
19  barRuns,
20  budgetOf,
21  costOf,
22  drawAgents,
23  drawAgentsBand,
24  fmtUsd,
25  parseBudget,
26  tokensOf,
27} from './agents'
28import type { AgentsConfig, Usage } from './agents'
29import { inspectBlast, looksDestructive, merge } from './blast'
30import { applyEdits, buildStep, relPath } from './diff'
31import { configOf, inspectGuard, looksRisky } from './guard'
32import type { Finding, GuardConfig, Io } from './guard'
33import { HOLD_LIMIT_MS, HOLD_PANE, OUTCOME_LABEL, POLL_SECONDS, drawHold, drawHoldBand, drawIdleBand, paneRows, pending, refusal } from './hold'
34import type { Pending } from './hold'
35import { mask } from './mask'
36import { REPLAY_PANE, drawReplay, drawReplayBand, replayRows } from './replay'
37
38const COMMAND = 'code-buddy'
39const QUEUE = { plugin: 'code-buddy', key: 'queue' } as const
40const LOG = { plugin: 'code-buddy', key: 'log' } as const
41const RECORDING = { plugin: 'code-buddy', key: 'recording' } as const
42const REPLAY = { plugin: 'code-buddy', key: 'replay' } as const
43const AGENTS = { plugin: 'code-buddy', key: 'agents' } as const
44const BATCH = { plugin: 'code-buddy', key: 'batch' } as const
45const AGENTS_UI = { plugin: 'code-buddy', key: 'agentsUi' } as const
46const UI_DEFAULT: AgentsUi = { isCompact: false, autoOpenedBatch: -1, budgetAlertedAt: 0 }
47
48const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
49const MAX_STEPS = 100
50
51const CONFIG_FIELDS: Record<string, { key: string; label: string }> = {
52  production: { key: 'productionDomains', label: 'Production domains' },
53  staging: { key: 'stagingDomains', label: 'Staging domains' },
54  branch: { key: 'protectedBranch', label: 'Protected branch' },
55  guard: { key: 'guard', label: 'Guard' },
56  budget: { key: 'agentBudgetUsd', label: 'Subagent budget' },
57  autoopen: { key: 'autoOpenAgents', label: 'Auto-open agents panel' },
58}
59
60const ago = (ms: number): string => {
61  const s = Math.max(0, Math.round(ms / 1000))
62  if (s < 60) return `${s}s ago`
63  if (s < 3600) return `${Math.round(s / 60)} min ago`
64  return `${Math.round(s / 3600)} h ago`
65}
66
67let serial = 0
68// Whether anyone could ever answer a hold: a REPL session, or one some surface has
69// drawn Code Buddy on (the desktop app attaches its window after the session starts).
70let isInteractive = false
71let hasDrawn = false
72
73// ---- Engine access (every `$` call lives in this file) ----------------------
74
75/** What guard.ts may read: the session folder, local commands, folder listings. */
76function ioOf($: EngineInterface): Io {
77  return {
78    cwd: () => $.session.cwd(),
79    run: async (argv, cwd, timeoutMs) => {
80      try {
81        const r = await $.process.run(argv, { cwd, timeoutMs })
82        return { exitCode: r.exitCode, stdout: r.stdout }
83      } catch {
84        return null
85      }
86    },
87    list: async dir => {
88      try {
89        return (await $.fs.list(dir)).map(f => f.name)
90      } catch {
91        return []
92      }
93    },
94  }
95}
96
97async function readLog($: EngineInterface): Promise<HeldRecord[]> {
98  return (await $.state.get(LOG)).value ?? []
99}
100
101/** Writes the log with read-modify-write, again on a lost race. */
102async function writeLog($: EngineInterface, change: (list: HeldRecord[]) => HeldRecord[]): Promise<void> {
103  for (let tries = 0; tries < 5; tries += 1) {
104    const now = await $.state.get(LOG)
105    const done = await $.state.set(LOG, change(now.value ?? []), { ifVersion: now.version })
106    if (done.isSet) return
107  }
108}
109
110async function record($: EngineInterface, view: HoldView, outcome: HoldOutcome, at: number): Promise<void> {
111  await writeLog($, list => {
112    const row: HeldRecord = { id: view.id, at, tool: view.tool, rules: view.rules, command: view.command, outcome }
113    return list.some(r => r.id === view.id) ? list.map(r => (r.id === view.id ? row : r)) : [...list, row].slice(-200)
114  })
115}
116
117async function publish($: EngineInterface): Promise<void> {
118  await $.state.set(QUEUE, pending.map(p => p.view))
119}
120
121async function pause($: EngineInterface): Promise<void> {
122  await $.process.run(['sleep', POLL_SECONDS], { timeoutMs: 5000 })
123}
124
125/**
126 * Holds the call until the person answers, one hold shown at a time. Resolves
127 * the outcome: `proceeded` runs the call, anything else refuses it.
128 */
129async function hold($: EngineInterface, signal: AbortSignal, tool: string, found: Finding, isSubagent: boolean): Promise<HoldOutcome> {
130  serial += 1
131  const heldAt = await $.clock.now()
132  const view: HoldView = { id: `h${heldAt}-${serial}`, tool, rules: found.rules, command: found.command, affects: found.affects, isSubagent }
133
134  // Nobody could ever answer (a -p run, an SDK session no surface ever drew): refuse at
135  // once. A desktop window that is only detached for now gets the usual 10 minutes.
136  if ((await $.session.surfaces()).length === 0 && !isInteractive && !hasDrawn) {
137    await record($, view, 'no-one', heldAt)
138    return 'no-one'
139  }
140
141  const mine: Pending = { view, decision: null }
142  pending.push(mine)
143  let outcome: HoldOutcome = 'error'
144  try {
145    await record($, view, 'waiting', heldAt)
146    await publish($)
147
148    while (pending[0] !== mine) {
149      if (signal.aborted) {
150        outcome = 'interrupted'
151        return outcome
152      }
153      await pause($)
154    }
155
156    await $.ui.open({ id: HOLD_PANE, title: 'Code Buddy · held', focus: true, rows: paneRows(view) })
157    $.ui.invalidate('ui.render')
158    const shownAt = await $.clock.now()
159    for (;;) {
160      if (mine.decision !== null) {
161        outcome = mine.decision === 'proceed' ? 'proceeded' : 'cancelled'
162        break
163      }
164      if (signal.aborted) {
165        outcome = 'interrupted'
166        break
167      }
168      if ((await $.clock.now()) - shownAt >= HOLD_LIMIT_MS) {
169        outcome = 'timeout'
170        break
171      }
172      await pause($)
173    }
174    return outcome
175  } catch {
176    outcome = 'error' // anything unexpected refuses the call
177    return outcome
178  } finally {
179    const i = pending.indexOf(mine)
180    if (i >= 0) pending.splice(i, 1)
181    try {
182      if (pending.length === 0) await $.ui.close({ id: HOLD_PANE })
183      await publish($)
184      await record($, view, outcome, heldAt)
185    } catch {
186      // the pane is already gone
187    }
188    $.ui.invalidate('ui.render')
189  }
190}
191
192// ---- Replay (feature 3) -------------------------------------------------------
193
194/** The step an edit makes: the file's text before, the edit applied. Null when not an edit. */
195async function stepFor($: EngineInterface, e: Record<string, unknown>): Promise<ReplayStep | null> {
196  const tool = String(e.tool)
197  const path = String(e.file_path ?? e.notebook_path ?? '')
198  if (!EDIT_TOOLS.has(tool) || !path) return null
199  let before = ''
200  let isNew = true
201  try {
202    if (await $.fs.exists(path)) {
203      before = await $.fs.read(path)
204      isNew = false
205    }
206  } catch {
207    // unreadable: shown as a new file
208  }
209  const file = relPath(await $.session.cwd(), path)
210  if (tool === 'Write') return buildStep(file, tool, isNew ? '' : 'rewrite', before, String(e.content ?? ''), isNew)
211  if (tool === 'NotebookEdit') return buildStep(file, tool, `cell ${String(e.edit_mode ?? 'replace')}`, '', String(e.new_source ?? ''), false)
212  const edits = tool === 'MultiEdit' && Array.isArray(e.edits) ? (e.edits as Record<string, unknown>[]) : [e]
213  const after = applyEdits(before, edits)
214  if (after === null) return null
215  const note = tool === 'MultiEdit' ? `${edits.length} edits` : e.replace_all === true ? 'replace all' : ''
216  return buildStep(file, tool, note, before, after, isNew)
217}
218
219async function recordStep($: EngineInterface, step: ReplayStep): Promise<void> {
220  for (let tries = 0; tries < 5; tries += 1) {
221    const now = await $.state.get(RECORDING)
222    const done = await $.state.set(RECORDING, [...(now.value ?? []), step].slice(-MAX_STEPS), { ifVersion: now.version })
223    if (done.isSet) return
224  }
225}
226
227/** Opens the replay pane on step 1; false when there is nothing to replay. */
228async function openReplay($: EngineInterface): Promise<boolean> {
229  const r = (await $.state.get(REPLAY)).value
230  if (!r || r.steps.length === 0) return false
231  await $.state.set(REPLAY, { ...r, index: 0, isSeen: true })
232  await $.ui.open({ id: REPLAY_PANE, title: 'Code Buddy · replay', focus: true, closeOnEscape: true, rows: replayRows(r) })
233  return true
234}
235
236async function goToStep($: EngineInterface, index: number): Promise<void> {
237  const r = (await $.state.get(REPLAY)).value
238  if (!r) return
239  await $.state.set(REPLAY, { ...r, index: Math.max(0, Math.min(index, r.steps.length - 1)) })
240}
241
242// ---- Agents (feature 4) ---------------------------------------------------------
243
244/** Changes the list of runs with read-modify-write, again on a lost race. */
245async function writeAgents($: EngineInterface, change: (list: AgentRun[]) => AgentRun[]): Promise<void> {
246  for (let tries = 0; tries < 5; tries += 1) {
247    const now = await $.state.get(AGENTS)
248    const done = await $.state.set(AGENTS, change(now.value ?? []), { ifVersion: now.version })
249    if (done.isSet) return
250  }
251}
252
253/** Opens the agents panel, or closes it when it is up; true when it ends up open. */
254async function toggleAgents($: EngineInterface): Promise<boolean> {
255  if ((await $.ui.panes()).some(p => p.id === AGENTS_PANE)) {
256    await $.ui.close({ id: AGENTS_PANE })
257    return false
258  }
259  const n = ((await $.state.get(AGENTS)).value ?? []).length
260  await $.ui.open(agentsPaneArgs(agentsRows(n, (await readUi($)).isCompact)))
261  return true
262}
263
264async function readUi($: EngineInterface): Promise<AgentsUi> {
265  return { ...UI_DEFAULT, ...((await $.state.get(AGENTS_UI)).value ?? {}) }
266}
267
268/** Full rows or one line each; the open pane is resized to match. */
269async function toggleCompact($: EngineInterface): Promise<void> {
270  const ui = await readUi($)
271  await $.state.set(AGENTS_UI, { ...ui, isCompact: !ui.isCompact })
272  if ((await $.ui.panes()).some(p => p.id === AGENTS_PANE)) {
273    const n = ((await $.state.get(AGENTS)).value ?? []).length
274    await $.ui.open(agentsPaneArgs(agentsRows(n, !ui.isCompact)))
275  }
276}
277
278/** Opens the panel by itself once a turn has two subagents; once per turn, so a close sticks. */
279async function maybeAutoOpen($: EngineInterface, batch: number): Promise<void> {
280  const list = (await $.state.get(AGENTS)).value ?? []
281  if (list.filter(a => a.batch === batch).length < 2) return
282  const ui = await readUi($)
283  if (ui.autoOpenedBatch === batch) return
284  await $.state.set(AGENTS_UI, { ...ui, autoOpenedBatch: batch })
285  if ((await $.ui.panes()).some(p => p.id === AGENTS_PANE)) return
286  await $.ui.open(agentsPaneArgs(agentsRows(list.length, ui.isCompact)))
287}
288
289/** A subagent's tool call: what it is doing now, and one more tool. */
290async function noteActivity($: EngineInterface, agentId: string, activity: string): Promise<void> {
291  await writeAgents($, list => list.map(a => (a.agentId === agentId && a.status === 'running' ? { ...a, activity, tools: (a.tools ?? 0) + 1 } : a)))
292}
293
294/** One toast when the session's subagents pass the budget (again only if the budget changes). */
295async function checkBudget($: EngineInterface, budget: number): Promise<void> {
296  if (budget <= 0) return
297  const b = budgetOf((await $.state.get(AGENTS)).value ?? [], budget)
298  if (!b?.isOver) return
299  const ui = await readUi($)
300  if (ui.budgetAlertedAt === budget) return
301  await $.state.set(AGENTS_UI, { ...ui, budgetAlertedAt: budget })
302  $.ui.toast(`Code Buddy: subagents have used ${b.used} this session, over your ${b.limit} budget.`, { timeoutMs: 8000 })
303}
304
305/** One model request of a subagent: its tokens and estimated cost. */
306async function addUsage($: EngineInterface, agentId: string, model: string, usage: Usage): Promise<void> {
307  await writeAgents($, list =>
308    list.map(a =>
309      a.agentId !== agentId
310        ? a
311        : { ...a, model: model || a.model, tokens: a.tokens + tokensOf(usage), costUsd: a.costUsd + costOf(model || a.model, usage), steps: a.steps + 1 },
312    ),
313  )
314}
315
316/** A subagent's turn ended: done, failed or stopped, with the turn's usage when no step was seen. */
317async function finishAgent($: EngineInterface, agentId: string, reason: string, usage: (Usage & { model?: string }) | undefined): Promise<void> {
318  const at = await $.clock.now()
319  const status: AgentRun['status'] = reason === 'answer' ? 'done' : reason === 'aborted' ? 'stopped' : 'failed'
320  await writeAgents($, list =>
321    list.map(a => {
322      if (a.agentId !== agentId || a.status !== 'running') return a
323      const model = usage?.model || a.model
324      const fallback = a.steps === 0 && usage ? { model, tokens: tokensOf(usage), costUsd: costOf(model, usage) } : {}
325      return { ...a, ...fallback, status, endedAt: at, activity: undefined }
326    }),
327  )
328}
329
330/** Everything the guard and blast radius hold this call for; null lets it through. */
331async function inspect($: EngineInterface, e: Record<string, unknown>, cfg: GuardConfig): Promise<Finding | null> {
332  if (!cfg.isOn) return null
333  const io = ioOf($)
334  return merge(await inspectGuard(io, e, cfg), await inspectBlast(io, e))
335}
336
337// ---- /code-buddy -------------------------------------------------------------
338
339async function statusText($: EngineInterface, cfg: GuardConfig, acfg: AgentsConfig): Promise<string> {
340  const log = await readLog($)
341  const now = await $.clock.now()
342  const lines = [
343    `Code Buddy · guard ${cfg.isOn ? 'on' : 'off'} · ${log.length} held this session`,
344    `  Production: ${cfg.production.join(', ') || '(none)'}`,
345    `  Staging:    ${cfg.staging.join(', ') || '(none)'} (production wins on overlap)`,
346    `  Protected:  ${cfg.branches.join(', ')}`,
347    `  Subagents:  budget ${acfg.budget > 0 ? fmtUsd(acfg.budget) : 'off'} · auto-open ${acfg.autoOpen ? 'on' : 'off'}`,
348    '',
349  ]
350  if (log.length === 0) {
351    lines.push('No calls held this session.')
352  } else {
353    lines.push('Held calls, newest first:')
354    for (const r of [...log].reverse()) {
355      lines.push(`  ${ago(now - r.at).padEnd(10)} ${r.tool.padEnd(8)} ${OUTCOME_LABEL[r.outcome]}`)
356      lines.push(`      ${r.rules.join('; ')}`)
357      lines.push(`      ${r.command.length > 160 ? `${r.command.slice(0, 159)}…` : r.command}`)
358    }
359  }
360  lines.push('', 'More: /code-buddy replay · /code-buddy agents · /code-buddy config')
361  return mask(lines.join('\n'))
362}
363
364function configText(cfg: GuardConfig, acfg: AgentsConfig): string {
365  return [
366    'Code Buddy settings',
367    `  production  ${cfg.production.join(', ') || '(none)'}`,
368    `  staging     ${cfg.staging.join(', ') || '(none)'}`,
369    `  branch      ${cfg.branches.join(', ')}`,
370    `  guard       ${cfg.isOn ? 'on' : 'off'}`,
371    `  budget      ${acfg.budget > 0 ? fmtUsd(acfg.budget) : 'off'}   (estimated subagent spend per session)`,
372    `  autoopen    ${acfg.autoOpen ? 'on' : 'off'}   (agents panel opens when a 2nd subagent starts)`,
373    '',
374    'Production wins on overlap: a host is let through only when staging lists it exactly (no wildcard).',
375    '',
376    'Change one with /code-buddy config <setting> <value>, for example:',
377    '  /code-buddy config production example.com, *.example.com',
378    '  /code-buddy config staging staging.example.com',
379    '  /code-buddy config branch main',
380    '  /code-buddy config guard off',
381    '  /code-buddy config budget 2.50',
382    '  /code-buddy config autoopen off',
383  ].join('\n')
384}
385
386async function setConfig($: EngineInterface, args: string, cfg: GuardConfig, acfg: AgentsConfig): Promise<string> {
387  const [name = '', ...rest] = args.trim().split(/\s+/)
388  if (!name) return configText(cfg, acfg)
389  const field = CONFIG_FIELDS[name.toLowerCase()]
390  if (!field) return `Unknown setting "${name}". Settings: ${Object.keys(CONFIG_FIELDS).join(', ')}.`
391  const text = rest.join(' ').trim()
392  if (!text) return `Give a value: /code-buddy config ${name} <value>`
393  const isSwitch = field.key === 'guard' || field.key === 'autoOpenAgents'
394  if (isSwitch && text !== 'on' && text !== 'off') return `${field.label} takes on or off.`
395  const budget = field.key === 'agentBudgetUsd' ? parseBudget(text) : 0
396  if (budget === null) return 'Budget takes an amount in dollars, like 2 or 2.50, or off.'
397  const value = isSwitch ? text === 'on' : field.key === 'agentBudgetUsd' ? budget : text
398  const result = await $.config.set({ key: `${COMMAND}.${field.key}`, value })
399  if ('deny' in result && result.deny !== undefined) return `Couldn't change ${field.label}: ${result.deny}`
400  return `${field.label} set to ${text}. Code Buddy reloads with it now.`
401}
402
403// ---- Hooks -------------------------------------------------------------------
404
405export const register: Register = (on, options) => {
406  const cfg = configOf(options as Record<string, unknown>)
407  const acfg = agentsConfigOf(options as Record<string, unknown>)
408
409  on('session.start', async ($, e, next) => {
410    const started = await next(e)
411    isInteractive = e.isInteractive
412    await $.command.register({
413      name: 'code-buddy',
414      description: 'Code Buddy: status and held calls; replay, agents or config',
415      argumentHint: '[replay | agents | config]',
416    })
417    // Running subagents' clocks tick once a second; quiet when none runs.
418    $.clock.every(1000, () => {
419      void (async () => {
420        const list = (await $.state.get(AGENTS)).value ?? []
421        if (list.some(a => a.status === 'running')) $.ui.invalidate('ui.render')
422      })()
423    })
424    return started
425  })
426
427  on('command.run', { command: 'code-buddy' }, async ($, e) => {
428    const [sub = '', ...rest] = e.args.trim().split(/\s+/)
429    switch (sub.toLowerCase()) {
430      case '':
431      case 'status':
432        return { text: await statusText($, cfg, acfg) }
433      case 'config':
434        return { text: await setConfig($, rest.join(' '), cfg, acfg) }
435      case 'replay': {
436        const r = (await $.state.get(REPLAY)).value
437        if (!(await openReplay($)) || !r) return { text: 'Code Buddy · replay: no edits recorded yet. It fills after a turn that edits files.' }
438        return { text: `Code Buddy · replay: ${r.steps.length} ${r.steps.length === 1 ? 'edit' : 'edits'} from the last turn. Prev, Next and Close step through them.` }
439      }
440      case 'agents': {
441        const isOpen = await toggleAgents($)
442        const n = ((await $.state.get(AGENTS)).value ?? []).length
443        return { text: isOpen ? `Code Buddy · agents panel opened (${n} ${n === 1 ? 'subagent' : 'subagents'} this session).` : 'Code Buddy · agents panel closed.' }
444      }
445      default:
446        return { text: `Unknown option "${sub}". Try /code-buddy, /code-buddy replay, /code-buddy agents or /code-buddy config.` }
447    }
448  })
449
450  // Features 1 and 2: hold a risky call until the person answers.
451  // Feature 3: record each file edit that runs, never changing it.
452  on('tool.call', async ($, e, next) => {
453    const input = e as unknown as Record<string, unknown>
454    if (e.agentId !== undefined) {
455      try {
456        await noteActivity($, e.agentId, activityOf(input, await $.session.cwd()))
457      } catch {
458        // showing activity must never stop a call
459      }
460    }
461    const found = await inspect($, input, cfg)
462    if (found !== null) {
463      const outcome = await hold($, next.signal, String(e.tool), found, e.agentId !== undefined)
464      if (outcome !== 'proceeded') {
465        $.ui.toast(`Code Buddy: refused (${OUTCOME_LABEL[outcome]})`)
466        return { deny: refusal(outcome, found) }
467      }
468      $.ui.toast('Code Buddy: running it')
469    }
470    if (!EDIT_TOOLS.has(String(e.tool))) return next(e)
471    let step: ReplayStep | null = null
472    try {
473      step = await stepFor($, input)
474    } catch {
475      // recording must never stop the edit
476    }
477    const ran = await next(e)
478    if (step !== null && ran.deny === undefined && ran.isError !== true) {
479      try {
480        await recordStep($, step)
481      } catch {
482        // nor fail it
483      }
484    }
485    return ran
486  }).catch(($, e, next) =>
487    next.called
488      ? next(e)
489      : cfg.isOn && (looksRisky(e as unknown as Record<string, unknown>, cfg) || (e.tool === 'Bash' && looksDestructive(String(e.command))))
490        ? { deny: 'Code Buddy could not finish checking this call, and it looks risky, so it was refused. Do not retry it unless the user asks you to.' }
491        : next(e),
492  )
493
494  // Feature 4: every subagent, as it starts. Any Agent tool call, no companion skill.
495  on('agent.spawn', async ($, e, next) => {
496    const started = await next(e)
497    if (started.deny !== undefined) return started
498    try {
499      const at = await $.clock.now()
500      const batch = (await $.state.get(BATCH)).value ?? 0
501      const run: AgentRun = {
502        id: started.agentId ?? e.tool_use_id,
503        agentId: started.agentId,
504        type: e.subagentType || 'general-purpose',
505        description: mask(e.description || e.name || ''),
506        model: started.model,
507        status: 'running',
508        startedAt: at,
509        tokens: 0,
510        costUsd: 0,
511        steps: 0,
512        batch,
513      }
514      await writeAgents($, list => [...list.filter(a => a.id !== run.id), run].slice(-200))
515      if (acfg.autoOpen) await maybeAutoOpen($, batch)
516    } catch {
517      // tracking must never stop a subagent
518    }
519    return started
520  }).catch(($, e, next) => next(e)) // a failed hook still lets the subagent start
521
522  // Each model request of a subagent: its tokens and cost.
523  on('turn.step', async function* ($, e, next) {
524    const result = yield* next(e)
525    if (e.agentId !== undefined && result.usage) {
526      try {
527        await addUsage($, e.agentId, result.usage.model || e.model, result.usage)
528        await checkBudget($, acfg.budget)
529      } catch {
530        // nor slow one down
531      }
532    }
533    return result
534  })
535
536  // A new prompt starts a new recording and a new batch of subagents; the replay hint is put away.
537  on('turn.start', async ($, e, next) => {
538    await $.state.set(BATCH, ((await $.state.get(BATCH)).value ?? 0) + 1)
539    await $.state.set(RECORDING, [])
540    const r = (await $.state.get(REPLAY)).value
541    if (r && !r.isSeen) await $.state.set(REPLAY, { ...r, isSeen: true })
542    return next(e)
543  })
544
545  // The main turn's end: its edits become the replay (subagents' turns end too, carrying agentId).
546  on('turn.complete', async ($, e, next) => {
547    const done = await next(e)
548    if (e.agentId !== undefined) {
549      await finishAgent($, e.agentId, e.reason, e.usage)
550      await checkBudget($, acfg.budget)
551      return done
552    }
553    const steps = (await $.state.get(RECORDING)).value ?? []
554    if (steps.length > 0) {
555      const replay: Replay = { steps, index: 0, isSeen: false }
556      await $.state.set(REPLAY, replay)
557      await $.state.set(RECORDING, [])
558    }
559    return done
560  })
561
562  on('ui.render', { component: 'Pane', requestId: 'code-buddy-replay' }, async ($, e) => {
563    const ui = $.ui.resolve(e)
564    const r = (await $.state.get(REPLAY)).value
565    if (!r || r.steps.length === 0) return <ui.Text dimColor>No edits to replay yet.</ui.Text>
566    return drawReplay(ui, e.surface, r, e.props.bodyColumns, {
567      go: index => void goToStep($, index),
568      close: () => void $.ui.close({ id: REPLAY_PANE }),
569    })
570  })
571
572  on('ui.render', { component: 'Pane', requestId: 'code-buddy-agents' }, async ($, e) => {
573    const ui = $.ui.resolve(e)
574    const list = (await $.state.get(AGENTS)).value ?? []
575    const view = { isCompact: (await readUi($)).isCompact, budget: acfg.budget }
576    return drawAgents(ui, e.surface, list, await $.clock.now(), e.props.bodyColumns, view, { toggleCompact: () => void toggleCompact($) })
577  })
578
579  on('ui.render', { component: 'Pane', requestId: 'code-buddy-hold' }, async ($, e) => {
580    const ui = $.ui.resolve(e)
581    const queue = (await $.state.get(QUEUE)).value ?? []
582    const head = queue[0]
583    if (head === undefined) return <ui.Text dimColor>Nothing is held.</ui.Text>
584    return drawHold(ui, e.surface, head, queue.length - 1, e.props.bodyColumns)
585  })
586
587  // The one band above the prompt. Priority: a waiting hold, then the agents
588  // progress bar, then the replay hint, then the idle status.
589  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
590    hasDrawn = true
591    if (e.props.hasSurvey) return next(e)
592    const ui = $.ui.resolve(e)
593    const queue = (await $.state.get(QUEUE)).value ?? []
594    const head = queue[0]
595    if (head !== undefined) return drawHoldBand(ui, e.surface, head, queue.length - 1, e.props.bodyColumns)
596    const all = (await $.state.get(AGENTS)).value ?? []
597    const spend = budgetOf(all, acfg.budget)
598    const runs = barRuns(all, (await $.state.get(BATCH)).value ?? 0)
599    if (runs.some(a => a.status === 'running') || (e.props.isWorking && runs.length > 0)) {
600      return drawAgentsBand(ui, e.surface, runs, e.props.bodyColumns, spend?.isOver ?? false, () => void toggleAgents($))
601    }
602    const replay = (await $.state.get(REPLAY)).value
603    if (replay && !replay.isSeen && replay.steps.length > 0 && !e.props.isWorking) {
604      return drawReplayBand(ui, e.surface, replay, e.props.bodyColumns, () => void openReplay($))
605    }
606    const log = (await $.state.get(LOG)).value ?? []
607    return drawIdleBand(ui, e.surface, cfg.isOn, log.length, e.props.bodyColumns, spend?.isOver ? `over budget · ${spend.used}` : '')
608  })
609}
610
hooks/agents.tsx 341 lines
1// Feature 4, Agents panel: every subagent of the session with its model,
2// status, elapsed time, tokens, estimated cost and what it is doing now; a
3// progress bar of finished out of total in the band; a cost budget. Pure
4// helpers and drawing: register.tsx records the runs from agent.spawn,
5// tool.call, turn.step and turn.complete.
6//
7// The tracking approach and the token-cost estimate follow savvy-progress
8// (claude-kit, MIT). Modified for Code Buddy: no companion skill or tool, no
9// environment reads, current list prices, Ruah Tech theme. See NOTICE.
10
11import type { EngineInterface } from 'claude-code'
12
13import type { AgentRun } from '../types'
14import { mask } from './mask'
15import {
16  AGENTS_HEADER_H,
17  BAND_ROW_H,
18  BRAND,
19  COMPACT_ROW_H,
20  GREEN,
21  agentRowHeight,
22  agentRowSvg,
23  agentsBandSvg,
24  agentsHeaderSvg,
25  compactAgentRowSvg,
26  labelSvg,
27  pxOf,
28} from './theme'
29import type { AgentRowView, BudgetView } from './theme'
30
31export const AGENTS_PANE = 'code-buddy-agents'
32
33type Ui = ReturnType<EngineInterface['ui']['resolve']>
34type Surface = 'terminal' | 'desktop' | 'mobile' | 'vscode'
35export type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
36
37// ---- Settings ----------------------------------------------------------------
38
39export type AgentsConfig = {
40  /** USD; 0 means no budget. */
41  budget: number
42  autoOpen: boolean
43}
44
45export function agentsConfigOf(options: Record<string, unknown>): AgentsConfig {
46  const budget = Number(String(options.agentBudgetUsd ?? '').replace(/[$\s]/g, ''))
47  return { budget: Number.isFinite(budget) && budget > 0 ? budget : 0, autoOpen: options.autoOpenAgents !== false }
48}
49
50/** "2", "$2.50", "off" → dollars, 0 for off; null when it is not a number. */
51export function parseBudget(text: string): number | null {
52  if (/^(off|none|0)$/i.test(text.trim())) return 0
53  const n = Number(text.replace(/[$\s]/g, ''))
54  return Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null
55}
56
57/** The pane's open arguments: a wider dock, and as tall as its rows want. */
58export function agentsPaneArgs(rows: number) {
59  return { id: AGENTS_PANE, title: 'Agents', columns: 76, rows: Math.max(12, Math.min(34, rows)) }
60}
61
62/** Rows the panel wants for this many subagents, full or compact. */
63export const agentsRows = (n: number, isCompact: boolean): number => 8 + (isCompact ? n : n * 3)
64
65// ---- Pricing -----------------------------------------------------------------
66
67// USD per million tokens: input, output, cache read, cache write (5-minute TTL,
68// 1.25 × input). List prices as of 2026-10; the engine reports tokens, not money.
69const PRICES: [RegExp, [number, number, number, number]][] = [
70  [/(fable|mythos)-5-1/, [10, 50, 0.25, 12.5]],
71  [/fable|mythos/, [10, 50, 1, 12.5]],
72  [/opus-5-5/, [4, 20, 0.2, 5]],
73  [/opus/, [5, 25, 0.5, 6.25]],
74  [/sonnet-4/, [3, 15, 0.3, 3.75]],
75  [/sonnet/, [2, 10, 0.2, 2.5]],
76  [/haiku-5/, [0.1, 0.5, 0.01, 0.125]],
77  [/haiku/, [1, 5, 0.1, 1.25]],
78]
79const DEFAULT_PRICE: [number, number, number, number] = [4, 20, 0.2, 5]
80
81export const priceOf = (model: string): [number, number, number, number] =>
82  PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? DEFAULT_PRICE
83
84export function costOf(model: string, u: Usage): number {
85  const [i, o, r, w] = priceOf(model)
86  return ((u.input_tokens || 0) * i + (u.output_tokens || 0) * o + (u.cache_read_input_tokens || 0) * r + (u.cache_creation_input_tokens || 0) * w) / 1e6
87}
88
89export const tokensOf = (u: Usage): number =>
90  (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
91
92// ---- Formatting --------------------------------------------------------------
93
94/** `claude-opus-5-5` → `Opus 5.5`. */
95export function modelName(id: string): string {
96  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
97  const [, family = '', major = '', minor] = m ?? []
98  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
99  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? `.${minor}` : ''}`
100}
101
102export const fmtTokens = (n: number): string => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`)
103export const fmtCost = (usd: number): string =>
104  usd > 0 && usd < 0.005 ? '<$0.01' : `≈$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
105export const fmtUsd = (usd: number): string => `$${usd.toFixed(2)}`
106export function fmtTime(ms: number): string {
107  const s = Math.max(0, Math.round(ms / 1000))
108  const h = Math.floor(s / 3600)
109  const m = Math.floor((s % 3600) / 60)
110  const ss = String(s % 60).padStart(2, '0')
111  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
112}
113
114// ---- Activity ----------------------------------------------------------------
115
116const short = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
117const rel = (cwd: string, path: string): string => (cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path)
118
119/** What a subagent's tool call is doing, in a few words, masked: "Read · src/api/routes.ts". */
120export function activityOf(e: Record<string, unknown>, cwd: string): string {
121  const tool = String(e.tool)
122  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
123  const name = tool.startsWith('mcp__') ? tool.split('__').slice(-1)[0] ?? tool : tool
124  let what = ''
125  if (s('command')) what = s('command').split('\n')[0] ?? ''
126  else if (s('file_path') || s('notebook_path')) what = rel(cwd, s('file_path') || s('notebook_path'))
127  else if (s('pattern')) what = `${s('pattern')}${s('path') ? ` in ${rel(cwd, s('path'))}` : ''}`
128  else if (s('url')) what = s('url').replace(/^https?:\/\//, '')
129  else if (s('query')) what = s('query')
130  else if (s('description')) what = s('description')
131  else if (s('prompt')) what = s('prompt')
132  return mask(what ? `${name} · ${short(what, 80)}` : name)
133}
134
135// ---- Totals ------------------------------------------------------------------
136
137export const elapsed = (a: AgentRun, now: number): number => (a.endedAt ?? Math.max(now, a.startedAt)) - a.startedAt
138
139export function totals(list: AgentRun[], now: number) {
140  const done = list.filter(a => a.status !== 'running').length
141  const running = list.length - done
142  const cost = list.reduce((s, a) => s + a.costUsd, 0)
143  const tokens = list.reduce((s, a) => s + a.tokens, 0)
144  const start = list.length ? Math.min(...list.map(a => a.startedAt)) : now
145  const end = list.length ? Math.max(...list.map(a => a.endedAt ?? Math.max(now, a.startedAt))) : now
146  return { done, running, total: list.length, cost, tokens, time: end - start }
147}
148
149/** The session's spend against the budget, or undefined with no budget set. */
150export function budgetOf(list: AgentRun[], budget: number): BudgetView | undefined {
151  if (budget <= 0) return undefined
152  const used = list.reduce((s, a) => s + a.costUsd, 0)
153  return { limit: fmtUsd(budget), used: fmtCost(used), ratio: Math.min(1, used / budget), isOver: used >= budget }
154}
155
156/** The runs the band's bar counts: this prompt's, plus any still running from before. */
157export const barRuns = (list: AgentRun[], batch: number): AgentRun[] => list.filter(a => a.batch === batch || a.status === 'running')
158
159// ---- Drawing -----------------------------------------------------------------
160
161const STATUS_WORD = { running: 'running', done: 'finished', failed: 'failed', stopped: 'stopped' } as const
162const STATUS_GLYPH = { running: '●', done: '✓', failed: '✗', stopped: '■' } as const
163const STATUS_COLOR = { running: BRAND.sky, done: GREEN, failed: BRAND.coral, stopped: BRAND.muted } as const
164
165const rowOf = (a: AgentRun, now: number): AgentRowView => ({
166  status: a.status,
167  title: a.description || a.type,
168  type: a.type,
169  model: modelName(a.model),
170  elapsed: fmtTime(elapsed(a, now)),
171  tokens: `${fmtTokens(a.tokens)} tokens`,
172  cost: fmtCost(a.costUsd),
173  activity: a.status === 'running' ? a.activity || 'starting…' : undefined,
174  progress: `step ${a.steps} · ${a.tools ?? 0} ${(a.tools ?? 0) === 1 ? 'tool' : 'tools'}`,
175})
176
177export type AgentsView = { isCompact: boolean; budget: number }
178export type AgentsActions = { toggleCompact: () => void }
179
180/** The side panel: summary, then running and finished subagents, full or compact. */
181export function drawAgents(ui: Ui, surface: Surface, list: AgentRun[], now: number, columns: number, view: AgentsView, act: AgentsActions) {
182  const { Box, Text, Button } = ui
183  const running = list.filter(a => a.status === 'running').reverse()
184  const finished = list.filter(a => a.status !== 'running').reverse()
185  const t = totals(list, now)
186  const budget = budgetOf(list, view.budget)
187  const stats: [string, string][] = [
188    ['Cost', fmtCost(t.cost)],
189    ['Tokens', fmtTokens(t.tokens)],
190    ['Time', fmtTime(t.time)],
191  ]
192  const toggle = (
193    <Button key="compact" label={view.isCompact ? 'Expand' : 'Compact'} hotkey="x" onPress={() => act.toggleCompact()} />
194  )
195  const note = (
196    <Text key="note" dimColor italic wrap="wrap">
197      {`Costs are estimates from list prices per model; the engine reports tokens, not money.${budget ? ` Budget ${budget.limit} this session${budget.isOver ? `, passed at ${budget.used}` : ''}.` : ''}`}
198    </Text>
199  )
200  const empty = (
201    <Text key="empty" dimColor wrap="wrap">
202      No subagents yet this session. Each one shows here as soon as it starts.
203    </Text>
204  )
205
206  if (surface === 'desktop' && 'Svg' in ui) {
207    const { Svg } = ui
208    const W = pxOf(columns, 16, 260)
209    const rows = (items: AgentRun[]) =>
210      items.map(a => {
211        const r = rowOf(a, now)
212        const alt = `${r.title}: ${STATUS_WORD[a.status]}, ${r.type}, ${r.model}, ${r.elapsed}, ${r.tokens}, ${r.cost}${r.activity ? `, now ${r.activity}` : ''}`
213        return view.isCompact ? (
214          <Svg key={a.id} source={compactAgentRowSvg(W, r)} alt={alt} width={W} height={COMPACT_ROW_H} />
215        ) : (
216          <Svg key={a.id} source={agentRowSvg(W, r)} alt={alt} width={W} height={agentRowHeight(r)} />
217        )
218      })
219    return (
220      <Box flexDirection="column" gap={1}>
221        <Svg
222          key="head"
223          source={agentsHeaderSvg(W, t.done, t.running, t.total, stats, budget)}
224          alt={`Subagents: ${t.done} of ${t.total} finished. ${stats.map(s => s.join(' ')).join(', ')}${budget ? `, budget ${budget.limit}${budget.isOver ? ' passed' : ''}` : ''}`}
225          width={W}
226          height={AGENTS_HEADER_H}
227        />
228        {toggle}
229        {list.length === 0 && empty}
230        {running.length > 0 && <Svg key="l-run" source={labelSvg(W, `Running · ${running.length}`)} alt={`Running: ${running.length}`} width={W} height={18} />}
231        {rows(running)}
232        {finished.length > 0 && <Svg key="l-done" source={labelSvg(W, `Finished · ${finished.length}`)} alt={`Finished: ${finished.length}`} width={W} height={18} />}
233        {rows(finished)}
234        {list.length > 0 && note}
235      </Box>
236    )
237  }
238
239  const barW = Math.max(8, Math.min(24, columns - 40))
240  const row = (a: AgentRun) => {
241    const r = rowOf(a, now)
242    if (view.isCompact) {
243      return (
244        <Text key={a.id} wrap="truncate-end">
245          <Text color={STATUS_COLOR[a.status]} bold>{`${STATUS_GLYPH[a.status]} `}</Text>
246          <Text bold>{r.title}</Text>
247          <Text dimColor>{`  ${r.model} · ${r.elapsed} · ${r.cost}`}</Text>
248          {r.activity ? <Text color={BRAND.sky}>{`  ▸ ${r.activity}`}</Text> : null}
249        </Text>
250      )
251    }
252    return (
253      <Box key={a.id} flexDirection="column" marginBottom={1}>
254        <Text wrap="truncate-end">
255          <Text color={STATUS_COLOR[a.status]} bold>{`${STATUS_GLYPH[a.status]} `}</Text>
256          <Text bold>{r.title}</Text>
257        </Text>
258        <Text dimColor wrap="truncate-end">{`  ${r.type} · ${r.model} · ${r.elapsed} · ${r.tokens} · ${r.cost}`}</Text>
259        {r.activity ? (
260          <Text wrap="truncate-end">
261            <Text color={BRAND.sky}>{`  ▸ ${r.activity}`}</Text>
262            <Text dimColor>{`  ${r.progress}`}</Text>
263          </Text>
264        ) : null}
265      </Box>
266    )
267  }
268  return (
269    <Box flexDirection="column" paddingX={1}>
270      <Box key="head" flexDirection="row" justifyContent="space-between">
271        <Text wrap="truncate-end">
272          <Text backgroundColor={BRAND.royal} color={BRAND.white} bold>
273            {' ≋ AGENTS '}
274          </Text>
275          <Text bold color={BRAND.sky}>{`  ${t.done} of ${t.total} finished`}</Text>
276        </Text>
277        {toggle}
278      </Box>
279      <Text key="bar" wrap="truncate-end">
280        <Text color={budget?.isOver ? BRAND.coral : BRAND.blue}>{bar(t.done, t.total, barW)}</Text>
281        <Text dimColor>{`  ${stats.map(([k, v]) => `${k.toLowerCase()} ${v}`).join(' · ')}`}</Text>
282      </Text>
283      {budget && (
284        <Text key="budget" wrap="truncate-end">
285          <Text dimColor>{'budget '}</Text>
286          {budget.isOver ? (
287            <Text backgroundColor={BRAND.coral} color={BRAND.white} bold>{` over ${budget.limit} `}</Text>
288          ) : (
289            <Text color={BRAND.sky}>{`${Math.round(budget.ratio * 100)}% of ${budget.limit}`}</Text>
290          )}
291        </Text>
292      )}
293      <Box key="list" flexDirection="column" marginTop={1}>
294        {list.length === 0 && empty}
295        {running.length > 0 && <Text color={BRAND.sky} bold>{`RUNNING · ${running.length}`}</Text>}
296        {running.map(row)}
297        {finished.length > 0 && <Text color={BRAND.sky} bold>{`FINISHED · ${finished.length}`}</Text>}
298        {finished.map(row)}
299      </Box>
300      {list.length > 0 && note}
301    </Box>
302  )
303}
304
305const bar = (done: number, total: number, width: number): string => {
306  const filled = total ? Math.round((width * done) / total) : 0
307  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
308}
309
310/** The band: finished out of total, and a button that toggles the panel. Coral past the budget. */
311export function drawAgentsBand(ui: Ui, surface: Surface, list: AgentRun[], columns: number, isOver: boolean, toggle: () => void) {
312  const { Box, Text, Button } = ui
313  const t = totals(list, 0)
314  const button = <Button key="toggle-agents" label="Agents" hotkey="a" onPress={toggle} />
315  if (surface === 'desktop' && 'Svg' in ui) {
316    const { Svg } = ui
317    const W = Math.min(pxOf(columns, 110, 220), 760)
318    return (
319      <Box flexDirection="row" alignItems="center" gap={1}>
320        <Svg key="agents" source={agentsBandSvg(W, t.done, t.running, t.total, fmtCost(t.cost), isOver)} alt={`Agents: ${t.done} of ${t.total} finished, ${fmtCost(t.cost)}${isOver ? ', over budget' : ''}`} width={W} height={BAND_ROW_H} />
321        {button}
322      </Box>
323    )
324  }
325  const barW = Math.max(6, Math.min(24, columns - 60))
326  return (
327    <Box flexDirection="row" gap={1}>
328      <Text wrap="truncate-end">
329        <Text backgroundColor={BRAND.royal} color={BRAND.white} bold>
330          {' ≋ Code Buddy '}
331        </Text>
332        <Text bold>{'  Agents '}</Text>
333        <Text color={isOver ? BRAND.coral : BRAND.blue}>{bar(t.done, t.total, barW)}</Text>
334        <Text dimColor>{`  ${t.done}/${t.total} finished · ${fmtCost(t.cost)}`}</Text>
335        {isOver ? <Text color={BRAND.coral} bold>{' · over budget'}</Text> : null}
336      </Text>
337      {button}
338    </Box>
339  )
340}
341
hooks/blast.ts 325 lines
1// Feature 2, Blast radius: holds destructive shell commands and works out what
2// they would delete or change.
3//
4// Adapted from blast-radius (claude-code-playground), Copyright 2026 Anthropic
5// PBC, licensed under the Apache License 2.0. Modified for Code Buddy: ported
6// to TypeScript; reads through register.tsx's Io instead of `$`; `~` expands
7// from the account record (HOME unset) so no environment variable is read;
8// migrations are listed from files on disk instead of the migration tools'
9// status commands, which would connect to the database. See NOTICE.
10
11import type { Finding, Io } from './guard'
12import { mask } from './mask'
13import { gitParts, segments } from './shell'
14import type { Segment } from './shell'
15
16const LIST_MAX = 10
17
18type GitRisk = { kind: 'git-reset' | 'git-clean' | 'git-push-force' | 'git-checkout'; label: string; args: string[]; dir: string | null }
19
20type Risk =
21  | { kind: 'rm'; label: string; targets: string[]; dir: string | null }
22  | GitRisk
23  | { kind: 'migrate'; tool: string; label: string; dir: string | null }
24
25type Measured = { summary: string; lines: string[]; more: number; note: string }
26
27// Commands that only read, so a bare word "migrate" in them isn't a migration.
28const READ_ONLY = new Set(['ls', 'cat', 'echo', 'printf', 'grep', 'rg', 'find', 'less', 'head', 'tail', 'git', 'man', 'which', 'type'])
29
30/** Every destructive command on the line, in order. */
31export function classify(command: string): Risk[] {
32  return segments(command)
33    .map(classifySegment)
34    .filter((r): r is Risk => r !== null)
35}
36
37function classifySegment(seg: Segment): Risk | null {
38  const { cmd, args, dir } = seg
39  if (cmd === 'rm') {
40    const flags = args.filter(a => a.startsWith('-') && a !== '-' && a !== '--')
41    const recursive = flags.some(f => f === '--recursive' || (/^-[^-]/.test(f) && /[rR]/.test(f)))
42    const force = flags.some(f => f === '--force' || (/^-[^-]/.test(f) && f.includes('f')))
43    if (recursive || force) {
44      const dash = args.indexOf('--')
45      const targets = dash >= 0 ? args.slice(dash + 1) : args.filter(a => !a.startsWith('-') || a === '-')
46      return { kind: 'rm', label: `rm ${flags.join(' ')}`.trim(), targets, dir }
47    }
48  }
49  if (cmd === 'git') {
50    const { dir: gitDir, sub, rest } = gitParts(seg)
51    if (sub === 'reset' && rest.includes('--hard')) return { kind: 'git-reset', label: 'git reset --hard', args: rest, dir: gitDir }
52    if (sub === 'clean' && !rest.some(a => a === '-n' || a === '--dry-run' || (/^-[a-zA-Z]+$/.test(a) && a.includes('n')))) {
53      return { kind: 'git-clean', label: 'git clean', args: rest, dir: gitDir }
54    }
55    if (sub === 'push' && rest.some(a => a === '--force' || a === '-f' || a.startsWith('--force-with-lease') || a === '--force-if-includes' || /^\+/.test(a) || (/^-[a-zA-Z]+$/.test(a) && a.includes('f')))) {
56      return { kind: 'git-push-force', label: 'git push --force', args: rest, dir: gitDir }
57    }
58    const stagedOnly = sub === 'restore' && rest.includes('--staged') && !rest.includes('--worktree') && !rest.includes('-W')
59    if ((sub === 'checkout' || sub === 'restore') && (rest.includes('.') || rest.includes(':/')) && !stagedOnly) {
60      return { kind: 'git-checkout', label: `git ${sub} -- .`, args: rest, dir: gitDir }
61    }
62  }
63  const joined = [cmd, ...args].join(' ')
64  const migration: [RegExp, string, string][] = [
65    [/\balembic\s+(upgrade|downgrade)\b/, 'alembic', 'alembic upgrade'],
66    [/\bdb:(migrate|rollback|reset|drop|schema:load)(?!:status\b)/, 'rails', 'rails db:migrate'],
67    [/\bprisma\s+(migrate\s+(deploy|dev|reset)|db\s+push)\b/, 'prisma', 'prisma migrate'],
68    [/\bmanage\.py\s+(migrate|flush)\b/, 'django', 'manage.py migrate'],
69    [/\b(knex|sequelize)\b.*\b(migrate|db:migrate)/, 'knex', 'knex/sequelize migrate'],
70    [/\bdrizzle-kit\s+(migrate|push)\b/, 'drizzle', 'drizzle-kit migrate'],
71    [/\bflyway\b.*\bmigrate\b/, 'flyway', 'flyway migrate'],
72  ]
73  for (const [re, tool, label] of migration) if (re.test(joined)) return { kind: 'migrate', tool, label, dir }
74  if (!READ_ONLY.has(cmd) && args.includes('migrate')) return { kind: 'migrate', tool: 'unknown', label: `${cmd} migrate`, dir }
75  return null
76}
77
78// ---- Measuring -------------------------------------------------------------
79
80// With HOME unset, bash expands ~ from the account record: no variable is read.
81const NO_HOME = ['env', '-u', 'HOME', 'bash', '-c']
82
83// Resolves a folder to an absolute one, or prints nothing when it doesn't exist.
84// The folder is an argument, never source.
85const CD_SCRIPT = [
86  'unset CDPATH; h=~; d="$1"',
87  'case "$d" in "~") d="$h";; "~/"*) d="$h/${d#\\~/}";; esac',
88  'cd -- "$d" 2>/dev/null && pwd -P',
89].join('\n')
90
91// Counts what rm would remove. Paths are arguments, never source; compgen -G
92// expands a glob without command substitution.
93const RM_SCRIPT = [
94  'shopt -s nullglob dotglob',
95  'h=~',
96  'paths=()',
97  'for p in "$@"; do',
98  '  case "$p" in "~") p="$h";; "~/"*) p="$h/${p#\\~/}";; esac',
99  '  if [[ "$p" == *[*?[]* ]]; then',
100  '    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")',
101  '  elif [[ -e "$p" || -L "$p" ]]; then',
102  '    paths+=("$p")',
103  '  fi',
104  'done',
105  'if (( ${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi',
106  // A relative path gets ./ in front, so find never reads a name like -delete as an action.
107  'for i in "${!paths[@]}"; do case "${paths[$i]}" in /*) ;; *) paths[$i]="./${paths[$i]}";; esac; done',
108  'files=$(find "${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d " ")',
109  'kb=$(du -skc "${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)',
110  'echo "$files $(( ${kb:-0} * 1024 )) ${#paths[@]}"',
111  `find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}`,
112].join('\n')
113
114async function resolveDir(io: Io, cwd: string, dir: string | null): Promise<string | null> {
115  if (dir === null) return cwd
116  if (dir === '-') return null // `cd -` depends on the shell's history
117  const run = await io.run([...NO_HOME, CD_SCRIPT, 'code-buddy', dir], cwd, 5000)
118  const out = run?.stdout.trim() ?? ''
119  return run?.exitCode === 0 && out !== '' ? out : null
120}
121
122function size(bytes: number): string {
123  if (!Number.isFinite(bytes) || bytes < 1024) return `${bytes || 0} B`
124  const units = ['KB', 'MB', 'GB', 'TB']
125  let n = bytes
126  let i = -1
127  while (n >= 1024 && i < units.length - 1) {
128    n /= 1024
129    i += 1
130  }
131  return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`
132}
133
134const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`
135
136async function measureRm(io: Io, risk: Extract<Risk, { kind: 'rm' }>, cwd: string): Promise<Measured> {
137  if (risk.targets.length === 0) return { summary: 'rm with no paths', lines: [], more: 0, note: 'No paths to expand.' }
138  const run = await io.run([...NO_HOME, RM_SCRIPT, 'code-buddy', ...risk.targets], cwd, 15000)
139  const [head, ...rest] = (run?.stdout ?? '').split('\n').filter(l => l !== '')
140  const [files = 0, bytes = 0, found = 0] = (head ?? '0 0 0').split(' ').map(Number)
141  if (!found) return { summary: `delete nothing: no file matches ${risk.targets.join(' ')}`, lines: [], more: 0, note: "The paths don't exist, so rm has nothing to remove." }
142  if (!files) return { summary: `delete ${plural(found, 'path')} with no files in ${found === 1 ? 'it' : 'them'}`, lines: [], more: 0, note: `Paths: ${risk.targets.join(' ')}` }
143  return {
144    summary: `delete ${plural(files, 'file')} (about ${size(bytes)})`,
145    lines: rest.map(l => l.replace(/^\.\//, '')),
146    more: Math.max(0, files - rest.length),
147    note: `Paths: ${risk.targets.join(' ')}. Deleted files don't go to the Trash.`,
148  }
149}
150
151async function measureClean(io: Io, args: string[], where: string): Promise<Measured> {
152  const flags: string[] = []
153  const paths: string[] = []
154  for (let i = 0; i < args.length; i += 1) {
155    const a = args[i] ?? ''
156    if (a === '--') {
157      paths.push(...args.slice(i + 1))
158      break
159    }
160    if (a === '-e' || a === '--exclude') {
161      flags.push(a, args[i + 1] ?? '')
162      i += 1
163    } else if (a.startsWith('--exclude=') || /^-e./.test(a)) {
164      flags.push(a)
165    } else if (/^-[a-zA-Z]+$/.test(a)) {
166      const kept = a.replace(/[finq]/g, '') // -n is added below; -f, -i and -q would change the dry run
167      if (kept !== '-') flags.push(kept)
168    } else if (!a.startsWith('-')) {
169      paths.push(a)
170    }
171  }
172  const run = await io.run(['git', 'clean', '-n', ...flags, '--', ...paths], where, 15000)
173  if (run?.exitCode !== 0) return { summary: 'git clean (could not dry-run it)', lines: [], more: 0, note: '' }
174  const gone = run.stdout.split('\n').filter(l => l.startsWith('Would remove ')).map(l => l.slice(13))
175  return {
176    summary: gone.length === 0 ? 'remove nothing: no untracked files match' : `remove ${plural(gone.length, 'untracked path')}`,
177    lines: gone.slice(0, LIST_MAX),
178    more: Math.max(0, gone.length - LIST_MAX),
179    note: "From git clean -n. Untracked files aren't in git, so they can't be recovered.",
180  }
181}
182
183async function measureDiscard(io: Io, risk: GitRisk, where: string): Promise<Measured> {
184  const status = await io.run(['git', 'status', '--porcelain'], where, 15000)
185  if (status?.exitCode !== 0) return { summary: `${risk.label} (not a git repo here?)`, lines: [], more: 0, note: '' }
186  const rows = status.stdout.split('\n').filter(l => l.length > 3 && !l.startsWith('??'))
187  // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
188  const lost = risk.kind === 'git-reset' ? rows : rows.filter(l => l[1] !== ' ')
189  const stat = await io.run(['git', 'diff', '--shortstat', risk.kind === 'git-reset' ? 'HEAD' : '--'], where, 15000)
190  const target = risk.kind === 'git-reset' ? risk.args.find(a => !a.startsWith('-')) : undefined
191  const shortstat = stat?.stdout.trim() ?? ''
192  return {
193    summary: lost.length === 0 ? `discard nothing: no uncommitted changes${target ? ` (then move to ${target})` : ''}` : `discard uncommitted changes in ${plural(lost.length, 'file')}${target ? ` and move to ${target}` : ''}`,
194    lines: lost.slice(0, LIST_MAX).map(l => `${l.slice(0, 2)} ${l.slice(3)}`),
195    more: Math.max(0, lost.length - LIST_MAX),
196    note: shortstat !== '' ? `${shortstat}. Uncommitted changes can't be recovered.` : 'From git status --porcelain.',
197  }
198}
199
200async function measurePush(io: Io, args: string[], where: string): Promise<Measured> {
201  const positional = args.filter(a => !a.startsWith('-'))
202  const remote = positional[0] ?? 'origin'
203  const spec = (positional[1] ?? '').replace(/^\+/, '')
204  let [source = '', branch = ''] = spec.includes(':') ? spec.split(':') : [spec, spec]
205  branch = branch.replace(/^refs\/heads\//, '')
206  if (!branch || branch === 'HEAD') {
207    const head = await io.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], where, 10000)
208    branch = head?.stdout.trim() ?? ''
209    source = 'HEAD'
210  }
211  source = source || 'HEAD'
212  const ref = `${remote}/${branch}`
213  const known = await io.run(['git', 'rev-parse', '--verify', '--quiet', ref], where, 10000)
214  if (known?.exitCode !== 0) return { summary: `force-push to ${ref}`, lines: [], more: 0, note: `No local copy of ${ref}, so the commits it would drop can't be listed (no fetch is made).` }
215  const log = await io.run(['git', 'log', '--oneline', '--no-decorate', `${source}..${ref}`], where, 15000)
216  const dropped = (log?.stdout ?? '').split('\n').filter(l => l !== '')
217  return {
218    summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${plural(dropped.length, 'commit')}`,
219    lines: dropped.slice(0, LIST_MAX),
220    more: Math.max(0, dropped.length - LIST_MAX),
221    note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
222  }
223}
224
225// Where each tool keeps its migration files. Listing files needs no database.
226const MIGRATION_DIRS: Record<string, string[]> = {
227  prisma: ['prisma/migrations'],
228  django: ['migrations'],
229  alembic: ['alembic/versions', 'migrations/versions'],
230  rails: ['db/migrate'],
231  knex: ['migrations', 'db/migrations'],
232  drizzle: ['drizzle', 'migrations'],
233  flyway: ['sql', 'db/migration', 'src/main/resources/db/migration'],
234  unknown: ['migrations', 'db/migrations', 'db/migrate'],
235}
236
237async function measureMigrations(io: Io, risk: Extract<Risk, { kind: 'migrate' }>, where: string): Promise<Measured> {
238  for (const rel of MIGRATION_DIRS[risk.tool] ?? []) {
239    const names = (await io.list(`${where}/${rel}`)).filter(n => !n.startsWith('.') && !n.startsWith('__') && n !== 'migration_lock.toml').sort()
240    if (names.length === 0) continue
241    const newest = names.slice(-LIST_MAX).reverse()
242    return {
243      summary: `run ${risk.label} against the configured database (${plural(names.length, 'migration')} in ${rel})`,
244      lines: newest,
245      more: Math.max(0, names.length - newest.length),
246      note: "Newest first. Which ones are pending can't be checked without connecting to the database, so none is assumed applied.",
247    }
248  }
249  return { summary: `run ${risk.label} against the configured database`, lines: [], more: 0, note: "Couldn't find the migration files to list them." }
250}
251
252async function measure(io: Io, risk: Risk, cwd: string): Promise<Measured> {
253  const where = await resolveDir(io, cwd, risk.dir)
254  if (where === null) return { summary: `${risk.label} in ${risk.dir}`, lines: [], more: 0, note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
255  switch (risk.kind) {
256    case 'rm':
257      return measureRm(io, risk, where)
258    case 'git-clean':
259      return measureClean(io, risk.args, where)
260    case 'git-push-force':
261      return measurePush(io, risk.args, where)
262    case 'migrate':
263      return measureMigrations(io, risk, where)
264    default:
265      return measureDiscard(io, risk, where)
266  }
267}
268
269/** Whether blast radius holds this call, and what it would change. */
270export async function inspectBlast(io: Io, e: Record<string, unknown>): Promise<Finding | null> {
271  if (String(e.tool) !== 'Bash') return null
272  const command = String(e.command ?? '')
273  const risks = classify(command)
274  if (risks.length === 0) return null
275  const cwd = await io.cwd()
276  const rules: string[] = []
277  const summaries: string[] = []
278  const lines: string[] = []
279  const notes: string[] = []
280  let more = 0
281  for (const risk of risks) {
282    let m: Measured
283    try {
284      m = await measure(io, risk, cwd)
285    } catch {
286      m = { summary: `${risk.label} (could not measure it)`, lines: [], more: 0, note: '' }
287    }
288    rules.push(`destructive: ${risk.label}`)
289    summaries.push(m.summary)
290    lines.push(...m.lines)
291    more += m.more
292    if (m.note) notes.push(m.note)
293  }
294  return {
295    rules: rules.map(mask),
296    command: mask(command),
297    affects: {
298      summary: mask(summaries.join('; ')),
299      lines: lines.slice(0, LIST_MAX).map(mask),
300      more: more + Math.max(0, lines.length - LIST_MAX),
301      note: mask(notes.join(' ')),
302    },
303  }
304}
305
306/** Merges the guard's and blast radius's findings for one call into one hold. */
307export function merge(a: Finding | null, b: Finding | null): Finding | null {
308  if (a === null) return b
309  if (b === null) return a
310  const lines = [...a.affects.lines, ...b.affects.lines]
311  return {
312    rules: [...a.rules, ...b.rules],
313    command: a.command,
314    affects: {
315      summary: `${a.affects.summary}; ${b.affects.summary}`,
316      lines: lines.slice(0, LIST_MAX),
317      more: a.affects.more + b.affects.more + Math.max(0, lines.length - LIST_MAX),
318      note: [a.affects.note, b.affects.note].filter(Boolean).join(' '),
319    },
320  }
321}
322
323/** For when the check itself failed: does this look destructive? */
324export const looksDestructive = (command: string): boolean => classify(command).length > 0
325
hooks/diff.ts 151 lines
1// Feature 3, Replay: turns one file edit into a step with a unified diff.
2//
3// The line diff is adapted from replay-theater (claude-code-playground),
4// Copyright 2026 Anthropic PBC, licensed under the Apache License 2.0.
5// Modified for Code Buddy: ported to TypeScript; the edit is applied to the
6// file's text so the diff has real line numbers; common lines at the ends are
7// trimmed before the LCS; the result is unified-diff hunks, masked. See NOTICE.
8
9import type { ReplayStep } from '../types'
10import { mask } from './mask'
11
12type Op = { op: ' ' | '-' | '+'; t: string }
13
14const MAX_LCS_LINES = 400
15const CONTEXT = 3
16/** Diff lines kept per step; whole hunks first, the last one cut to fit. */
17export const MAX_DIFF_LINES = 160
18
19function splitLines(text: string): string[] {
20  if (text === '') return []
21  const lines = text.split('\n')
22  if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
23  return lines
24}
25
26/** Line operations from a to b: common ends trimmed, then an LCS on the middle. */
27export function lineOps(a: string[], b: string[]): { ops: Op[]; skipped: number } {
28  let start = 0
29  while (start < a.length && start < b.length && a[start] === b[start]) start += 1
30  let endA = a.length
31  let endB = b.length
32  while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
33    endA -= 1
34    endB -= 1
35  }
36  const ctxStart = Math.max(0, start - CONTEXT)
37  const head: Op[] = a.slice(ctxStart, start).map(t => ({ op: ' ', t }))
38  const tail: Op[] = a.slice(endA, Math.min(a.length, endA + CONTEXT)).map(t => ({ op: ' ', t }))
39  const midA = a.slice(start, endA)
40  const midB = b.slice(start, endB)
41  let mid: Op[]
42  if (midA.length > MAX_LCS_LINES || midB.length > MAX_LCS_LINES) {
43    mid = [...midA.map(t => ({ op: '-' as const, t })), ...midB.map(t => ({ op: '+' as const, t }))]
44  } else {
45    const n = midA.length
46    const m = midB.length
47    const dp = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
48    for (let i = n - 1; i >= 0; i -= 1) {
49      for (let j = m - 1; j >= 0; j -= 1) {
50        dp[i]![j] = midA[i] === midB[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
51      }
52    }
53    mid = []
54    let i = 0
55    let j = 0
56    while (i < n && j < m) {
57      if (midA[i] === midB[j]) {
58        mid.push({ op: ' ', t: midA[i]! })
59        i += 1
60        j += 1
61      } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
62        mid.push({ op: '-', t: midA[i]! })
63        i += 1
64      } else {
65        mid.push({ op: '+', t: midB[j]! })
66        j += 1
67      }
68    }
69    while (i < n) mid.push({ op: '-', t: midA[i++]! })
70    while (j < m) mid.push({ op: '+', t: midB[j++]! })
71  }
72  return { ops: [...head, ...mid, ...tail], skipped: ctxStart }
73}
74
75/** Unified-diff hunks over the ops; `skipped` lines precede the first op in both files. */
76function hunks(ops: Op[], skipped: number): string[][] {
77  const changed = ops.map((o, i) => (o.op === ' ' ? -1 : i)).filter(i => i >= 0)
78  if (changed.length === 0) return []
79  // Group changes whose gap is small enough to share context.
80  const groups: [number, number][] = []
81  for (const i of changed) {
82    const last = groups[groups.length - 1]
83    if (last && i - last[1] <= CONTEXT * 2 + 1) last[1] = i
84    else groups.push([i, i])
85  }
86  const out: string[][] = []
87  for (const [first, lastChange] of groups) {
88    const from = Math.max(0, first - CONTEXT)
89    const to = Math.min(ops.length, lastChange + CONTEXT + 1)
90    const before = ops.slice(0, from)
91    const oldBefore = skipped + before.filter(o => o.op !== '+').length
92    const newBefore = skipped + before.filter(o => o.op !== '-').length
93    out.push([`@@${oldBefore}@@${newBefore}`, ...ops.slice(from, to).map(o => `${o.op}${o.t}`)])
94  }
95  return out
96}
97
98/** The header of a hunk from its lines, counts recomputed so a cut hunk still parses. */
99function header(oldBefore: number, newBefore: number, lines: string[]): string {
100  const oldCount = lines.filter(l => !l.startsWith('+')).length
101  const newCount = lines.filter(l => !l.startsWith('-')).length
102  const oldStart = oldCount === 0 ? oldBefore : oldBefore + 1
103  const newStart = newCount === 0 ? newBefore : newBefore + 1
104  return `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`
105}
106
107/** One replay step: the file's text before and after the edit, as a masked unified diff. */
108export function buildStep(file: string, tool: string, note: string, before: string, after: string, isNew: boolean): ReplayStep {
109  const { ops, skipped } = lineOps(splitLines(before), splitLines(after))
110  const adds = ops.filter(o => o.op === '+').length
111  const dels = ops.filter(o => o.op === '-').length
112  const body: string[] = []
113  let cut = 0
114  for (const h of hunks(ops, skipped)) {
115    const [mark = '', ...lines] = h
116    const [, ob = '0', nb = '0'] = mark.split('@@')
117    const room = MAX_DIFF_LINES - body.length - 1
118    if (room < 2) {
119      cut += lines.length
120      continue
121    }
122    const kept = lines.slice(0, room)
123    cut += lines.length - kept.length
124    body.push(header(Number(ob), Number(nb), kept), ...kept.map(mask))
125  }
126  const path = mask(file)
127  const diff = body.length === 0 ? '' : [`--- ${isNew ? '/dev/null' : `a/${path}`}`, `+++ b/${path}`, ...body].join('\n')
128  return { file: path, tool, note: cut > 0 ? `${note}${note ? ' · ' : ''}${cut} more lines not shown` : note, diff, adds, dels, isNew }
129}
130
131/** The file's text after an Edit or MultiEdit, the edits applied as the tool would. */
132export function applyEdits(before: string, edits: { old_string?: unknown; new_string?: unknown; replace_all?: unknown }[]): string | null {
133  let text = before
134  for (const ed of edits) {
135    const from = String(ed.old_string ?? '')
136    const to = String(ed.new_string ?? '')
137    if (from === '') {
138      text = to + text
139      continue
140    }
141    const at = text.indexOf(from)
142    if (at < 0) return null // the tool would fail; nothing to record
143    text = ed.replace_all === true ? text.split(from).join(to) : text.slice(0, at) + to + text.slice(at + from.length)
144  }
145  return text
146}
147
148/** Relative to the session folder when inside it. */
149export const relPath = (cwd: string, path: string): string =>
150  !path ? '(unknown file)' : cwd && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
151
hooks/guard.ts 375 lines
1// Feature 1, Staging guard: decides whether a tool call should be held, and
2// works out what it would affect. Reads the working copy with local git only:
3// no network requests, no model calls, no environment variables.
4
5import type { Affects } from '../types'
6import { liveStripeKeys, mask } from './mask'
7import { gitParts, segments } from './shell'
8import type { Segment } from './shell'
9
10/** What the guard may read, handed in by register.tsx (only it calls the engine). */
11export type Io = {
12  cwd: () => Promise<string>
13  /** Runs a local command; null when it could not start. */
14  run: (argv: string[], cwd: string, timeoutMs: number) => Promise<{ exitCode: number; stdout: string } | null>
15  /** The names in a folder; empty when unreadable. */
16  list: (dir: string) => Promise<string[]>
17}
18
19export type GuardConfig = {
20  production: string[]
21  staging: string[]
22  branches: string[]
23  isOn: boolean
24}
25
26/** What a held call is held for. Every text in it is masked. */
27export type Finding = {
28  rules: string[]
29  command: string
30  affects: Affects
31}
32
33const LIST_MAX = 10
34
35export const parseList = (text: unknown): string[] =>
36  String(text ?? '')
37    .split(/[,\s]+/)
38    .map(s => s.trim().toLowerCase().replace(/\.$/, ''))
39    .filter(Boolean)
40
41export function configOf(options: Record<string, unknown>): GuardConfig {
42  return {
43    production: parseList(options.productionDomains),
44    staging: parseList(options.stagingDomains),
45    branches: parseList(options.protectedBranch).length ? parseList(options.protectedBranch) : ['main'],
46    isOn: options.guard !== false && options.guard !== 'off',
47  }
48}
49
50// ---- Domains ---------------------------------------------------------------
51
52const HOST = /(?<![A-Za-z0-9_%+.-])((?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63})\.?(?![A-Za-z0-9_-])/g
53// A URL's host, or a whole value that is a bare host (a browser's address bar).
54const URL_HOST = /[A-Za-z][A-Za-z0-9+.-]*:\/\/(?:[^\s/@]*@)?([A-Za-z0-9.-]+)/g
55
56/** Every host named in shell text. */
57export function hostsIn(text: string): string[] {
58  const out = new Set<string>()
59  for (const m of text.matchAll(HOST)) out.add((m[1] ?? '').toLowerCase())
60  return [...out]
61}
62
63/** Hosts in a browser or fetch tool's values: URLs, or a value that is just a host. */
64export function urlHostsIn(text: string): string[] {
65  const out = new Set<string>()
66  for (const m of text.matchAll(URL_HOST)) out.add((m[1] ?? '').toLowerCase().replace(/\.$/, ''))
67  const bare = text.trim().toLowerCase().replace(/[/?#].*$/, '')
68  if (/^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/.test(bare)) out.add(bare)
69  return [...out]
70}
71
72export const matchesHost = (host: string, pattern: string): boolean =>
73  pattern.startsWith('*.') ? host.endsWith(pattern.slice(1)) : host === pattern
74
75/**
76 * Production wins on overlap: a host is let through only when an exact
77 * (non-wildcard) staging entry names it. Returns null when it isn't held.
78 */
79export function productionMatch(host: string, cfg: GuardConfig): { pattern: string; alsoStaging: string | undefined } | null {
80  const pattern = cfg.production.find(p => matchesHost(host, p))
81  if (pattern === undefined) return null
82  if (cfg.staging.some(p => !p.startsWith('*.') && p === host)) return null
83  return { pattern, alsoStaging: cfg.staging.find(p => matchesHost(host, p)) }
84}
85
86function domainFindings(hosts: string[], cfg: GuardConfig, verb: string): { rules: string[]; lines: string[]; notes: string[] } {
87  const rules: string[] = []
88  const lines: string[] = []
89  const notes: string[] = []
90  for (const host of hosts) {
91    const hit = productionMatch(host, cfg)
92    if (!hit) continue
93    rules.push(`production domain: ${host}`)
94    lines.push(`${verb} ${host} (matches ${hit.pattern})`)
95    if (hit.alsoStaging) notes.push(`${host} also matches staging ${hit.alsoStaging}; production wins.`)
96  }
97  return { rules, lines, notes }
98}
99
100// ---- Branches --------------------------------------------------------------
101
102const globRe = (glob: string): RegExp =>
103  new RegExp(`^${glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')}$`)
104
105export const isProtected = (branch: string | undefined, cfg: GuardConfig): boolean =>
106  branch !== undefined && cfg.branches.some(b => globRe(b).test(branch.replace(/^refs\/heads\//, '')))
107
108const absDir = (cwd: string, dir: string | null): string | null => {
109  if (dir === null) return cwd
110  if (dir === '-' || dir.startsWith('~')) return null // depends on the shell's state
111  return dir.startsWith('/') ? dir : `${cwd}/${dir}`
112}
113
114async function currentBranch(io: Io, dir: string | null): Promise<string | undefined> {
115  if (dir === null) return undefined
116  const run = await io.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], dir, 10000)
117  const out = run?.stdout.trim()
118  return run?.exitCode === 0 && out && out !== 'HEAD' ? out : undefined
119}
120
121async function gitLines(io: Io, dir: string | null, argv: string[]): Promise<string[] | null> {
122  if (dir === null) return null
123  const run = await io.run(['git', ...argv], dir, 15000)
124  return run?.exitCode === 0 ? run.stdout.split('\n').filter(l => l !== '') : null
125}
126
127// Options of git push that take a value, so the value isn't read as the remote.
128const PUSH_VALUE_OPTIONS = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
129
130function positional(args: string[], valued: Set<string>): string[] {
131  const out: string[] = []
132  for (let i = 0; i < args.length; i += 1) {
133    const a = args[i] ?? ''
134    if (a === '--') {
135      out.push(...args.slice(i + 1))
136      break
137    }
138    if (valued.has(a)) i += 1
139    else if (!a.startsWith('-')) out.push(a)
140  }
141  return out
142}
143
144type Hit = { rule: string; summary: string; lines: string[]; note?: string }
145
146async function gitFindings(io: Io, seg: Segment, cwd: string, cfg: GuardConfig, switched: { branch?: string }): Promise<Hit[]> {
147  const { dir, sub, rest } = gitParts(seg)
148  const where = absDir(cwd, dir)
149  const hits: Hit[] = []
150  const branchNow = async () => switched.branch ?? (await currentBranch(io, where))
151
152  if (sub === 'checkout' || sub === 'switch') {
153    const pos = positional(rest, new Set(['-b', '-B', '-c', '-C', '--orphan']))
154    const created = rest.findIndex(a => ['-b', '-B', '-c', '-C', '--orphan'].includes(a))
155    const target = created >= 0 ? rest[created + 1] : pos.length === 1 ? pos[0] : undefined
156    if (target && target !== '.' && target !== '--') switched.branch = target
157    return hits
158  }
159
160  if (sub === 'push') {
161    const flags = rest.filter(a => a.startsWith('-'))
162    const pos = positional(rest, PUSH_VALUE_OPTIONS)
163    const remote = pos[0] ?? 'origin'
164    const specs = pos.slice(1)
165    if (flags.some(f => f === '--all' || f === '--mirror' || f === '--branches')) {
166      hits.push({ rule: `push to protected branch: ${flags.find(f => ['--all', '--mirror', '--branches'].includes(f))} includes ${cfg.branches.join(', ')}`, summary: `push every local branch to ${remote}, ${cfg.branches.join(', ')} included`, lines: [] })
167      return hits
168    }
169    const deleting = flags.includes('-d') || flags.includes('--delete')
170    const targets: { src: string; dst: string }[] = []
171    if (specs.length === 0) {
172      if (flags.includes('--tags')) return hits
173      const b = await branchNow()
174      if (b === undefined) {
175        hits.push({ rule: 'push to protected branch: could not tell the current branch', summary: `push the current branch to ${remote}`, lines: [], note: "Couldn't read the current branch, so this is held to be safe." })
176        return hits
177      }
178      targets.push({ src: 'HEAD', dst: b })
179    }
180    for (const spec of specs) {
181      const s = spec.replace(/^\+/, '')
182      const [src = '', dst = ''] = s.includes(':') ? s.split(':') : [s, s]
183      const name = dst.replace(/^refs\/heads\//, '')
184      targets.push({ src: src || '(delete)', dst: name === 'HEAD' ? ((await branchNow()) ?? 'HEAD') : name })
185    }
186    for (const t of targets) {
187      if (!isProtected(t.dst, cfg)) continue
188      if (deleting || t.src === '(delete)') {
189        hits.push({ rule: `push to protected branch: deletes ${remote}/${t.dst}`, summary: `delete ${t.dst} on ${remote}`, lines: [] })
190        continue
191      }
192      const commits = await gitLines(io, where, ['log', '--oneline', '--no-decorate', `${remote}/${t.dst}..${t.src}`])
193      hits.push({
194        rule: `push to protected branch: ${remote}/${t.dst}`,
195        summary: commits === null ? `push ${t.src} to ${remote}/${t.dst}` : `push ${commits.length} ${commits.length === 1 ? 'commit' : 'commits'} to ${remote}/${t.dst}`,
196        lines: (commits ?? []).slice(0, LIST_MAX),
197        note: commits === null ? `No local copy of ${remote}/${t.dst}, so the commits can't be listed (no fetch is made).` : `Commits ${t.src} has that ${remote}/${t.dst} doesn't, as of the last fetch.`,
198      })
199    }
200    return hits
201  }
202
203  if (sub === 'merge' || sub === 'pull') {
204    if (rest.some(a => ['--abort', '--continue', '--quit', '--skip'].includes(a))) return hits
205    const b = await branchNow()
206    if (!isProtected(b, cfg)) return hits
207    const pos = positional(rest, new Set(['-m', '-s', '-X', '--strategy', '--strategy-option', '-F', '--file']))
208    const from = sub === 'merge' ? pos : pos.slice(1)
209    if (sub === 'pull' && (from.length === 0 || from.every(f => isProtected(f.replace(/^.*:/, ''), cfg)))) return hits // updating main from its own upstream
210    const source = from.join(' ') || '(upstream)'
211    const commits = sub === 'merge' && from.length === 1 ? await gitLines(io, where, ['log', '--oneline', '--no-decorate', `HEAD..${from[0]}`]) : null
212    hits.push({
213      rule: `merge into protected branch: ${b}`,
214      summary: commits === null ? `merge ${source} into ${b}` : `merge ${commits.length} ${commits.length === 1 ? 'commit' : 'commits'} from ${source} into ${b}`,
215      lines: (commits ?? []).slice(0, LIST_MAX),
216      note: '',
217    })
218  }
219  return hits
220}
221
222// ---- Deploys ---------------------------------------------------------------
223
224async function deployFindings(io: Io, seg: Segment, cwd: string): Promise<Hit[]> {
225  const { cmd, args } = seg
226  const where = absDir(cwd, seg.dir)
227  const place = seg.dir ?? 'the session folder'
228  const valueOf = (names: string[]): string | undefined => {
229    for (let i = 0; i < args.length; i += 1) {
230      const a = args[i] ?? ''
231      for (const n of names) {
232        if (a === n) return args[i + 1]
233        if (a.startsWith(`${n}=`)) return a.slice(n.length + 1)
234      }
235    }
236    return undefined
237  }
238
239  if ((cmd === 'vercel' || cmd === 'vc') && (args.includes('--prod') || args.includes('--production') || valueOf(['--target']) === 'production')) {
240    return [{ rule: 'deploy: vercel --prod', summary: `deploy ${place} to Vercel production`, lines: [], note: 'Production traffic is served from this build once it finishes.' }]
241  }
242  if ((cmd === 'terraform' || cmd === 'tofu') && args.includes('apply')) {
243    const tf = where ? (await io.list(where)).filter(n => n.endsWith('.tf')) : []
244    const plan = args.find(a => a.endsWith('.tfplan') || a.endsWith('.plan'))
245    return [{
246      rule: `deploy: ${cmd} apply`,
247      summary: plan ? `apply the saved plan ${plan}` : `apply Terraform changes in ${place}`,
248      lines: tf.slice(0, LIST_MAX),
249      note: args.some(a => a.startsWith('-auto-approve') || a.startsWith('--auto-approve')) ? "-auto-approve skips Terraform's own confirmation." : '',
250    }]
251  }
252  if (cmd === 'cdk' && args.includes('deploy')) {
253    const stacks = positional(args.slice(args.indexOf('deploy') + 1), new Set(['--profile', '-c', '--context', '--app', '-a', '--role-arn', '-r', '--parameters', '--outputs-file', '-O']))
254    return [{ rule: 'deploy: cdk deploy', summary: args.includes('--all') || stacks.length === 0 ? 'deploy every stack in the CDK app' : `deploy ${stacks.length} CDK ${stacks.length === 1 ? 'stack' : 'stacks'}`, lines: stacks.slice(0, LIST_MAX), note: args.includes('--require-approval') ? '' : '' }]
255  }
256  if (cmd === 'kubectl' && args.includes('apply')) {
257    const files: string[] = []
258    for (let i = 0; i < args.length; i += 1) {
259      const a = args[i] ?? ''
260      if (a === '-f' || a === '--filename' || a === '-k' || a === '--kustomize') files.push(args[i + 1] ?? '')
261      else if (a.startsWith('--filename=') || a.startsWith('-f=')) files.push(a.replace(/^[^=]*=/, ''))
262    }
263    let context = valueOf(['--context'])
264    if (!context) {
265      const run = await io.run(['kubectl', 'config', 'current-context'], where ?? cwd, 5000)
266      if (run?.exitCode === 0) context = run.stdout.trim()
267    }
268    const ns = valueOf(['-n', '--namespace'])
269    return [{ rule: 'deploy: kubectl apply', summary: `apply ${files.length ? files.join(', ') : 'manifests'} to ${context ? `context ${context}` : 'the current kubectl context'}${ns ? `, namespace ${ns}` : ''}`, lines: [], note: args.some(a => a.startsWith('--dry-run')) ? 'This has --dry-run, but is held anyway.' : '' }]
270  }
271  if ((cmd === 'docker' || cmd === 'podman') && args.includes('push')) {
272    const image = positional(args.slice(args.indexOf('push') + 1), new Set())[0] ?? '(image)'
273    return [{ rule: `deploy: ${cmd} push`, summary: `push image ${image} to its registry`, lines: [], note: '' }]
274  }
275  if (cmd === 'gh' && args[0] === 'pr' && args[1] === 'merge') {
276    return [{ rule: 'merge into protected branch: gh pr merge', summary: `merge pull request ${args[2] && !args[2].startsWith('-') ? args[2] : '(current branch)'} into its base branch`, lines: [], note: "The base branch isn't checked (that needs the network); it is usually main." }]
277  }
278  return []
279}
280
281// ---- Tools -----------------------------------------------------------------
282
283const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
284const WEBBY = /fetch|http|request|curl|browser|chrome|playwright|puppeteer|navigate|preview_start|web/i
285
286const strings = (value: unknown, out: string[] = []): string[] => {
287  if (typeof value === 'string') out.push(value)
288  else if (Array.isArray(value)) value.forEach(v => strings(v, out))
289  else if (value && typeof value === 'object') Object.values(value).forEach(v => strings(v, out))
290  return out
291}
292
293const finding = (rules: string[], command: string, summary: string, lines: string[], notes: string[]): Finding => ({
294  rules: rules.map(mask),
295  command: mask(command),
296  affects: {
297    summary: mask(summary),
298    lines: lines.slice(0, LIST_MAX).map(mask),
299    more: Math.max(0, lines.length - LIST_MAX),
300    note: mask(notes.filter(Boolean).join(' ')),
301  },
302})
303
304/** Whether the guard holds this call, and why. Null when it goes through. */
305export async function inspectGuard(io: Io, e: Record<string, unknown>, cfg: GuardConfig): Promise<Finding | null> {
306  const tool = String(e.tool)
307
308  if (tool === 'Bash') {
309    const command = String(e.command ?? '')
310    const rules: string[] = []
311    const lines: string[] = []
312    const notes: string[] = []
313    let summary = ''
314    const domains = domainFindings(hostsIn(command), cfg, 'reaches')
315    rules.push(...domains.rules)
316    lines.push(...domains.lines)
317    notes.push(...domains.notes)
318    if (domains.rules.length) summary = `reach production: ${domains.rules.map(r => r.replace(/^production domain: /, '')).join(', ')}`
319    const keys = liveStripeKeys(command)
320    if (keys.length) {
321      rules.push(`live Stripe key in the command: ${keys.join(', ')}`)
322      summary ||= 'use a live Stripe key: real charges and real customer data'
323    }
324    const cwd = await io.cwd()
325    const switched: { branch?: string } = {}
326    for (const seg of segments(command)) {
327      const hits = [...(seg.cmd === 'git' ? await gitFindings(io, seg, cwd, cfg, switched) : []), ...(await deployFindings(io, seg, cwd))]
328      for (const h of hits) {
329        rules.push(h.rule)
330        summary = summary ? `${summary}; ${h.summary}` : h.summary
331        lines.push(...h.lines)
332        if (h.note) notes.push(h.note)
333      }
334    }
335    return rules.length ? finding(rules, command, summary, lines, notes) : null
336  }
337
338  if (EDIT_TOOLS.has(tool)) {
339    const file = String(e.file_path ?? e.notebook_path ?? '')
340    const text = [e.content, e.new_string, e.new_source, ...(Array.isArray(e.edits) ? e.edits.map(x => (x as { new_string?: unknown }).new_string) : [])]
341      .filter(v => typeof v === 'string')
342      .join('\n')
343    const keys = liveStripeKeys(text)
344    if (!keys.length) return null
345    return finding([`live Stripe key in file content: ${keys.join(', ')}`], `${tool} ${file}`, `write a live Stripe key into ${file}`, [], ['Anything that reads this file gets live access to Stripe. Use a test key (sk_test_) or an environment variable.'])
346  }
347
348  if (tool === 'WebFetch' || (tool.startsWith('mcp__') && WEBBY.test(tool))) {
349    const values = strings(e)
350    const hosts = [...new Set(values.flatMap(urlHostsIn))]
351    const domains = domainFindings(hosts, cfg, tool === 'WebFetch' ? 'fetches' : 'opens')
352    if (!domains.rules.length) return null
353    const shown = typeof e.url === 'string' ? e.url : values.find(v => urlHostsIn(v).some(h => productionMatch(h, cfg))) ?? ''
354    return finding(domains.rules, `${tool} ${shown}`, `send a request to production: ${domains.rules.map(r => r.replace(/^production domain: /, '')).join(', ')}`, domains.lines, domains.notes)
355  }
356
357  return null
358}
359
360/**
361 * A synchronous judgment for when the guard itself failed: true when the call
362 * looks risky enough to refuse without the full check.
363 */
364export function looksRisky(e: Record<string, unknown>, cfg: GuardConfig): boolean {
365  const text = strings(e).join('\n')
366  if (liveStripeKeys(text).length) return true
367  if (String(e.tool) === 'Bash' || String(e.tool) === 'WebFetch' || WEBBY.test(String(e.tool))) {
368    if ([...hostsIn(text), ...urlHostsIn(text)].some(h => productionMatch(h, cfg))) return true
369  }
370  if (String(e.tool) === 'Bash') {
371    return /\bgit\b[^;&|\n]*\b(push|merge)\b|\b(vercel|vc)\b[^;&|\n]*--prod|\b(terraform|tofu)\b[^;&|\n]*\bapply\b|\bcdk\b[^;&|\n]*\bdeploy\b|\bkubectl\b[^;&|\n]*\bapply\b|\b(docker|podman)\b[^;&|\n]*\bpush\b/.test(text)
372  }
373  return false
374}
375
hooks/hold.tsx 224 lines
1// The one hold pane for the staging guard and blast radius. Holds are queued so
2// only one shows at a time. Each waits for Proceed or Cancel, and is refused
3// after 10 minutes, when the turn is interrupted, or when nobody can answer.
4//
5// The waiting approach follows blast-radius (claude-code-playground, Copyright
6// 2026 Anthropic PBC, Apache-2.0): a hook has 10 s of its own time, but time
7// inside a `$` call is free, so the loop waits on a short `sleep` process.
8
9import type { EngineInterface } from 'claude-code'
10
11import type { HoldOutcome, HoldView } from '../types'
12import type { Finding } from './guard'
13import { BRAND, HEADER_H, chipsSvg, holdBandSvg, holdHeaderSvg, idleBandSvg, labelSvg, pxOf } from './theme'
14
15export const HOLD_PANE = 'code-buddy-hold'
16export const HOLD_LIMIT_MS = 10 * 60 * 1000
17export const POLL_SECONDS = '0.25'
18
19export type Decision = 'proceed' | 'cancel'
20export type Pending = { view: HoldView; decision: Decision | null }
21
22// The calls being held, in order; the first is the one shown. Module state:
23// a reload drops the module and with it every hook that waits here.
24export const pending: Pending[] = []
25
26/** A press on Proceed or Cancel, for the hold drawn with this id. */
27export function decide(id: string, decision: Decision): void {
28  const p = pending.find(x => x.view.id === id)
29  if (p && p.decision === null) p.decision = decision
30}
31
32export const paneRows = (view: HoldView): number =>
33  Math.min(28, 14 + view.rules.length + view.affects.lines.length + (view.affects.more ? 1 : 0) + Math.ceil(view.command.length / 70))
34
35const WHY: Record<HoldOutcome, string> = {
36  waiting: 'it is still waiting',
37  proceeded: 'it was allowed',
38  cancelled: 'the user pressed Cancel',
39  timeout: 'nobody answered within 10 minutes',
40  interrupted: 'the turn was interrupted',
41  'no-one': 'this is a non-interactive run, so nobody could approve it',
42  error: 'Code Buddy hit an error while holding it',
43}
44
45export function refusal(outcome: HoldOutcome, found: Finding): string {
46  return `Code Buddy held this call and did not run it: ${WHY[outcome]}. Rule: ${found.rules.join('; ')}. It would: ${found.affects.summary}. Do not retry it unless the user asks you to.`
47}
48
49export const OUTCOME_LABEL: Record<HoldOutcome, string> = {
50  waiting: 'waiting',
51  proceeded: 'proceeded',
52  cancelled: 'cancelled',
53  timeout: 'refused: no answer in 10 min',
54  interrupted: 'refused: turn interrupted',
55  'no-one': 'refused: non-interactive',
56  error: 'refused: error',
57}
58
59// ---- Drawing ---------------------------------------------------------------
60// The desktop gets brand SVG rows (theme.ts) with real Buttons beside them; a
61// terminal gets the same hierarchy in coloured text.
62
63type Ui = ReturnType<EngineInterface['ui']['resolve']>
64type Surface = 'terminal' | 'desktop' | 'mobile' | 'vscode'
65
66const subtitleOf = (view: HoldView): string => {
67  const blast = view.rules.some(r => r.startsWith('destructive'))
68  const guard = view.rules.some(r => !r.startsWith('destructive'))
69  return `Code Buddy · ${[guard ? 'staging guard' : '', blast ? 'blast radius' : ''].filter(Boolean).join(' + ')} · needs your OK`
70}
71
72const titleOf = (view: HoldView): string => `Holding a ${view.tool} call${view.isSubagent ? ' from a subagent' : ''}`
73
74/** The hold pane: header, rules, command, what it would affect, Proceed and Cancel. */
75export function drawHold(ui: Ui, surface: Surface, view: HoldView, behind: number, columns: number) {
76  const { Box, Text, Button, Code } = ui
77  const a = view.affects
78  const buttons = (
79    <Box key="buttons" flexDirection="row" gap={2} marginTop={1}>
80      <Button key="proceed" label="Proceed" hotkey="1" onPress={() => decide(view.id, 'proceed')} />
81      <Button key="cancel" label="Cancel" hotkey="2" variant="primary" autoFocus onPress={() => decide(view.id, 'cancel')} />
82    </Box>
83  )
84  const footer = (
85    <Text key="footer" dimColor>
86      Refused automatically after 10 minutes without an answer.
87    </Text>
88  )
89  const lines = a.lines.length > 0 ? `${a.lines.join('\n')}${a.more > 0 ? `\n+ ${a.more} more` : ''}` : ''
90
91  if (surface === 'desktop' && 'Svg' in ui) {
92    const { Svg } = ui
93    const W = pxOf(columns, 16, 260)
94    const chips = chipsSvg(W, view.rules)
95    return (
96      <Box flexDirection="column" gap={1}>
97        <Svg key="head" source={holdHeaderSvg(W, titleOf(view), subtitleOf(view), behind)} alt={`${titleOf(view)}. ${subtitleOf(view)}`} width={W} height={HEADER_H} />
98        <Svg key="rules" source={chips.source} alt={`Rule: ${view.rules.join('; ')}`} width={W} height={chips.height} />
99        <Svg key="l-cmd" source={labelSvg(W, 'Command')} alt="Command" width={W} height={18} />
100        <Code key="cmd" source={view.command} language={view.tool === 'Bash' ? 'bash' : 'text'} wrap="wrap" />
101        <Svg key="l-would" source={labelSvg(W, 'Would')} alt="Would" width={W} height={18} />
102        <Text key="sum" bold color={BRAND.coral} wrap="wrap">
103          {a.summary}
104        </Text>
105        {lines !== '' && <Code key="lines" source={lines} language="text" wrap="truncate-end" />}
106        {a.note !== '' && (
107          <Text key="note" dimColor italic wrap="wrap">
108            {a.note}
109          </Text>
110        )}
111        {buttons}
112        {footer}
113      </Box>
114    )
115  }
116
117  return (
118    <Box flexDirection="column" paddingX={1}>
119      <Text key="head" wrap="truncate-end">
120        <Text backgroundColor={BRAND.royal} color={BRAND.white} bold>
121          {' ⏸ CODE BUDDY '}
122        </Text>
123        <Text bold color={BRAND.sky}>
124          {`  ${titleOf(view)}`}
125        </Text>
126        <Text dimColor>{behind > 0 ? `  · ${behind} more waiting` : ''}</Text>
127      </Text>
128      <Text key="sub" dimColor wrap="truncate-end">
129        {subtitleOf(view).replace('Code Buddy · ', '')}
130      </Text>
131      <Box key="rules" flexDirection="column" marginTop={1}>
132        {view.rules.map((r, i) => (
133          <Text key={`rule${i}`} wrap="wrap">
134            <Text color={BRAND.coral}>{'● '}</Text>
135            <Text bold>{r}</Text>
136          </Text>
137        ))}
138      </Box>
139      <Text key="l-cmd" bold color={BRAND.sky}>
140        {'\nCOMMAND'}
141      </Text>
142      <Code key="cmd" source={view.command} language={view.tool === 'Bash' ? 'bash' : 'text'} wrap="wrap" />
143      <Text key="l-would" bold color={BRAND.sky}>
144        {'\nWOULD'}
145      </Text>
146      <Text key="sum" bold color={BRAND.coral} wrap="wrap">
147        {a.summary}
148      </Text>
149      {a.lines.map((l, i) => (
150        <Text key={`l${i}`} wrap="truncate-end">
151          <Text color={BRAND.blue}>{'  │ '}</Text>
152          {l}
153        </Text>
154      ))}
155      {a.more > 0 && <Text key="more" dimColor>{`  │ + ${a.more} more`}</Text>}
156      {a.note !== '' && (
157        <Text key="note" dimColor italic wrap="wrap">
158          {a.note}
159        </Text>
160      )}
161      {buttons}
162      {footer}
163    </Box>
164  )
165}
166
167/** The band while a call waits: alert badge, the rule, the command, the buttons. */
168export function drawHoldBand(ui: Ui, surface: Surface, view: HoldView, behind: number, columns: number) {
169  const { Box, Text, Button } = ui
170  const rule = view.rules[0] ?? 'held'
171  const proceed = <Button key="band-proceed" label="Proceed" hotkey="1" onPress={() => decide(view.id, 'proceed')} />
172  const cancel = <Button key="band-cancel" label="Cancel" hotkey="2" variant="primary" autoFocus onPress={() => decide(view.id, 'cancel')} />
173  if (surface === 'desktop' && 'Svg' in ui) {
174    const { Svg } = ui
175    const W = pxOf(columns, 210, 200)
176    return (
177      <Box flexDirection="row" alignItems="center" gap={1}>
178        <Svg key="held" source={holdBandSvg(W, rule, view.command, behind)} alt={`Held${behind > 0 ? ` +${behind}` : ''}: ${rule}. ${view.command}`} width={W} height={24} />
179        {proceed}
180        {cancel}
181      </Box>
182    )
183  }
184  const room = Math.max(10, columns - rule.length - 40)
185  return (
186    <Box flexDirection="row" gap={1}>
187      <Text backgroundColor={BRAND.coral} color={BRAND.white} bold>
188        {` ⏸ HELD${behind > 0 ? ` +${behind}` : ''} `}
189      </Text>
190      <Text bold wrap="truncate-end">
191        {rule}
192      </Text>
193      <Text dimColor wrap="truncate-end">
194        {view.command.length > room ? `${view.command.slice(0, room - 1)}…` : view.command}
195      </Text>
196      {proceed}
197      {cancel}
198    </Box>
199  )
200}
201
202/** The idle band: the brand badge, guard state, how many calls were held. */
203export function drawIdleBand(ui: Ui, surface: Surface, isOn: boolean, held: number, columns: number, overBudget = '') {
204  const { Text } = ui
205  const words = `Code Buddy · guard ${isOn ? 'on' : 'off'} · ${held} held`
206  if (surface === 'desktop' && 'Svg' in ui) {
207    const { Svg } = ui
208    const W = Math.min(pxOf(columns, 0, 200), overBudget ? 520 : 360)
209    return <Svg key="idle" source={idleBandSvg(W, isOn, held, overBudget)} alt={overBudget ? `${words} · ${overBudget}` : words} width={W} height={24} />
210  }
211  return (
212    <Text wrap="truncate-end">
213      <Text backgroundColor={BRAND.royal} color={BRAND.white} bold>
214        {' ≋ Code Buddy '}
215      </Text>
216      <Text color={isOn ? BRAND.sky : undefined} dimColor={!isOn}>
217        {isOn ? '  ● guard on' : '  ○ guard off'}
218      </Text>
219      <Text dimColor>{`  ·  ${held} held`}</Text>
220      {overBudget ? <Text color={BRAND.coral} bold>{`  ·  ${overBudget}`}</Text> : null}
221    </Text>
222  )
223}
224
hooks/mask.ts 56 lines
1// Masks secrets so only their first 8 characters show. Everything Code Buddy
2// draws, stores or hands back to the model goes through `mask` first.
3
4const KEEP = 8
5const HIDDEN = '••••••'
6
7// Whole-token secrets: the match itself is the key.
8const TOKENS: RegExp[] = [
9  /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{6,}/g, // Stripe
10  /\bwhsec_[A-Za-z0-9]{10,}/g, // Stripe webhook secret
11  /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, // AWS access key id
12  /\bgh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub
13  /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
14  /\bglpat-[A-Za-z0-9_-]{16,}/g, // GitLab
15  /\bxox[abposr]-[A-Za-z0-9-]{10,}/g, // Slack
16  /\bsk-[A-Za-z0-9_-]{20,}/g, // Anthropic, OpenAI and similar
17  /\bAIza[0-9A-Za-z_-]{30,}/g, // Google API key
18  /\bnpm_[A-Za-z0-9]{30,}/g,
19  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, // JWT
20  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
21]
22
23// key = value: the value is the secret.
24const ASSIGNED =
25  /\b([A-Za-z0-9_.-]*(?:api[_-]?key|secret|token|passwd|password|private[_-]?key|access[_-]?key)[A-Za-z0-9_.-]*)(\s*[:=]\s*|\s+(?=["']))(["']?)([^\s"'`]{9,})/gi
26// --token xyz, --password=xyz
27const FLAG = /(--?(?:api[_-]?key|token|secret|password|passwd)(?:=|\s+))(["']?)([^\s"'`]{6,})/gi
28
29// Authorization: Bearer xyz, and user:password@ in URLs.
30const BEARER = /\b(Bearer|Basic|Token)\s+([A-Za-z0-9._~+/=-]{9,})/g
31const URL_PASSWORD = /(:\/\/[^\s:/@]+:)([^\s@/]{1,})@/g
32
33const cut = (secret: string): string => secret.slice(0, KEEP) + HIDDEN
34
35export function mask(text: string): string {
36  if (!text) return text
37  let out = text
38  for (const re of TOKENS) out = out.replace(re, m => cut(m))
39  out = out.replace(ASSIGNED, (_m, name: string, sep: string, quote: string, value: string) =>
40    value.endsWith(HIDDEN) ? `${name}${sep}${quote}${value}` : `${name}${sep}${quote}${cut(value)}`,
41  )
42  out = out.replace(FLAG, (_m, flag: string, quote: string, value: string) =>
43    value.endsWith(HIDDEN) ? `${flag}${quote}${value}` : `${flag}${quote}${cut(value)}`,
44  )
45  out = out.replace(BEARER, (_m, kind: string, value: string) => `${kind} ${cut(value)}`)
46  out = out.replace(URL_PASSWORD, (_m, head: string, pass: string) => `${head}${cut(pass)}@`)
47  return out
48}
49
50/** Live Stripe keys, for the guard: sk_live_, rk_live_, pk_live_. */
51export const LIVE_STRIPE = /\b(?:sk|rk|pk)_live_[A-Za-z0-9]{6,}/g
52
53export function liveStripeKeys(text: string): string[] {
54  return [...new Set(text.match(LIVE_STRIPE) ?? [])]
55}
56
hooks/replay.tsx 124 lines
1// Feature 3, Replay: the pane that steps through the last turn's edits with
2// Prev, Next and Close, and the band's hint. Drawing only: register.tsx owns
3// the state and hands in what the buttons do.
4//
5// The stepper follows replay-theater (claude-code-playground, Copyright 2026
6// Anthropic PBC, Apache-2.0): a strip of steps, one diff at a time, Prev /
7// Next / Close with hotkeys p, n and c. See NOTICE.
8
9import type { EngineInterface } from 'claude-code'
10
11import type { Replay } from '../types'
12import { BAND_ROW_H, BRAND, GREEN, REPLAY_HEADER_H, fileRowSvg, pxOf, replayBandSvg, replayHeaderSvg } from './theme'
13
14export const REPLAY_PANE = 'code-buddy-replay'
15
16type Ui = ReturnType<EngineInterface['ui']['resolve']>
17type Surface = 'terminal' | 'desktop' | 'mobile' | 'vscode'
18
19export type ReplayActions = { go: (index: number) => void; close: () => void }
20
21export const filesOf = (r: Replay): number => new Set(r.steps.map(s => s.file)).size
22
23export const replayRows = (r: Replay): number =>
24  Math.min(32, 9 + Math.max(...r.steps.map(s => s.diff.split('\n').length), 1))
25
26const countText = (r: Replay): string => {
27  const n = r.steps.length
28  const f = filesOf(r)
29  return `${n} ${n === 1 ? 'edit' : 'edits'} in ${f} ${f === 1 ? 'file' : 'files'}`
30}
31
32/** The replay pane: one step at a time. */
33export function drawReplay(ui: Ui, surface: Surface, r: Replay, columns: number, act: ReplayActions) {
34  const { Box, Text, Button, Code } = ui
35  const total = r.steps.length
36  const k = Math.max(0, Math.min(r.index, total - 1))
37  const step = r.steps[k]
38  if (step === undefined) return <Text dimColor>No edits to replay.</Text>
39  const meta = `${step.tool}${step.isNew ? ' · new file' : ''}${step.note ? ` · ${step.note}` : ''}`
40  const diff =
41    step.diff === '' ? (
42      <Text key="diff" dimColor>
43        (no line changes)
44      </Text>
45    ) : (
46      <Code key="diff" source={step.diff} format="diff" path={step.file} wrap="truncate-end" />
47    )
48  const buttons = (
49    <Box key="buttons" flexDirection="row" gap={2} marginTop={1}>
50      <Button key="prev" label="◀ Prev" hotkey="p" onPress={() => act.go(k - 1)} />
51      <Button key="next" label="Next ▶" hotkey="n" variant="primary" autoFocus onPress={() => act.go(k + 1)} />
52      <Button key="close" label="Close" hotkey="c" onPress={() => act.close()} />
53    </Box>
54  )
55
56  if (surface === 'desktop' && 'Svg' in ui) {
57    const { Svg } = ui
58    const W = pxOf(columns, 16, 260)
59    return (
60      <Box flexDirection="column" gap={1}>
61        <Svg key="head" source={replayHeaderSvg(W, k, total, filesOf(r))} alt={`Replay, step ${k + 1} of ${total}. ${countText(r)}.`} width={W} height={REPLAY_HEADER_H} />
62        <Svg key="file" source={fileRowSvg(W, step.file, meta, step.adds, step.dels)} alt={`${step.file}: ${meta}, ${step.adds} added, ${step.dels} removed`} width={W} height={34} />
63        {diff}
64        {buttons}
65      </Box>
66    )
67  }
68
69  const strip = r.steps.map((_, i) => (i === k ? '●' : i < k ? '•' : '·')).join(' ')
70  return (
71    <Box flexDirection="column" paddingX={1}>
72      <Text key="head" wrap="truncate-end">
73        <Text backgroundColor={BRAND.royal} color={BRAND.white} bold>
74          {' ▶ REPLAY '}
75        </Text>
76        <Text bold color={BRAND.sky}>{`  step ${k + 1} of ${total}`}</Text>
77        <Text dimColor>{`  ·  ${countText(r)}`}</Text>
78      </Text>
79      <Text key="strip" color={BRAND.sky} wrap="truncate-end">
80        {strip}
81      </Text>
82      <Text key="file" bold wrap="truncate-start">
83        {`\n${step.file}`}
84      </Text>
85      <Text key="meta" wrap="truncate-end">
86        <Text dimColor>{`${meta}   `}</Text>
87        <Text color={GREEN} bold>{`+${step.adds}`}</Text>
88        <Text>{' '}</Text>
89        <Text color={BRAND.coral} bold>{`−${step.dels}`}</Text>
90      </Text>
91      {diff}
92      {buttons}
93    </Box>
94  )
95}
96
97/** The band's hint after a turn with edits. */
98export function drawReplayBand(ui: Ui, surface: Surface, r: Replay, columns: number, open: () => void) {
99  const { Box, Text, Button } = ui
100  const button = <Button key="open-replay" label="Replay" hotkey="r" variant="primary" onPress={open} />
101  if (surface === 'desktop' && 'Svg' in ui) {
102    const { Svg } = ui
103    const W = Math.min(pxOf(columns, 110, 200), 460)
104    return (
105      <Box flexDirection="row" alignItems="center" gap={1}>
106        <Svg key="hint" source={replayBandSvg(W, r.steps.length, filesOf(r))} alt={`Replay ready: ${countText(r)}`} width={W} height={BAND_ROW_H} />
107        {button}
108      </Box>
109    )
110  }
111  return (
112    <Box flexDirection="row" gap={1}>
113      <Text wrap="truncate-end">
114        <Text backgroundColor={BRAND.royal} color={BRAND.white} bold>
115          {' ≋ Code Buddy '}
116        </Text>
117        <Text bold color={BRAND.sky}>{'  ▶ Replay ready'}</Text>
118        <Text dimColor>{`  ·  ${countText(r)}`}</Text>
119      </Text>
120      {button}
121    </Box>
122  )
123}
124
hooks/theme.ts 428 lines
1// Code Buddy's look: the Ruah Tech logo's blues (sampled from the official logo
2// at ruahtech.com.au), drawn as SVG on the desktop Code tab and as coloured
3// text in a terminal. The coral alert comes from the website's highlight.
4//
5// One SVG per row on the desktop, with Buttons beside it, follows savvy-progress
6// (claude-kit, MIT; see NOTICE): the desktop wraps sibling elements onto new
7// lines, so a row's text and shapes live in one drawing.
8
9export const BRAND = {
10  deep: '#012D96', // the logo's darkest royal blue
11  royal: '#0745A4',
12  blue: '#037AC1', // the logo's median blue
13  sky: '#1E99D5', // the logo's lightest blue
14  ink: '#0B1B3F',
15  tint: '#E5F2FB',
16  muted: '#6B7A90',
17  coral: '#F55C6D', // ruahtech.com.au highlight, for holds
18  white: '#FFFFFF',
19} as const
20
21export const FONT = "'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
22export const MONO = "'IBM Plex Mono',ui-monospace,'SF Mono',Menlo,Consolas,monospace"
23
24/** About 8 CSS px per reported column on the desktop. */
25export const pxOf = (columns: number, reserve = 0, min = 220): number =>
26  Math.max(min, Math.min(1600, Math.round((columns || 100) * 8 - reserve)))
27
28export const xml = (s: string): string =>
29  s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c)
30
31// Rough advance of UI text, in em: enough to size a slot and cut with an ellipsis.
32const charEm = (ch: string, mono: boolean): number =>
33  mono ? 0.61 : /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZmwW@%]/.test(ch) ? 0.72 : 0.56
34
35export const textWidth = (s: string, size: number, mono = false): number =>
36  [...s].reduce((w, ch) => w + charEm(ch, mono) * size, 0)
37
38export function fit(s: string, size: number, maxW: number, mono = false): string {
39  if (textWidth(s, size, mono) <= maxW) return s
40  let out = ''
41  for (const ch of s) {
42    if (textWidth(`${out}${ch}…`, size, mono) > maxW) break
43    out += ch
44  }
45  return `${out}…`
46}
47
48// Light and dark: the classes every drawing shares.
49const CSS = `<style>
50.t{fill:${BRAND.ink}}.m{fill:${BRAND.muted}}.chip{fill:${BRAND.tint}}.chipt{fill:${BRAND.royal}}.ln{stroke:#D9E8F4}.acc{fill:${BRAND.blue}}
51@media (prefers-color-scheme: dark){.t{fill:#EAF2FB}.m{fill:#93A3B8}.chip{fill:#0D2A4F}.chipt{fill:#7CC4EC}.ln{stroke:#1E3352}.acc{fill:${BRAND.sky}}}
52.pulse{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.35}}
53@media (prefers-reduced-motion: reduce){.pulse{animation:none}}
54</style>`
55
56const DEFS = `<defs>
57<linearGradient id="cb-brand" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${BRAND.deep}"/><stop offset=".55" stop-color="${BRAND.blue}"/><stop offset="1" stop-color="${BRAND.sky}"/></linearGradient>
58<linearGradient id="cb-glow" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${BRAND.sky}"/><stop offset="1" stop-color="${BRAND.white}" stop-opacity=".6"/></linearGradient>
59<linearGradient id="cb-alert" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${BRAND.coral}"/><stop offset="1" stop-color="#E2475A"/></linearGradient>
60</defs>`
61
62const svg = (W: number, H: number, body: string): string =>
63  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${DEFS}${body}</svg>`
64
65// The Ruah wave: a dot over four widening swooshes, 14 × 13, top-left at (x, y).
66const wave = (x: number, y: number, stroke: string): string =>
67  `<g transform="translate(${x},${y})" fill="none" stroke="${stroke}" stroke-width="1.5" stroke-linecap="round">` +
68  `<circle cx="8.2" cy="1" r=".9" fill="${stroke}" stroke="none"/>` +
69  '<path d="M5.6 3.4Q7.6 4.1 9.6 3.2"/><path d="M4 6Q7.2 7 10.8 5.7"/>' +
70  '<path d="M2.4 8.6Q6.9 10 12.2 8.3"/><path d="M.8 11.2Q6.7 13 13.6 10.9"/></g>'
71
72// Two pause bars, 10 × 12.
73const pauseMark = (x: number, y: number, fill: string): string =>
74  `<rect x="${x}" y="${y}" width="3.4" height="12" rx="1.2" fill="${fill}"/><rect x="${x + 6.2}" y="${y}" width="3.4" height="12" rx="1.2" fill="${fill}"/>`
75
76/** The "Code Buddy" badge; returns its markup and width. */
77function badge(x: number, y: number, h: number, label: string, alert: boolean): { body: string; w: number } {
78  const w = Math.round(30 + textWidth(label, 11.5) + 12)
79  const icon = alert ? pauseMark(x + 11, y + (h - 12) / 2, BRAND.white) : wave(x + 9, y + (h - 13) / 2, BRAND.white)
80  return {
81    w,
82    body: `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}" fill="url(#${alert ? 'cb-alert' : 'cb-brand'})"/>${icon}
83<text x="${x + 29}" y="${y + h / 2 + 4}" font-family="${FONT}" font-size="11.5" font-weight="600" letter-spacing=".2" fill="${BRAND.white}">${xml(label)}</text>`,
84  }
85}
86
87const BAND_H = 24
88
89/** Idle band: badge, guard state, how many calls were held. */
90export function idleBandSvg(W: number, isOn: boolean, held: number, overBudget = ''): string {
91  const b = badge(0, 2, 20, 'Code Buddy', false)
92  let x = b.w + 12
93  const dot = isOn
94    ? `<circle class="pulse" cx="${x + 4}" cy="${BAND_H / 2}" r="3.6" fill="${BRAND.sky}"/>`
95    : `<circle cx="${x + 4}" cy="${BAND_H / 2}" r="3.6" fill="none" stroke="${BRAND.muted}" stroke-width="1.4"/>`
96  x += 13
97  const guard = `<text class="${isOn ? 't' : 'm'}" x="${x}" y="${BAND_H / 2 + 4.5}" font-family="${FONT}" font-size="12.5" font-weight="500">Guard ${isOn ? 'on' : 'off'}</text>`
98  x += textWidth(`Guard ${isOn ? 'on' : 'off'}`, 12.5) + 14
99  const label = `${held} held`
100  const cw = Math.round(textWidth(label, 11.5) + 18)
101  const chip = `<rect class="chip" x="${x}" y="4" width="${cw}" height="16" rx="8"/><text class="chipt" x="${x + cw / 2}" y="${BAND_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11.5" font-weight="600">${xml(label)}</text>`
102  x += cw + 8
103  const ow = Math.round(textWidth(overBudget, 11.5) + 18)
104  const over = overBudget
105    ? `<rect x="${x}" y="4" width="${ow}" height="16" rx="8" fill="${BRAND.coral}" fill-opacity=".16"/><text x="${x + ow / 2}" y="${BAND_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11.5" font-weight="700" fill="${BRAND.coral}">${xml(overBudget)}</text>`
106    : ''
107  return svg(W, BAND_H, `${b.body}${dot}${guard}${chip}${over}`)
108}
109
110/** Band while a call waits: coral badge, the rule, the command. */
111export function holdBandSvg(W: number, rule: string, command: string, behind: number): string {
112  const b = badge(0, 2, 20, behind > 0 ? `Held +${behind}` : 'Held', true)
113  const x = b.w + 12
114  const ruleW = Math.min(W * 0.45, textWidth(rule, 12.5) + 4)
115  const ruleText = fit(rule, 12.5, ruleW)
116  const cx = x + textWidth(ruleText, 12.5) + 14
117  const cmd = fit(command, 12, Math.max(0, W - cx - 4), true)
118  return svg(
119    W,
120    BAND_H,
121    `${b.body}<text class="t" x="${x}" y="${BAND_H / 2 + 4.5}" font-family="${FONT}" font-size="12.5" font-weight="600">${xml(ruleText)}</text>
122<text class="m" x="${cx}" y="${BAND_H / 2 + 4.5}" font-family="${MONO}" font-size="12">${xml(cmd)}</text>`,
123  )
124}
125
126export const HEADER_H = 62
127
128/** The hold pane's header card. */
129export function holdHeaderSvg(W: number, title: string, subtitle: string, behind: number): string {
130  const pill = behind > 0 ? `+${behind} waiting` : ''
131  const pw = pill ? Math.round(textWidth(pill, 11.5) + 20) : 0
132  const textW = W - 64 - (pw ? pw + 16 : 12)
133  return svg(
134    W,
135    HEADER_H,
136    `<clipPath id="cb-card"><rect x="0" y="0" width="${W}" height="${HEADER_H}" rx="12"/></clipPath>
137<g clip-path="url(#cb-card)"><rect x="0" y="0" width="${W}" height="${HEADER_H}" fill="url(#cb-brand)"/>
138<rect x="0" y="${HEADER_H - 3}" width="${W}" height="3" fill="url(#cb-glow)" opacity=".9"/></g>
139<circle cx="31" cy="${HEADER_H / 2}" r="17" fill="${BRAND.white}" fill-opacity=".14"/>
140${pauseMark(26.2, HEADER_H / 2 - 6, BRAND.white)}
141${pill ? '' : wave(0, 0, BRAND.white).replace('<g transform="translate(0,0)"', `<g opacity=".22" transform="translate(${W - 58},${HEADER_H / 2 - 16}) scale(2.4)"`)}
142<text x="60" y="27" font-family="${FONT}" font-size="15" font-weight="600" fill="${BRAND.white}">${xml(fit(title, 15, textW))}</text>
143<text x="60" y="45" font-family="${FONT}" font-size="11.5" fill="${BRAND.white}" fill-opacity=".78">${xml(fit(subtitle, 11.5, textW))}</text>
144${pill ? `<rect x="${W - pw - 14}" y="${HEADER_H / 2 - 11}" width="${pw}" height="22" rx="11" fill="${BRAND.white}" fill-opacity=".16"/><text x="${W - 14 - pw / 2}" y="${HEADER_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11.5" font-weight="600" fill="${BRAND.white}">${xml(pill)}</text>` : ''}`,
145  )
146}
147
148/** Rule chips, wrapped onto as many rows as they need; returns markup and height. */
149export function chipsSvg(W: number, chips: string[]): { source: string; height: number } {
150  const ROW = 26
151  let x = 0
152  let row = 0
153  const parts: string[] = []
154  for (const chip of chips) {
155    const label = fit(chip, 12, W - 34)
156    const w = Math.round(textWidth(label, 12) + 30)
157    if (x > 0 && x + w > W) {
158      x = 0
159      row += 1
160    }
161    const y = row * ROW + 2
162    parts.push(`<rect class="chip" x="${x}" y="${y}" width="${w}" height="21" rx="10.5"/>
163<circle cx="${x + 11}" cy="${y + 10.5}" r="3" fill="${BRAND.coral}"/>
164<text class="chipt" x="${x + 20}" y="${y + 14.5}" font-family="${FONT}" font-size="12" font-weight="600">${xml(label)}</text>`)
165    x += w + 6
166  }
167  const height = (row + 1) * ROW
168  return { source: svg(W, height, parts.join('')), height }
169}
170
171/** A section label: small caps in the accent blue, with a hairline. */
172export function labelSvg(W: number, label: string): string {
173  const tw = textWidth(label.toUpperCase(), 10.5) + 8
174  return svg(
175    W,
176    18,
177    `<text class="acc" x="0" y="13" font-family="${FONT}" font-size="10.5" font-weight="700" letter-spacing=".9">${xml(label.toUpperCase())}</text>
178<line class="ln" x1="${tw + 4}" y1="9.5" x2="${W}" y2="9.5"/>`,
179  )
180}
181
182// ---- Replay ----------------------------------------------------------------
183
184export const GREEN = '#1FA971'
185
186/** Cuts the start of `s` (a path) to fit, with a leading ellipsis. */
187export function fitStart(s: string, size: number, maxW: number, mono = false): string {
188  if (textWidth(s, size, mono) <= maxW) return s
189  let out = ''
190  for (const ch of [...s].reverse()) {
191    if (textWidth(`…${ch}${out}`, size, mono) > maxW) break
192    out = ch + out
193  }
194  return `…${out}`
195}
196
197// A play triangle, about 10 × 12.
198const playMark = (x: number, y: number, fill: string): string =>
199  `<path transform="translate(${x},${y})" d="M1.2 0.6Q0 0 0 1.4V10.6Q0 12 1.2 11.4L9.6 6.8Q10.8 6 9.6 5.2Z" fill="${fill}"/>`
200
201export const REPLAY_HEADER_H = 54
202
203/** The replay pane's header card: step k of n, the edits, a dot per step. */
204export function replayHeaderSvg(W: number, k: number, total: number, files: number): string {
205  const H = REPLAY_HEADER_H
206  const title = `Replay · step ${k + 1} of ${total}`
207  const sub = `${total} ${total === 1 ? 'edit' : 'edits'} in ${files} ${files === 1 ? 'file' : 'files'} · last turn`
208  const DOT = 14
209  const dotsW = total * DOT
210  const showDots = dotsW <= W * 0.4
211  const right = showDots ? dotsW + 16 : 0
212  const dots = showDots
213    ? Array.from({ length: total }, (_, i) => {
214        const cx = W - 16 - dotsW + i * DOT + DOT / 2
215        return i === k
216          ? `<rect x="${cx - 6}" y="${H / 2 - 3.5}" width="12" height="7" rx="3.5" fill="${BRAND.white}"/>`
217          : `<circle cx="${cx}" cy="${H / 2}" r="3" fill="${BRAND.white}" fill-opacity="${i < k ? 0.6 : 0.3}"/>`
218      }).join('')
219    : ''
220  const textW = W - 58 - right - 8
221  return svg(
222    W,
223    H,
224    `<clipPath id="cb-rcard"><rect x="0" y="0" width="${W}" height="${H}" rx="12"/></clipPath>
225<g clip-path="url(#cb-rcard)"><rect x="0" y="0" width="${W}" height="${H}" fill="url(#cb-brand)"/>
226<rect x="0" y="${H - 3}" width="${Math.round((W * (k + 1)) / Math.max(1, total))}" height="3" fill="url(#cb-glow)"/></g>
227<circle cx="27" cy="${H / 2}" r="15" fill="${BRAND.white}" fill-opacity=".14"/>
228${playMark(23.5, H / 2 - 6, BRAND.white)}
229<text x="52" y="23" font-family="${FONT}" font-size="14.5" font-weight="600" fill="${BRAND.white}">${xml(fit(title, 14.5, textW))}</text>
230<text x="52" y="40" font-family="${FONT}" font-size="11.5" fill="${BRAND.white}" fill-opacity=".78">${xml(fit(sub, 11.5, textW))}</text>
231${dots}`,
232  )
233}
234
235/** One step's file row: the path, the tool, and +adds −dels pills. */
236export function fileRowSvg(W: number, file: string, meta: string, adds: number, dels: number): string {
237  const H = 34
238  const plus = `+${adds}`
239  const minus = `−${dels}`
240  const pw = Math.round(textWidth(plus, 11.5) + 16)
241  const mw = Math.round(textWidth(minus, 11.5) + 16)
242  const pills = `<rect x="${W - pw - mw - 6}" y="${H / 2 - 9}" width="${pw}" height="18" rx="9" fill="${GREEN}" fill-opacity=".14"/>
243<text x="${W - mw - 6 - pw / 2}" y="${H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11.5" font-weight="700" fill="${GREEN}">${plus}</text>
244<rect x="${W - mw}" y="${H / 2 - 9}" width="${mw}" height="18" rx="9" fill="${BRAND.coral}" fill-opacity=".14"/>
245<text x="${W - mw / 2}" y="${H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11.5" font-weight="700" fill="${BRAND.coral}">${minus}</text>`
246  const textW = W - 24 - pw - mw - 18
247  const doc = `<path class="acc" d="M3 2.5Q3 1 4.5 1H10l4 4v10.5Q14 17 12.5 17h-8Q3 17 3 15.5Z" fill-opacity=".16"/><path d="M10 1v4h4" fill="none" stroke="${BRAND.blue}" stroke-width="1.2"/><path d="M3 2.5Q3 1 4.5 1H10l4 4v10.5Q14 17 12.5 17h-8Q3 17 3 15.5Z" fill="none" stroke="${BRAND.blue}" stroke-width="1.2"/>`
248  return svg(
249    W,
250    H,
251    `<g transform="translate(0,${H / 2 - 9})">${doc}</g>
252<text class="t" x="24" y="15" font-family="${MONO}" font-size="12.5" font-weight="600">${xml(fitStart(file, 12.5, textW, true))}</text>
253<text class="m" x="24" y="30" font-family="${FONT}" font-size="11">${xml(fit(meta, 11, textW))}</text>
254${pills}`,
255  )
256}
257
258/** The band's replay hint: badge, "Replay ready", how many edits. */
259export function replayBandSvg(W: number, edits: number, files: number): string {
260  const b = badge(0, 2, 20, 'Code Buddy', false)
261  const x = b.w + 12
262  const label = 'Replay ready'
263  const lx = x + 16
264  const count = `${edits} ${edits === 1 ? 'edit' : 'edits'} in ${files} ${files === 1 ? 'file' : 'files'}`
265  return svg(
266    W,
267    BAND_H,
268    `${b.body}${playMark(x, BAND_H / 2 - 6, BRAND.blue).replace('fill="#037AC1"', 'class="acc"')}
269<text class="t" x="${lx}" y="${BAND_H / 2 + 4.5}" font-family="${FONT}" font-size="12.5" font-weight="600">${label}</text>
270<text class="m" x="${lx + textWidth(label, 12.5) + 12}" y="${BAND_H / 2 + 4.5}" font-family="${FONT}" font-size="12.5">${xml(count)}</text>`,
271  )
272}
273export const BAND_ROW_H = BAND_H
274
275// ---- Agents ----------------------------------------------------------------
276
277export type AgentRowView = {
278  status: 'running' | 'done' | 'failed' | 'stopped'
279  title: string
280  type: string
281  model: string
282  elapsed: string
283  tokens: string
284  cost: string
285  /** What a running subagent is doing now, e.g. "Read · src/api/routes.ts". */
286  activity?: string
287  /** "step 4 · 9 tools" */
288  progress?: string
289}
290
291/** Spend against the budget, for the panel's header and the band. */
292export type BudgetView = { limit: string; used: string; ratio: number; isOver: boolean }
293
294const STATUS_COLOR = { running: BRAND.sky, done: GREEN, failed: BRAND.coral, stopped: BRAND.muted } as const
295
296function statusMark(cx: number, cy: number, status: AgentRowView['status'], r = 9): string {
297  const c = STATUS_COLOR[status]
298  const s = r / 9
299  if (status === 'running') {
300    return `<circle class="pulse" cx="${cx}" cy="${cy}" r="${r}" fill="${c}" fill-opacity=".18"/><circle cx="${cx}" cy="${cy}" r="${4.5 * s}" fill="${c}"/>`
301  }
302  const ring = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${c}" fill-opacity=".14"/>`
303  if (status === 'done') return `${ring}<path d="M${cx - 4 * s} ${cy}l${2.8 * s} ${2.8 * s} ${5.2 * s}-${5.6 * s}" fill="none" stroke="${c}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>`
304  if (status === 'failed') return `${ring}<path d="M${cx - 3.4 * s} ${cy - 3.4 * s}l${6.8 * s} ${6.8 * s}M${cx + 3.4 * s} ${cy - 3.4 * s}l-${6.8 * s} ${6.8 * s}" stroke="${c}" stroke-width="1.9" stroke-linecap="round"/>`
305  return `${ring}<rect x="${cx - 3.2 * s}" y="${cy - 3.2 * s}" width="${6.4 * s}" height="${6.4 * s}" rx="1.2" fill="${c}"/>`
306}
307
308/** A progress bar: finished in the brand gradient (coral when over budget), running as a lighter, moving stripe. */
309function progressBar(x: number, y: number, w: number, h: number, done: number, running: number, total: number, onCard: boolean, isOver = false): string {
310  const fw = total ? Math.round((w * done) / total) : 0
311  const rw = total ? Math.round((w * Math.min(total - done, running)) / total) : 0
312  const track = onCard ? `fill="${BRAND.white}" fill-opacity=".22"` : 'class="chip"'
313  const fill = isOver ? 'fill="url(#cb-alert)"' : onCard ? `fill="${BRAND.white}"` : 'fill="url(#cb-brand)"'
314  const id = `cb-bar-${x}-${y}`
315  return `<clipPath id="${id}"><rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/></clipPath>
316<g clip-path="url(#${id})"><rect x="${x}" y="${y}" width="${w}" height="${h}" ${track}/>
317<rect x="${x}" y="${y}" width="${fw}" height="${h}" ${fill}/>
318${rw > 0 ? `<rect class="pulse" x="${x + fw}" y="${y}" width="${rw}" height="${h}" fill="${isOver ? BRAND.coral : onCard ? BRAND.white : BRAND.sky}" fill-opacity=".45"/>` : ''}</g>`
319}
320
321/** Band: badge, "Agents", the bar, finished of total and the cost so far (coral past the budget). */
322export function agentsBandSvg(W: number, done: number, running: number, total: number, cost: string, isOver = false): string {
323  const b = badge(0, 2, 20, 'Code Buddy', false)
324  let x = b.w + 12
325  const label = 'Agents'
326  const head = `<text class="t" x="${x}" y="${BAND_H / 2 + 4.5}" font-family="${FONT}" font-size="12.5" font-weight="600">${label}</text>`
327  x += textWidth(label, 12.5) + 12
328  const tail = `${done} / ${total} finished · ${cost}`
329  const over = isOver ? ' · over budget' : ''
330  const tw = textWidth(tail + over, 12.5) + 6
331  const barW = Math.max(60, Math.min(320, W - x - tw - 12))
332  const bar = progressBar(x, BAND_H / 2 - 4, barW, 8, done, running, total, false, isOver)
333  const tx = x + barW + 12
334  return svg(
335    W,
336    BAND_H,
337    `${b.body}${head}${bar}<text class="m" x="${tx}" y="${BAND_H / 2 + 4.5}" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${xml(tail)}${isOver ? `<tspan fill="${BRAND.coral}" font-weight="700">${over}</tspan>` : ''}</text>`,
338  )
339}
340
341export const AGENTS_HEADER_H = 96
342
343/** The agents pane's summary card: finished of total, a bar, cost, tokens, time and the budget. */
344export function agentsHeaderSvg(W: number, done: number, running: number, total: number, stats: [string, string][], budget?: BudgetView): string {
345  const H = AGENTS_HEADER_H
346  const right = `${done} of ${total} finished`
347  const tiles: [string, string, boolean][] = stats.map(([k, v]) => [k, v, false])
348  if (budget) tiles.push(['Budget', budget.isOver ? `Over ${budget.limit}` : `${Math.round(budget.ratio * 100)}% of ${budget.limit}`, budget.isOver])
349  const tw = (W - 32) / tiles.length
350  const tileSvg = tiles
351    .map(([k, v, alert], i) => {
352      const x = 16 + i * tw
353      const vw = textWidth(v, 15) + 14
354      return `<text x="${x}" y="${H - 30}" font-family="${FONT}" font-size="10.5" font-weight="600" letter-spacing=".8" fill="${BRAND.white}" fill-opacity=".7">${xml(k.toUpperCase())}</text>
355${alert ? `<rect x="${x - 7}" y="${H - 28}" width="${vw}" height="22" rx="11" fill="${BRAND.coral}"/>` : ''}
356<text x="${x}" y="${H - 12}" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums" fill="${BRAND.white}">${xml(fit(v, 15, tw - 12))}</text>`
357    })
358    .join('')
359  return svg(
360    W,
361    H,
362    `<clipPath id="cb-acard"><rect x="0" y="0" width="${W}" height="${H}" rx="12"/></clipPath>
363<g clip-path="url(#cb-acard)"><rect x="0" y="0" width="${W}" height="${H}" fill="url(#cb-brand)"/></g>
364${wave(0, 0, BRAND.white).replace('<g transform="translate(0,0)"', `<g transform="translate(16,10) scale(1.15)"`)}
365<text x="40" y="23" font-family="${FONT}" font-size="14.5" font-weight="600" fill="${BRAND.white}">Subagents</text>
366<text x="${W - 16}" y="23" text-anchor="end" font-family="${FONT}" font-size="12" font-weight="600" fill="${BRAND.white}" fill-opacity=".85">${xml(right)}</text>
367${progressBar(16, 34, W - 32, 7, done, running, total, true, budget?.isOver ?? false)}
368${tileSvg}`,
369  )
370}
371
372/** A full row is 54 px, 72 with a running subagent's activity line. */
373export const agentRowHeight = (a: AgentRowView): number => (a.status === 'running' && a.activity ? 72 : 54)
374
375/** One subagent: status, description, type and model, elapsed, tokens, cost, and what it is doing now. */
376export function agentRowSvg(W: number, a: AgentRowView): string {
377  const H = agentRowHeight(a)
378  const sw = textWidth(`${a.tokens} · ${a.cost}`, 11.5, true) + 4
379  const textW = W - 40 - sw - 16
380  const chip = fit(a.type, 10.5, 140)
381  const cw = Math.round(textWidth(chip, 10.5) + 14)
382  const live = a.status === 'running' && a.activity
383  const activity = live
384    ? `<path d="M40 ${H - 22}l5 3.5-5 3.5z" fill="${BRAND.sky}"/>
385<text x="51" y="${H - 14.5}" font-family="${MONO}" font-size="11.5" fill="${BRAND.sky}">${xml(fit(a.activity ?? '', 11.5, W - 51 - textWidth(a.progress ?? '', 11) - 16, true))}</text>
386<text class="m" x="${W}" y="${H - 14.5}" text-anchor="end" font-family="${FONT}" font-size="11">${xml(a.progress ?? '')}</text>`
387    : ''
388  const shimmer =
389    a.status === 'running'
390      ? `<rect class="pulse" x="40" y="${H - 4}" width="${W - 40}" height="2" rx="1" fill="${BRAND.sky}" fill-opacity=".6"/>`
391      : `<line class="ln" x1="40" y1="${H - 0.5}" x2="${W}" y2="${H - 0.5}"/>`
392  return svg(
393    W,
394    H,
395    `${statusMark(16, 20, a.status)}
396<text class="t" x="40" y="20" font-family="${FONT}" font-size="13" font-weight="600">${xml(fit(a.title, 13, textW))}</text>
397<rect class="chip" x="40" y="29" width="${cw}" height="16" rx="8"/>
398<text class="chipt" x="${40 + cw / 2}" y="40.5" text-anchor="middle" font-family="${FONT}" font-size="10.5" font-weight="600">${xml(chip)}</text>
399<text class="m" x="${40 + cw + 8}" y="41" font-family="${FONT}" font-size="11.5">${xml(fit(a.model, 11.5, Math.max(40, textW - cw - 8)))}</text>
400<text class="t" x="${W}" y="20" text-anchor="end" font-family="${MONO}" font-size="11.5">${xml(a.elapsed)}</text>
401<text class="m" x="${W}" y="41" text-anchor="end" font-family="${MONO}" font-size="11.5">${xml(`${a.tokens} · ${a.cost}`)}</text>
402${activity}
403${shimmer}`,
404  )
405}
406
407export const COMPACT_ROW_H = 26
408
409/** The compact row: one line per subagent. */
410export function compactAgentRowSvg(W: number, a: AgentRowView): string {
411  const H = COMPACT_ROW_H
412  const right = `${a.model} · ${a.elapsed} · ${a.cost}`
413  const rw = textWidth(right, 11.5, true) + 8
414  const middle = a.status === 'running' && a.activity ? a.activity : ''
415  const titleW = Math.max(60, (W - 24 - rw) * (middle ? 0.55 : 1))
416  const title = fit(a.title, 12.5, titleW)
417  const mx = 24 + textWidth(title, 12.5) + 12
418  return svg(
419    W,
420    H,
421    `${statusMark(9, H / 2, a.status, 7)}
422<text class="t" x="24" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="12.5" font-weight="600">${xml(title)}</text>
423${middle ? `<text x="${mx}" y="${H / 2 + 4.5}" font-family="${MONO}" font-size="11" fill="${BRAND.sky}">${xml(fit(middle, 11, Math.max(0, W - rw - mx - 8), true))}</text>` : ''}
424<text class="m" x="${W}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${MONO}" font-size="11.5">${xml(right)}</text>
425<line class="ln" x1="24" y1="${H - 0.5}" x2="${W}" y2="${H - 0.5}"/>`,
426  )
427}
428
hooks/shell.ts 123 lines
1// Portions derived from blast-radius (claude-code-playground), Copyright 2026
2// Anthropic PBC, licensed under the Apache License 2.0. Modified for Code Buddy:
3// ported to TypeScript, returns every segment instead of the first risky one,
4// and strips package-runner prefixes (npx, pnpm dlx, bunx). See NOTICE.
5//
6// Splits a shell command line into simple commands ("segments"), each with the
7// folder it runs in after any `cd`, `pushd`, `popd` or `( subshell )` earlier
8// on the line. Good enough to read flags and paths, not a full shell parser.
9
10export type Segment = {
11  /** The command's name, without a leading backslash or path. */
12  cmd: string
13  /** Its arguments, quotes removed. */
14  args: string[]
15  /** Folder it runs in relative to the session folder; null = the session folder. */
16  dir: string | null
17  /** The segment as written. */
18  raw: string
19}
20
21// sudo options that take a value, so the value isn't read as the command.
22const SUDO_VALUE_OPTIONS = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U'])
23// Words that can come before the real command without changing what it does.
24const PREFIXES = new Set(['command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!', 'xargs'])
25
26/** Splits one segment into words, honouring quotes. */
27export function tokenize(text: string): string[] {
28  const words: string[] = []
29  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
30  let m: RegExpExecArray | null
31  while ((m = re.exec(text)) !== null) {
32    words.push(m[1] ?? m[2] ?? m[3] ?? '')
33  }
34  return words
35}
36
37/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
38export function joinDir(dir: string | null, arg: string | undefined): string {
39  if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) {
40    return arg ?? '~'
41  }
42  return dir ? `${dir}/${arg}` : arg
43}
44
45function stripPrefixes(words: string[]): string[] {
46  const w = [...words]
47  while (w.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0] ?? '')) w.shift()
48  if (w[0] === 'sudo') {
49    w.shift()
50    while (w.length > 0 && (w[0] ?? '').startsWith('-')) {
51      const option = w.shift() ?? ''
52      if (SUDO_VALUE_OPTIONS.has(option)) w.shift()
53    }
54  }
55  while (w.length > 0 && (PREFIXES.has(w[0] ?? '') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0] ?? ''))) w.shift()
56  if (w[0] === 'nice') {
57    w.shift()
58    if (w.at(0) === '-n') w.splice(0, 2)
59    else if (/^-\d+$/.test(w[0] ?? '')) w.shift()
60  }
61  // Package runners: npx vercel, pnpm dlx cdk, bunx, yarn dlx, npm exec --.
62  if (w[0] === 'npx' || w[0] === 'bunx' || w[0] === 'pnpx') {
63    w.shift()
64    while ((w[0] ?? '').startsWith('-')) w.shift()
65  } else if ((w[0] === 'pnpm' || w[0] === 'yarn') && w[1] === 'dlx') {
66    w.splice(0, 2)
67  } else if (w[0] === 'npm' && w[1] === 'exec') {
68    w.splice(0, 2)
69    if (w.at(0) === '--') w.shift()
70  }
71  return w
72}
73
74/** Every simple command on the line, in order, with the folder it runs in. */
75export function segments(command: string): Segment[] {
76  const out: Segment[] = []
77  let dir: string | null = null
78  const scopes: (string | null)[] = [] // dir to restore when a ( subshell ) closes
79  const pushed: (string | null)[] = [] // pushd stack, for popd
80  for (const raw of command.split(/&&|\|\||;|\||\n/)) {
81    const opens = raw.match(/^\s*\(+/)?.[0].trim().length ?? 0
82    // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
83    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, '')
84    const closes = tail.match(/\)+\s*$/)?.[0].trim().length ?? 0
85    for (let k = 0; k < opens; k += 1) scopes.push(dir)
86    const words = stripPrefixes(tokenize(raw.trim().replace(/^[({]+\s*/, '').replace(/\s*[)}]+$/, '')))
87    const [first, ...args] = words
88    if (first !== undefined) {
89      const cmd = first.replace(/^\\/, '').replace(/^.*\//, '')
90      if (cmd === 'cd') {
91        dir = args[0] === '-' ? '-' : joinDir(dir, args[0])
92      } else if (cmd === 'pushd') {
93        pushed.push(dir)
94        dir = joinDir(dir, args[0])
95      } else if (cmd === 'popd') {
96        dir = pushed.length > 0 ? (pushed.pop() ?? null) : '-'
97      } else {
98        out.push({ cmd, args, dir, raw: raw.trim() })
99      }
100    }
101    for (let k = 0; k < closes && scopes.length > 0; k += 1) dir = scopes.pop() ?? null
102  }
103  return out
104}
105
106/** For `git`: the folder after `-C`, the subcommand and its arguments. */
107export function gitParts(seg: Segment): { dir: string | null; sub: string | undefined; rest: string[] } {
108  let dir = seg.dir
109  let i = 0
110  const a = seg.args
111  while (i < a.length && (a[i] ?? '').startsWith('-')) {
112    if (a[i] === '-C' && i + 1 < a.length) {
113      dir = joinDir(dir, a[i + 1])
114      i += 2
115    } else if (a[i] === '-c' && i + 1 < a.length) {
116      i += 2
117    } else {
118      i += 1
119    }
120  }
121  return { dir, sub: a[i], rest: a.slice(i + 1) }
122}
123
types/index.d.ts 112 lines
1/** What a held call would affect, as the hold pane shows it. Every text is masked. */
2export type Affects = {
3  summary: string
4  lines: string[]
5  more: number
6  note: string
7}
8
9/** One call waiting in the hold queue; the first one is the one shown. */
10export type HoldView = {
11  id: string
12  tool: string
13  /** Each rule that matched, e.g. "production domain: api.example.com". */
14  rules: string[]
15  /** The command, URL or file, masked. */
16  command: string
17  affects: Affects
18  /** Whether a subagent made the call. */
19  isSubagent: boolean
20}
21
22export type HoldOutcome =
23  | 'waiting'
24  | 'proceeded'
25  | 'cancelled'
26  | 'timeout'
27  | 'interrupted'
28  | 'no-one'
29  | 'error'
30
31/** One held call of this session, kept for /code-buddy. Masked. */
32export type HeldRecord = {
33  id: string
34  at: number
35  tool: string
36  rules: string[]
37  command: string
38  outcome: HoldOutcome
39}
40
41/** One recorded file edit. The diff is unified-diff text, masked. */
42export type ReplayStep = {
43  file: string
44  tool: string
45  note: string
46  diff: string
47  adds: number
48  dels: number
49  isNew: boolean
50}
51
52/** The last finished turn's edits, and where the stepper stands. */
53export type Replay = {
54  steps: ReplayStep[]
55  index: number
56  /** False until the person has opened it, so the band shows the hint. */
57  isSeen: boolean
58}
59
60export type AgentStatus = 'running' | 'done' | 'failed' | 'stopped'
61
62/** One subagent of the session. */
63export type AgentRun = {
64  id: string
65  agentId?: string
66  /** The subagent type, e.g. general-purpose or Explore. */
67  type: string
68  /** The Agent tool's description, masked. */
69  description: string
70  model: string
71  status: AgentStatus
72  startedAt: number
73  endedAt?: number
74  /** All tokens of its model requests: input, output, cache reads and writes. */
75  tokens: number
76  /** Estimated from list prices. */
77  costUsd: number
78  /** Model requests seen. */
79  steps: number
80  /** Tool calls it has made. */
81  tools?: number
82  /** What it is doing now, e.g. "Read · src/api/routes.ts" (masked); cleared when it ends. */
83  activity?: string
84  /** The main prompt it was started under; the band's bar counts the current one. */
85  batch: number
86}
87
88/** The agents panel's own state. */
89export type AgentsUi = {
90  isCompact: boolean
91  /** The batch the panel last opened itself for, so a close sticks for that prompt. */
92  autoOpenedBatch: number
93  /** The budget the toast last fired for (0: none yet), so it fires once per budget. */
94  budgetAlertedAt: number
95}
96
97declare module 'claude-code' {
98  interface PluginState {
99    'code-buddy': {
100      queue: HoldView[]
101      log: HeldRecord[]
102      /** Edits recorded so far in the turn that is running. */
103      recording: ReplayStep[]
104      replay: Replay | null
105      agents: AgentRun[]
106      /** Counts main prompts, so the bar knows which subagents are this prompt's. */
107      batch: number
108      agentsUi: AgentsUi
109    }
110  }
111}
112