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

A Claude Code mod that keeps an eye on what Claude does for you. One plugin, four features:
vercel --prod, terraform apply, cdk deploy, kubectl apply, docker push).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.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">
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.
| Setting | What it does | Default |
|---|---|---|
| Production domains | Comma-separated hosts. *.example.com matches every subdomain. Shell commands, web requests and browser calls that target one are held. | empty (rule off) |
| Staging domains | Hosts that are let through. Production wins on overlap: only an exact host listed here (no wildcard) is let through. | empty |
| Protected branch | Pushes to it and merges into it are held. Comma-separated; * matches within a name. | main |
| Guard | Turns holds on or off. Replay and the agents panel keep working either way. | on |
| Subagent budget | Estimated 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 panel | Opens 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
| Command | What it does |
|---|---|
/code-buddy | Status, settings, and this session's held calls with their outcomes. |
/code-buddy replay | Opens the replay of the last turn's edits. |
/code-buddy agents | Opens 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 config | Shows the settings, or changes one. |
claude -p).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.
git status, git log, git clean -n and du. Migrations are listed from files on disk, never by connecting to the database.secret=/token= values show only their first 8 characters, on screen and in anything stored.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.
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.
hooks/register.tsx 610 lines1// 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}
610hooks/agents.tsx 341 lines1// 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}
341hooks/blast.ts 325 lines1// 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
325hooks/diff.ts 151 lines1// 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
151hooks/guard.ts 375 lines1// 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}
375hooks/hold.tsx 224 lines1// 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}
224hooks/mask.ts 56 lines1// 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}
56hooks/replay.tsx 124 lines1// 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}
124hooks/theme.ts 428 lines1// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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}
428hooks/shell.ts 123 lines1// 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}
123types/index.d.ts 112 lines1/** 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