Separate Claude Code features in one plugin: live Files tree, Replay, Blast Radius, Agents, Reflect, Cache Tax, and an Inbox (Slack, Gmail, Calendar, GitHub…

beta
One Claude Code plugin with separate features you switch on or off from one pane (/sb), in the desktop Code tab and the terminal:
/sb-auto). Ask first by default; nothing ever sends without a saved rule, a named person and a clear request./sb-replay).Everything runs locally: no server, no cloud database; tokens live in your OS keychain.
| Path | What it is |
|---|---|
.claude-plugin/plugin.json, hooks/ | The plugin. hooks/register.tsx holds every engine call; hooks/core/, hooks/ui/ and hooks/features/ are pure: rules, views, pictures |
types/index.d.ts | The plugin's $.state contract |
watcher/ | Python: adapters, queue, rules, auth, the guarded send path (actions.py), the CLI the pane calls (cli.py) |
config/ | priorities.example.yaml, rules.example.yaml (phase 4), slack-app-manifest.yaml |
install.sh, scripts/ | The installer; bootstrap.sh builds or repairs .venv |
tests/ | pytest for Python; tests/*.test.tsx for claude plugin test |
docs/, design/ | The plan and the approved design (the handoff package) |
Beta (1.0.0-beta.2). Needs Python 3.10+, Claude Code 2.1.295 or newer, and function hooks switched on: add
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"underenvin~/.claude/settings.json.
From the marketplace, inside Claude Code:
/plugin marketplace add hassamali898/switchboard
/plugin install switchboard@switchboard
Or download the latest release and run the installer:
./install.sh
Then start a new Claude Code session (the desktop Code tab or claude in a terminal) and type /sb-inbox. Everything else happens in the inbox, with buttons.
Connector permission. Switchboard reads Gmail, Slack and Jira through Claude's connectors, and those calls follow your Claude Code permission rules. If a source says Needs your OK, press Allow: it puts the exact read-only tools in your prompt, and Claude adds them to permissions.allow after you confirm. Tools that send are never added for you. Stuck? Switchboard runs inside Claude Code, so ask Claude what you see; it can read the plugin's state.
The installer:
env block of ~/.claude/settings.json and keeps a backup next to it:CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, because function hooks are early access in Claude CodeCLAUDE_CODE_PLUGIN_DIRS, so every session loads the plugin from this folderCLAUDE_CODE_PLUGIN_DIR_WATCH=1, so open desktop sessions reload the plugin when its files changeIt is safe to run again. To remove the plugin:
./install.sh --uninstall
If the Python environment goes missing or pyproject.toml changes, the plugin rebuilds it by itself on the next session.
Everything is a button; no commands to type. The first session opens the inbox with a Connect your sources card, and Settings (top right of the inbox) shows the same choices any time. Each choice says why you would pick it and what to know about it.
Slack
| Choice | Why | Good to know |
|---|---|---|
| Connect Slack directly (recommended) | Messages show up instantly: Slack sends them to us, so nothing is checked over and over | A two-minute setup; some workspaces need an admin to approve the app |
| Claude's Slack connection | No Slack app to create | Not offered: it could only check repeatedly, which is slow and runs into Slack's limits |
The steps:
xoxp-…) and press Paste code 1.connections:write (xapp-…), copy it and press Paste code 2.The codes go from the clipboard straight into the OS keychain. They are never shown, and the clipboard is cleared afterwards.
Gmail
| Choice | Why | Good to know |
|---|---|---|
| Use Claude's Gmail connection (recommended) | Nothing to set up: it uses the Gmail you already connected to Claude | New mail shows up within about 15 s while you work, and every few minutes while you are away. It only works while Claude Code is open |
| Connect Gmail directly | Mail shows up within seconds, even while Claude Code is closed | A one-time Google Cloud setup (about 5 minutes) |
Try the demo shows the design's made-up messages. Nothing is sent.
Your accounts matter more than speed, so every source works far below its service's limits and stops by itself when anything looks wrong:
watcher/safety.py and hooks/lib/governor.ts.Type /sb-inbox to open the inbox. All Switchboard commands start with sb- so they never clash with your own.
| Command | What it does |
|---|---|
/sb-inbox | Open the inbox (everything else is buttons) |
/sb-reply <n> | Open item #n with reply options |
In Settings you can:
Keys in the focused pane:
| Keys | Action |
|---|---|
1–9 | Open an item |
a / b / c | Pick a reply option |
s | Send |
u | Undo |
i | Improve |
e | Edit |
d | Save draft |
k | Skip |
m | Mark all read |
o | Settings |
Esc | Back to the prompt |
.venv/bin/pip install -e '.[dev]' && .venv/bin/python -m pytest -q
npm install && npx tsc -p .
claude plugin validate .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .
Regenerate .claude/types/ with /plugin-types after a Claude Code update. docs/DEVELOPING.md lists the hooks-module rules this code depends on.
watcher/actions.py):.env is only a git-ignored fallback) and are redacted from every error message.hooks/register.tsx 4077 lines1// Switchboard's hooks module: the one file that talks to the engine. Every `$` call lives here: the
2// engine follows `$` only into functions declared at the top of this file, never across an import.
3// Features are pure modules that take data and return trees or decisions (BUILD_PLAN.md 5.1).
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register, RenderSurface } from 'claude-code'
7import {
8 AUTO_CLOSE_MS, PANE_COLUMNS, PANE_ID, bandPlan, clearAutoBlocks, pruneCards, dropCard, dropCardsOf, initialArbiter, paneClosed, pushCard, releasePane, requestPane, shouldAutoClose,
9 touchPane, type PaneDecision, type PaneView,
10} from './core/arbiter.ts'
11import {
12 COMMANDS, blockedReason, commandByName, featureById, isHidden, type CommandContext, type FeatureId,
13} from './core/features.ts'
14import { SETTINGS_KEY, defaultSettings, enabledFeatures, readSettings, withFeature, type Settings } from './core/store.ts'
15import { classify, type Risk, type RiskKind } from './features/blast/classify.ts'
16import {
17 MIGRATION_LISTERS, RM_SCRIPT, cantMeasure, cleanReport, dirtyReport, findDryRun, migrateReport, parseRm, pushReport, pushTarget,
18 rmReport, topmost, unmeasuredReport, type BlastReport,
19} from './features/blast/measure.ts'
20import {
21 CLOSE_AFTER_MS, WHY, clock, decide, denyText, hold, initialBlast, logged, pauseLength, release,
22 statusLine as blastStatus, waitLength, waitWords, withHeld, type BlastDecision, type BlastState,
23} from './features/blast/model.ts'
24import { blastDesktop, blastKey, blastRow, blastText, type BlastActions } from './features/blast/view.tsx'
25import {
26 bandOf as agentsBandOf, fromList, initialAgents, isRunning as agentsRunning, onComplete as onAgentDone, onSpawn, onUsage,
27 shouldAutoOpen as agentsShouldOpen, statusLine as agentsStatus, type AgentsState,
28} from './features/agents/model.ts'
29import { agentsBand, agentsDesktop, agentsText, type AgentsActions } from './features/agents/view.tsx'
30import {
31 ITEMS_KEY, PAUSED_KEY, CARD_TTL_MS, capped, composedRules, decide as decideRule, fromStore as reflectFromStore, initialReflect,
32 pendingHere, record as recordRule, reword as rewordRule, scopeOf, statusLine as reflectStatus, type ReflectScope, type ReflectState,
33} from './features/reflect/model.ts'
34import { bulletsOf, cleanRule, correction, isCandidate, remembered, withRule, type Verdict } from './features/reflect/rules.ts'
35import { reflectCard, type ReflectActions } from './features/reflect/view.tsx'
36import {
37 DEFAULT_WINDOW_MS, KEYS as CACHE_KEYS, MIN_PING_MS, PING_PROMPT, arm as armWarm, disarm as disarmWarm, fmtDuration, fmtTok,
38 fmtUsd, guard as cacheGuard, initialCache, isCold, nextTimer, onClear as onCacheClear, onCompact as onCacheCompact, onHold, onModelSwitch,
39 onPay, onPing, onRequest, onResponse, onResume, onTurnDone as onCacheTurnDone, parseDuration, pingCheck, statusCard as cacheCardText,
40 statusLine as cacheStatus, type CacheState, type CacheTtl,
41} from './features/cache/model.ts'
42import { cacheCard, type CacheActions } from './features/cache/view.tsx'
43import { dueCard, countdown, joinable, withMeetings, type Meeting } from './features/meeting/model.ts'
44import { meetingCard, type MeetingActions } from './features/meeting/view.tsx'
45import {
46 REMIND_MS, REPLY_SOURCES, SOURCE_NAME as SOURCE_NAME_OF, UNDO_MS, ago as inboxAgo, allowRequest, refusedRules, bellCount as inboxBell, canSend as canSendReply, dismiss as dismissItem, eventOf, initialInbox, markAllRead as markAllInboxRead,
47 markRead, needsCard, onEvent as onInboxEvent, remindLater, replyItem, replyKey, replyText, startReply, statusLine as inboxStatus, withReply, type InboxItem,
48 type InboxReply, type InboxState,
49} from './features/inbox/model.ts'
50import type { CardKind, InboxSetup } from '../types/index.d.ts'
51import { IMPROVE_PRESETS, draftUsd, draftsRequest, improveRequest, parseDrafts, parseImproved, threadOf, type ThreadMessage } from './features/inbox/drafts.ts'
52import {
53 draftArgs as gmailDraftArgs, errorText as mcpErrorText, field as gmailField, gmailId as gmailMessageId, isError as isMcpError, jiraIssueOf, jiraKey,
54 CONNECTORS_URL, payload as mcpPayload, replyArgs as gmailReplyArgs, threadHasMessage as gmailThreadHas, threadMessages as gmailThreadMessages,
55 transitionsOf as jiraTransitions,
56} from './features/inbox/connector.ts'
57import { inboxDesktop, inboxText, needsBand, type InboxActions } from './features/inbox/view.tsx'
58import { sourcesDesktop, sourcesText, type SourcesActions } from './features/inbox/sourcesView.tsx'
59import { controlDesktop, controlModel, controlText, type ControlHandlers } from './features/control/view.tsx'
60import { isReadOnly, readTargets } from './features/files/bash.ts'
61import { parseNames, parseNumstat, parseStatus } from './features/files/git.ts'
62import {
63 PRUNE, changedBetween, collapseAll, foldersToMeasure, foldersToReveal, initialFiles, matchFiles, onCommit, onEditDone,
64 onEditStart, onGitOperation, onRead, onReadFailed, onSearch, onShellEdits, onShellReads, onTurnEnd, onTurnStart,
65 parseDu, parseFind, projectName, statusLine, toggleFolder, treeRows, withChildren, withQuery, withRoot, withStatus,
66 type FileChange, type FileNode, type FilesState,
67} from './features/files/model.ts'
68import { basename, dirname, inside, join, posix, relative, resolve } from './features/files/paths.ts'
69import { chunkKeyOf } from './features/files/pictures.ts'
70import { filesDesktop, filesText, type FilesActions } from './features/files/view.tsx'
71import { parseGitDiff } from './features/replay/diff.ts'
72import {
73 REPLAY_KEY, addSteps, bashSteps, currentTurn, editStep, finishTurn, forStore, fromStore, gitSteps, goTo, headline,
74 initialReplay, notebookStep, onTurnStart as onReplayTurnStart, showTurn, statusLine as replayStatus, summaryOf,
75 type BashFileDiff, type EditRecord, type NotebookRecord, type Place, type ReplayState, type ReplayStep,
76} from './features/replay/model.ts'
77import { replayDesktop, replayKey, replayText, type ReplayActions } from './features/replay/view.tsx'
78import {
79 ASK_LABELS, AUTO_MODEL, CLASSIFY_MS, MAX_SEEN, addTask, candidates as autoCandidates, classifyText, countUse, initialAuto, isPlainAsk, logIt,
80 actsAlone, emailFor, markUndone, newTask, nextCheck, pauseAll, repoMatches, restore as restoreAuto, rollDay as rollAutoDay, running as autoRunning, setRule, setTask,
81 statusLine as autoStatus, stored as autoStored, saveRule as withSavedRule, taskOf as autoTaskOf, taskText as autoTaskText, verdictOf, findRule, deleteRule,
82 type AutoPair, type AutoRule, type AutoState, type AutoTask,
83} from './features/auto/model.ts'
84import { AGENT_SYSTEM, bookPrompt, outcomeOf, parseRule, reviewPrompt, ruleRequest, ruleUsd, toolsFor } from './features/auto/making.ts'
85import { autoDesktop, autoRow, autoText, checkBand, ruleBand, type AutoActions } from './features/auto/view.tsx'
86import type { Basic, Pictured, Presses } from './ui/click.tsx'
87
88/** The elements Files draws with on each kind of surface (both have the app's text field). */
89type TextElements = Parameters<typeof filesText>[0]
90type DesktopElements = Parameters<typeof filesDesktop>[0]
91
92const PLUGIN = 'switchboard'
93
94const arbiter = atom({ plugin: 'switchboard', key: 'arbiter' } as const, initialArbiter())
95const prefs = atom({ plugin: 'switchboard', key: 'prefs' } as const, defaultSettings())
96const statuses = atom({ plugin: 'switchboard', key: 'statuses' } as const, {})
97const hover = atom({ plugin: 'switchboard', key: 'hover' } as const, '')
98const files = atom({ plugin: 'switchboard', key: 'files' } as const, initialFiles())
99const replay = atom({ plugin: 'switchboard', key: 'replay' } as const, initialReplay())
100const blast = atom({ plugin: 'switchboard', key: 'blast' } as const, initialBlast())
101const agents = atom({ plugin: 'switchboard', key: 'agents' } as const, initialAgents())
102const reflect = atom({ plugin: 'switchboard', key: 'reflect' } as const, initialReflect())
103const cache = atom({ plugin: 'switchboard', key: 'cache' } as const, initialCache())
104const inbox = atom({ plugin: 'switchboard', key: 'inbox' } as const, initialInbox())
105const auto = atom({ plugin: 'switchboard', key: 'auto' } as const, initialAuto())
106/** The band's own clock: the meeting card's countdown redraws the band alone, never the pane. */
107const tick = atom({ plugin: 'switchboard', key: 'tick' } as const, 0)
108
109/** What each clickable picture part does, by key; filled while drawing, run on a Client's `{ press }`. */
110const presses: Presses = new Map()
111/** The same for the band above the prompt, drawn on its own schedule. */
112const bandPresses: Presses = new Map()
113
114const CONTROL_VIEW: PaneView = { owner: 'control', view: 'main', title: 'Switchboard', reason: 'user' }
115const agentsView = (title: string, reason: PaneView['reason'] = 'user'): PaneView =>
116 ({ owner: 'agents', view: 'main', title: `Agents${title ? `: ${title}` : ''}`, reason })
117const BLAST_VIEW: PaneView = { owner: 'blast', view: 'main', title: 'Blast Radius', reason: 'hold' }
118const REPLAY_VIEW: PaneView = { owner: 'replay', view: 'main', title: 'Replay', reason: 'user' }
119const filesView = (name: string, reason: PaneView['reason'] = 'user'): PaneView =>
120 ({ owner: 'files', view: 'main', title: `Files: ${name}`, reason })
121
122/** This window's facts, read at each session start (module variables start over on a reload). */
123const here: {
124 surfaces: readonly RenderSurface[]; sessionId: string; isDev: boolean; cwd: string; home: string
125 /** When a background task's notification last arrived: the turn it starts keeps the marks. */
126 notifiedAt: number
127 /** Claude's last answer: context for Reflect's check (never stored). */
128 lastAnswer: string
129 /** The main turn running now, for Replay: edits after it ended are "after the turn". */
130 turn: { isRunning: boolean; id: string; prompt: string }
131} = { surfaces: [], sessionId: '', isDev: false, cwd: '', home: '', notifiedAt: 0, lastAnswer: '', turn: { isRunning: false, id: '', prompt: '' } }
132
133/** Development only (see `.sb-dev`): what the last draws were, so the live app can be checked. */
134const devDraws: Record<string, unknown>[] = []
135/** The same for everything but draws (loads, polls, errors). */
136const devEvents: Record<string, unknown>[] = []
137
138async function devLog($: EngineInterface, entry: Record<string, unknown>): Promise<void> {
139 if (!here.isDev) return
140 const list = entry.event === 'draw' ? devDraws : devEvents
141 list.push({ at: new Date(await $.clock.now()).toISOString(), ...entry })
142 if (list.length > 60) list.splice(0, list.length - 60)
143 await $.fs.write(`${$.plugin.root}/.sb-dev-log-${here.sessionId.slice(0, 8)}.json`, JSON.stringify({ events: devEvents, draws: devDraws.slice(-10) }, null, 1))
144}
145
146const commandContext = (current: Settings): CommandContext => ({
147 enabled: enabledFeatures(current),
148 isInboxSession: !!current.inboxSessionId && current.inboxSessionId === here.sessionId,
149 surfaces: here.surfaces,
150})
151
152/**
153 * `/clear` gives the session a new id without a new `session.start`. The Inbox role moves with it, and
154 * Files' marks start over (they belong to the old conversation).
155 */
156async function sessionCleared($: EngineInterface): Promise<void> {
157 const before = here.sessionId
158 here.sessionId = await $.session.id()
159 if (here.sessionId === before) return
160 const stored = readSettings(await $.store.get(SETTINGS_KEY).catch(() => undefined))
161 if (stored.inboxSessionId === before) await savePrefs($, current => ({ ...current, inboxSessionId: here.sessionId }))
162 await setFiles($, state => ({ ...state, sessionId: here.sessionId }))
163 // Background commands of the old conversation won't be reported to this one: their marks go.
164 for (const job of background.values()) dropMark($, job.mark)
165 background.clear()
166}
167
168/** Asks the arbiter for the pane and opens it when granted. True when it shows now. */
169async function showPane($: EngineInterface, view: PaneView): Promise<boolean> {
170 const out: { decision?: PaneDecision } = {}
171 await update($, arbiter, state => {
172 const result = requestPane(state, view)
173 out.decision = result.decision
174 return result.state
175 })
176 if (out.decision?.kind !== 'show') return false
177 await $.ui.open({
178 id: PANE_ID,
179 title: view.title,
180 columns: PANE_COLUMNS,
181 // A hold acts as a dialog. `focus` is a request: refused while you type or a dialog is up.
182 ...(view.reason === 'hold' ? { focus: true, closeOnEscape: true, holdToasts: true } as const : {}),
183 })
184 return true
185}
186
187/** What each feature puts on screen: switched off, its cards and its pane go at once. */
188const FEATURE_PARTS: Partial<Record<FeatureId, { cards: CardKind[]; panes: string[] }>> = {
189 inbox: { cards: ['needs', 'check', 'rule', 'meeting'], panes: ['inbox', 'auto'] },
190 cache: { cards: ['cache'], panes: [] },
191 reflect: { cards: ['reflect'], panes: [] },
192 files: { cards: [], panes: ['files'] },
193 agents: { cards: [], panes: ['agents'] },
194 replay: { cards: [], panes: ['replay'] },
195}
196
197/** Flips a feature, saves it, and refreshes the command menu (hidden commands follow). */
198async function toggleFeature($: EngineInterface, id: FeatureId): Promise<void> {
199 const saved = await savePrefs($, state => withFeature(state, id, !state.features[id]))
200 await update($, arbiter, touchPane)
201 $.ui.invalidate('command.describe')
202 const isOn = saved.features[id]
203 if (!isOn) {
204 const parts = FEATURE_PARTS[id]
205 if (parts) {
206 await update($, arbiter, state => dropCardsOf(state, parts.cards))
207 for (const owner of parts.panes) await releaseAuto($, owner)
208 }
209 }
210 if (id === 'inbox') {
211 if (isOn) {
212 await inboxLoad($)
213 await autoLoad($)
214 } else {
215 stopInbox()
216 await setInbox($, state => ({ ...state, mode: 'off' }))
217 }
218 }
219 if (id === 'cache' && !isOn) {
220 // A message Cache Tax was holding goes back in your prompt box, never lost and never sent unasked.
221 const held = (await read($, cache)).held
222 await setCache($, state => ({ ...state, held: null }))
223 if (held) await $.prompt.fill({ text: held.text })
224 }
225 if (id === 'agents' && !isOn) await setAgents($, () => initialAgents())
226}
227
228/**
229 * Saves a settings change. Read from the store first, not this window's copy: another window may
230 * have changed something since (the Inbox session, a feature switch), and this must not undo it.
231 */
232async function savePrefs($: EngineInterface, change: (current: Settings) => Settings): Promise<Settings> {
233 const stored = readSettings(await $.store.get(SETTINGS_KEY).catch(() => undefined))
234 const saved = change(stored)
235 await $.store.set(SETTINGS_KEY, saved)
236 await update($, prefs, () => saved)
237 return saved
238}
239
240// ---- Files ------------------------------------------------------------------------------------------
241
242/** Debounced git refresh (a burst of edits reads git once), the search, and the automatic close. */
243const filesTimers: { sync?: { cancel: () => void }; search?: { cancel: () => void }; autoClose?: { cancel: () => void } } = {}
244
245/** Every file under the root, for "Find a file": listed once per root, again after edits. */
246const searchIndex: { root: string; files: string[]; isStale: boolean } = { root: '', files: [], isStale: true }
247
248/** Bash commands still running in the background: what to compare against when each one ends. */
249const background = new Map<string, { mark: string; before: Record<string, FileChange>; agent?: string }>()
250
251/** Subagent types by id, for "by agent <type>". */
252const agentTypes = new Map<string, string>()
253
254/** Numbers the `find -newer` mark files. */
255const marks = { count: 0 }
256
257/** Updates the tree and the one line the Switchboard pane shows for it. */
258async function setFiles($: EngineInterface, change: (state: FilesState) => FilesState): Promise<FilesState> {
259 const out: { state?: FilesState } = {}
260 await update($, files, state => (out.state = change(state)))
261 const line = out.state ? statusLine(out.state) : 'watching'
262 await update($, statuses, current => (current.files === line ? current : { ...current, files: line }))
263 return out.state ?? initialFiles()
264}
265
266async function isFilesOn($: EngineInterface): Promise<boolean> {
267 return (await read($, prefs)).features.files
268}
269
270async function git($: EngineInterface, root: string, args: string[], timeoutMs = 20_000) {
271 return $.process.run(['git', '--no-optional-locks', '-C', root, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
272}
273
274/** One folder's children (`folder` relative to the root, '' for the root); null when it can't be read. */
275async function listFolder($: EngineInterface, root: string, folder: string): Promise<FileNode[] | null> {
276 try {
277 const entries = (await $.fs.list(folder ? join(root, folder) : root)).slice(0, 2_000)
278 return await Promise.all(entries.map(async entry => {
279 const path = folder ? `${folder}/${entry.name}` : entry.name
280 let isDir = entry.kind === 'dir'
281 if (entry.isLink) isDir = (await $.fs.stat(join(root, path)).catch(() => null))?.kind === 'dir'
282 return { path, name: entry.name, isDir, isHidden: entry.name.startsWith('.'), mtimeMs: entry.mtimeMs, size: entry.size }
283 }))
284 } catch {
285 return null
286 }
287}
288
289async function loadFolders($: EngineInterface, folders: readonly string[]): Promise<void> {
290 const root = (await read($, files)).root
291 if (!root) return
292 const listed = await Promise.all(folders.map(async folder => [folder, await listFolder($, root, folder)] as const))
293 await setFiles($, state => {
294 if (state.root !== root) return state
295 let next = state
296 for (const [folder, children] of listed) {
297 if (children) next = withChildren(next, folder, children)
298 else if (folder === '') next = { ...next, error: `Can't read ${root}.` }
299 }
300 return next
301 })
302}
303
304/** Reads git: status and line counts (paths relative to the root), or marks the folder as not git. */
305async function syncGit($: EngineInterface): Promise<void> {
306 const root = (await read($, files)).root
307 if (!root) return
308 const top = await git($, root, ['rev-parse', '--show-prefix'], 5_000).catch(() => null)
309 if (!top || top.exitCode !== 0) {
310 await setFiles($, state => (state.root === root ? { ...state, isGit: false, status: {}, diff: {} } : state))
311 return
312 }
313 const prefix = top.stdout.trim()
314 const status = await git($, root, ['status', '--porcelain=v1', '-z', '-b', '--untracked-files=normal', '--', '.'])
315 const parsed = parseStatus(status.stdout, prefix)
316 const numstat = await git($, root, parsed.branch?.hasCommits === false
317 ? ['diff', '--numstat', '-z', '--cached', '--', '.']
318 : ['diff', '--numstat', '-z', 'HEAD', '--', '.'])
319 await setFiles($, state => (state.root === root ? { ...withStatus(state, parsed, parseNumstat(numstat.stdout, prefix)), prefix } : state))
320}
321
322function scheduleSync($: EngineInterface, folders: readonly string[] = []): void {
323 searchIndex.isStale = true
324 filesTimers.sync?.cancel()
325 filesTimers.sync = $.clock.after(300, () => {
326 void (async () => {
327 if (folders.length) await loadFolders($, folders)
328 await syncGit($)
329 })().catch(() => undefined)
330 })
331}
332
333/** Shows `root` in the tree from scratch: its top folder, then git. */
334async function resetRoot($: EngineInterface, root: string, isPinned: boolean): Promise<void> {
335 searchIndex.isStale = true
336 await setFiles($, state => withRoot(state, root, isPinned))
337 await loadFolders($, [''])
338 await syncGit($)
339}
340
341/** Reloads every folder the tree has open, then git. */
342async function refreshFiles($: EngineInterface): Promise<void> {
343 const state = await read($, files)
344 await loadFolders($, ['', ...state.expanded])
345 await syncGit($)
346 if (state.showSizes) {
347 await setFiles($, current => ({ ...current, sizes: {} }))
348 await measureSizes($)
349 }
350}
351
352/** Opens a folder (listing it the first time) or closes it. */
353async function toggleFilesFolder($: EngineInterface, folder: string): Promise<void> {
354 const state = await setFiles($, current => toggleFolder(current, folder))
355 if (state.expanded.includes(folder) && !state.nodes[folder]) await loadFolders($, [folder])
356 await measureSizes($)
357}
358
359/** Show sizes: `du` measures the folders on screen it hasn't yet (files carry their own size). */
360async function measureSizes($: EngineInterface): Promise<void> {
361 const state = await read($, files)
362 const folders = foldersToMeasure(state)
363 if (!state.root || !folders.length) return
364 // `du` doesn't follow symlinked folders, as the plan asks.
365 const run = await $.process.run(['du', '-sk', '--', ...folders], { cwd: state.root, timeoutMs: 8_000 }).catch(() => null)
366 const found = run ? parseDu(run.stdout) : {}
367 // A folder `du` couldn't measure shows no size rather than asking again.
368 for (const folder of folders) if (found[folder] === undefined) found[folder] = -2
369 await setFiles($, current => (current.root === state.root ? { ...current, sizes: { ...current.sizes, ...found } } : current))
370}
371
372/** Every file under the root (git's list, or `find` outside git), at most 50,000. */
373async function allFiles($: EngineInterface): Promise<string[]> {
374 const state = await read($, files)
375 if (searchIndex.root === state.root && !searchIndex.isStale) return searchIndex.files
376 let list: string[] = []
377 if (state.isGit) {
378 const run = await git($, state.root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.'], 10_000).catch(() => null)
379 list = run ? run.stdout.split('\0').filter(Boolean) : []
380 } else if (state.root !== here.home && state.root !== '/') {
381 const prune = ['(', ...PRUNE.flatMap((name, index) => (index ? ['-o', '-name', name] : ['-name', name])), ')', '-prune', '-o']
382 const run = await $.process.run(['find', '-H', state.root, '-maxdepth', '6', ...prune, '-type', 'f', '-print0'], { timeoutMs: 8_000 })
383 .catch(() => null)
384 list = run ? parseFind(run.stdout, state.root) : []
385 }
386 Object.assign(searchIndex, { root: state.root, files: list.slice(0, 50_000), isStale: false })
387 return searchIndex.files
388}
389
390/** "Find a file": the text changed. The search runs once typing pauses. */
391function searchFiles($: EngineInterface, query: string): void {
392 filesTimers.search?.cancel()
393 void setFiles($, state => {
394 const next = withQuery(state, query)
395 return query.trim() ? { ...next, search: { ...next.search, isLoading: true } } : next
396 }).catch(() => undefined)
397 if (!query.trim()) return
398 filesTimers.search = $.clock.after(150, () => {
399 void (async () => {
400 const found = matchFiles(await allFiles($), query)
401 await setFiles($, state => (state.search.query === query ? { ...state, search: { query, ...found, isLoading: false } } : state))
402 })().catch(() => undefined)
403 })
404}
405
406/** A subagent's type ("Explore"), for the "by agent" tag; undefined for the main conversation. */
407async function agentType($: EngineInterface, agentId: string | undefined): Promise<string | undefined> {
408 if (!agentId) return undefined
409 if (!agentTypes.has(agentId)) {
410 for (const agent of await $.agent.list().catch(() => [])) agentTypes.set(agent.id, agent.type)
411 // One the list doesn't name (a teammate, an Auto agent) is asked about once, not on every call.
412 if (!agentTypes.has(agentId)) agentTypes.set(agentId, 'subagent')
413 if (agentTypes.size > 500) agentTypes.delete(agentTypes.keys().next().value!)
414 }
415 return agentTypes.get(agentId)
416}
417
418/** A file whose mtime marks "now", so `find -newer` can name what a command changes after it. */
419async function newMark($: EngineInterface): Promise<string> {
420 const dir = ((await $.env.get('TMPDIR').catch(() => undefined)) || '/tmp').replace(/\/+$/, '')
421 const mark = `${dir}/switchboard-${here.sessionId || 'session'}-${++marks.count}.mark`
422 const run = await $.process.run(['touch', mark], { timeoutMs: 3_000 }).catch(() => null)
423 return run?.exitCode === 0 ? mark : ''
424}
425
426function dropMark($: EngineInterface, mark: string): void {
427 if (mark) void $.process.run(['rm', '-f', mark], { timeoutMs: 3_000 }).catch(() => undefined)
428}
429
430/**
431 * What a shell command changed when its result names no files: what git now sees differently, plus
432 * files under the root newer than the mark (outside git, the mark alone). Absolute paths, at most 200.
433 */
434async function shellEdits($: EngineInterface, mark: string, before: Record<string, FileChange>): Promise<string[]> {
435 const state = await read($, files)
436 if (!state.root) return []
437 const found = new Set(state.isGit ? changedBetween(before, state.status) : [])
438 if (mark && state.root !== here.home && state.root !== '/') {
439 const prune = ['(', ...PRUNE.flatMap((name, index) => (index ? ['-o', '-name', name] : ['-name', name])), ')', '-prune', '-o']
440 const run = await $.process.run(['find', '-H', state.root, '-xdev', '-maxdepth', '8', ...prune, '-type', 'f', '-newer', mark, '-print0'],
441 { timeoutMs: 8_000 }).catch(() => null)
442 for (const path of run ? parseFind(run.stdout, state.root) : []) found.add(path)
443 }
444 return [...found].slice(0, 200).map(path => join(state.root, path))
445}
446
447/** Marks what a finished shell command changed and scrolls to the first of it. */
448async function markShellEdits($: EngineInterface, mark: string, before: Record<string, FileChange>, now: number, agent?: string): Promise<void> {
449 const changed = await shellEdits($, mark, before)
450 if (!changed.length) return
451 const state = await setFiles($, current => onShellEdits(current, changed, now, agent))
452 void revealFile($, relative(state.root, changed[0]!)).catch(() => undefined)
453}
454
455/** Files' automatic open (off by default; `/sb-files auto on`): only when nothing else has the pane. */
456async function autoOpenFiles($: EngineInterface): Promise<void> {
457 filesTimers.autoClose?.cancel()
458 const current = await read($, prefs)
459 if (!current.autoOpen.files || (await read($, arbiter)).pane?.owner === 'files') return
460 await showPane($, filesView(projectName(await read($, files)), 'auto'))
461}
462
463/** The turn ended: an automatic open you didn't touch closes 20 s later. */
464function scheduleAutoClose($: EngineInterface): void {
465 filesTimers.autoClose?.cancel()
466 filesTimers.autoClose = $.clock.after(AUTO_CLOSE_MS.files ?? 20_000, () => {
467 void (async () => {
468 if (!shouldAutoClose(await read($, arbiter), 'files')) return
469 await releaseAuto($, 'files')
470 })().catch(() => undefined)
471 })
472}
473
474/** An owner is done with the pane (an automatic open, a hold): what it covered comes back, else the pane closes. */
475async function releaseAuto($: EngineInterface, owner: string): Promise<void> {
476 const out: { decision?: ReturnType<typeof releasePane>['decision'] } = {}
477 await update($, arbiter, state => {
478 const result = releasePane(state, owner)
479 out.decision = result.decision
480 return result.state
481 })
482 if (out.decision?.kind === 'show') await $.ui.open({ id: PANE_ID, title: out.decision.view.title, columns: PANE_COLUMNS })
483 else if (out.decision?.kind === 'close') await $.ui.close({ id: PANE_ID })
484}
485
486/**
487 * Opens the folders down to a file and scrolls the Files pane (the app's own scrolling) to the tree
488 * picture that holds it, when the pane shows Files. A file already in view does not move the pane.
489 */
490async function revealFile($: EngineInterface, path: string): Promise<void> {
491 const state = await read($, files)
492 const folders = foldersToReveal(state, path)
493 if (folders.length) {
494 await setFiles($, current => ({ ...current, expanded: [...new Set([...current.expanded, ...folders])] }))
495 const missing = folders.filter(folder => !state.nodes[folder])
496 if (missing.length) await loadFolders($, missing)
497 }
498 if ((await read($, arbiter)).pane?.owner !== 'files') return
499 const key = chunkKeyOf(await read($, files), path)
500 // The redraw with the opened folders lands first, then the scroll to the picture that holds the file.
501 if (key) $.clock.after(120, () => void $.ui.scroll({ to: { key }, in: PANE_ID, block: 'nearest' }).catch(() => undefined))
502}
503
504/** A proven commit: the files in it and its line counts. */
505async function recordCommit($: EngineInterface, sha: string): Promise<void> {
506 const state = await read($, files)
507 if (!state.root || !state.isGit) return
508 const names = await git($, state.root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha], 10_000)
509 const numstat = await git($, state.root, ['show', '--numstat', '--format=', '-z', sha], 10_000)
510 const inRoot = parseNames(names.stdout).filter(name => name.startsWith(state.prefix)).map(name => name.slice(state.prefix.length))
511 await setFiles($, current => onCommit(current, sha, inRoot, parseNumstat(numstat.stdout, current.prefix)))
512}
513
514// ---- Replay -----------------------------------------------------------------------------------------
515
516async function setReplay($: EngineInterface, change: (state: ReplayState) => ReplayState): Promise<ReplayState> {
517 const out: { state?: ReplayState } = {}
518 await update($, replay, state => (out.state = change(state)))
519 const line = out.state ? replayStatus(out.state) : 'nothing yet'
520 await update($, statuses, current => (current.replay === line ? current : { ...current, replay: line }))
521 return out.state ?? initialReplay()
522}
523
524async function isReplayOn($: EngineInterface): Promise<boolean> {
525 return (await read($, prefs)).features.replay
526}
527
528/** The last 3 turns go to `$.store`, so Replay survives a reload and a resume. */
529async function saveReplay($: EngineInterface): Promise<void> {
530 await $.store.set(REPLAY_KEY, forStore(await read($, replay)))
531}
532
533async function recordSteps($: EngineInterface, steps: ReplayStep[]): Promise<void> {
534 if (!steps.length) return
535 await setReplay($, state => addSteps(state, steps, here.turn.isRunning))
536 if (!here.turn.isRunning) await saveReplay($)
537}
538
539/** Where git's working tree stands before a shell command: a stash commit (no ref moves), or HEAD. */
540async function stashPoint($: EngineInterface, cwd: string): Promise<string> {
541 const stash = await git($, cwd, ['stash', 'create'], 8_000).catch(() => null)
542 if (!stash || stash.exitCode !== 0) return ''
543 if (stash.stdout.trim()) return stash.stdout.trim()
544 const head = await git($, cwd, ['rev-parse', '--verify', '-q', 'HEAD'], 5_000).catch(() => null)
545 return head?.exitCode === 0 ? head.stdout.trim() : ''
546}
547
548/** Opens Replay on a turn (0 is the last). False when there is nothing to show or the pane is held. */
549async function openReplay($: EngineInterface, turn: number): Promise<boolean> {
550 const state = await setReplay($, current => showTurn(current, turn))
551 if (!state.turns.length) return false
552 return showPane($, REPLAY_VIEW)
553}
554
555/** Moves to a step and brings the edit into view (the steps list can push it below the fold). */
556async function replayGo($: EngineInterface, step: number): Promise<void> {
557 await setReplay($, state => goTo(state, step))
558 $.clock.after(120, () => void $.ui.scroll({ to: { key: 'replay-edit' }, in: PANE_ID, block: 'nearest' }).catch(() => undefined))
559}
560
561async function closePane($: EngineInterface): Promise<void> {
562 await update($, arbiter, state => paneClosed(state, 'plugin'))
563 await $.ui.close({ id: PANE_ID })
564}
565
566function replayActions($: EngineInterface): ReplayActions {
567 return {
568 go: step => void replayGo($, step).catch(() => undefined),
569 showFull: () => void setReplay($, state => ({ ...state, isFull: true })).catch(() => undefined),
570 showTurn: turn => void setReplay($, state => showTurn(state, turn)).catch(() => undefined),
571 close: () => void closePane($).catch(() => undefined),
572 }
573}
574
575// ---- Blast Radius ---------------------------------------------------------------------------------
576
577async function setBlast($: EngineInterface, change: (state: BlastState) => BlastState): Promise<BlastState> {
578 const out: { state?: BlastState } = {}
579 await update($, blast, state => (out.state = change(state)))
580 const line = out.state ? blastStatus(out.state, await $.clock.now()) : 'watching'
581 await update($, statuses, current => (current.blast === line ? current : { ...current, blast: line }))
582 return out.state ?? initialBlast()
583}
584
585/** Waits a quarter second inside `$.process.run`: time there doesn't count against the hook's 10 s. */
586async function pause($: EngineInterface): Promise<void> {
587 await $.process.run(['sleep', '0.25'], { timeoutMs: 5_000 })
588}
589
590/** A `cd` target as an absolute folder, or null; the target is an argument, never source. */
591const CD_SCRIPT = 'unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P'
592
593async function resolveDir($: EngineInterface, cwd: string, dir: string): Promise<string | null> {
594 if (dir === '-') return null
595 const run = await $.process.run(['bash', '-c', CD_SCRIPT, 'blast', dir], { cwd, timeoutMs: 5_000 }).catch(() => null)
596 const out = run?.stdout.trim() ?? ''
597 return run?.exitCode === 0 && out ? posix(out) : null
598}
599
600/** Runs one measuring command; null when it failed or ran out of time. */
601async function measureRun($: EngineInterface, argv: string[], cwd: string, timeoutMs = 15_000): Promise<string | null> {
602 const run = await $.process.run(argv, { cwd, timeoutMs }).catch(() => null)
603 return run && run.exitCode === 0 ? run.stdout : null
604}
605
606/** What the risky command would change, measured where it will run. Never throws. */
607async function measureRisk($: EngineInterface, risk: Risk, cwd: string | null): Promise<BlastReport> {
608 const base = { command: risk.label, where: cwd ? `in ${basename(cwd) || cwd}` : `in ${risk.dir}` }
609 const cant = cantMeasure(risk, base)
610 if (cant) return cant
611 if (!cwd) return unmeasuredReport(base, `Couldn't find the folder ${risk.dir}, so what this changes can't be measured.`)
612 try {
613 switch (risk.kind) {
614 case 'rm': {
615 if (!risk.targets.length) return unmeasuredReport(base, 'It names no paths.')
616 const out = await measureRun($, ['bash', '-c', RM_SCRIPT, 'blast', ...risk.targets], cwd)
617 return out === null ? unmeasuredReport(base, 'Measuring took more than 15 seconds.') : rmReport(base, parseRm(out))
618 }
619 case 'find-delete': {
620 const listed = await measureRun($, ['find', ...risk.targets, ...findDryRun(risk.args)], cwd)
621 if (listed === null) return unmeasuredReport(base, 'Its dry run (the same find with -print) failed.')
622 // Folders it lists take what is under them; keep only the topmost of each.
623 const { top, isPartial } = topmost(listed.split('\n'))
624 if (!top.length) return rmReport(base, parseRm('T\t0\t0\t0\t0'))
625 const out = await measureRun($, ['bash', '-c', RM_SCRIPT, 'blast', ...top], cwd)
626 return out === null ? unmeasuredReport(base, 'Measuring took more than 15 seconds.') : rmReport(base, parseRm(out), isPartial)
627 }
628 case 'git-reset':
629 case 'git-checkout': {
630 const isReset = risk.kind === 'git-reset'
631 const out = await measureRun($, ['git', '--no-optional-locks', 'diff', '--numstat', '--relative', ...(isReset ? ['HEAD'] : [])], cwd)
632 return out === null ? unmeasuredReport(base, 'git diff failed here.') : dirtyReport(base, out, isReset)
633 }
634 case 'git-clean': {
635 const flags: string[] = []
636 const paths: string[] = []
637 for (let i = 0; i < risk.args.length; i++) {
638 const arg = risk.args[i]!
639 if (arg === '--') {
640 paths.push(...risk.args.slice(i + 1))
641 break
642 }
643 if (arg === '-e' || arg === '--exclude') flags.push(arg, risk.args[++i] ?? '')
644 else if (arg.startsWith('--exclude=') || /^-e./.test(arg)) flags.push(arg)
645 else if (/^-[a-zA-Z]+$/.test(arg)) {
646 const kept = arg.replace(/[finq]/g, '')
647 if (kept !== '-') flags.push(kept)
648 } else if (!arg.startsWith('-')) paths.push(arg)
649 }
650 const out = await measureRun($, ['git', 'clean', '-n', ...flags, '--', ...paths], cwd)
651 return out === null ? unmeasuredReport(base, 'git clean -n failed here.') : cleanReport(base, out)
652 }
653 case 'git-push-force': {
654 const target = pushTarget(risk.args)
655 let { source, branch } = target
656 if (!branch || branch === 'HEAD') {
657 branch = (await measureRun($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd, 5_000))?.trim() ?? ''
658 source = 'HEAD'
659 }
660 const ref = `${target.remote}/${branch}`
661 const known = await measureRun($, ['git', 'rev-parse', '--verify', '--quiet', ref], cwd, 5_000)
662 if (known === null) return pushReport(base, ref, null)
663 const log = await measureRun($, ['git', 'log', '--oneline', '--no-decorate', '-n', '200', `${source}..${ref}`], cwd)
664 return pushReport(base, ref, log ?? '')
665 }
666 case 'migrate': {
667 const lister = MIGRATION_LISTERS[risk.tool]
668 return migrateReport(base, risk.tool, lister ? await measureRun($, lister.argv, cwd, 20_000) : null)
669 }
670 default:
671 return unmeasuredReport(base, 'It can\'t be measured.')
672 }
673 } catch {
674 return unmeasuredReport(base, 'Measuring it failed.')
675 }
676}
677
678/** Takes the pane (a hold: focused, Esc closes it) or, when it can't be placed, the band's card slot. */
679async function showBlast($: EngineInterface, id: string): Promise<void> {
680 const out: { decision?: PaneDecision } = {}
681 const before = (await read($, arbiter)).pane
682 await update($, arbiter, state => {
683 const result = requestPane(state, BLAST_VIEW)
684 out.decision = result.decision
685 return result.state
686 })
687 const opened = out.decision?.kind === 'show'
688 ? await $.ui.open({ id: PANE_ID, title: BLAST_VIEW.title, columns: PANE_COLUMNS, focus: true, closeOnEscape: true, holdToasts: true }).catch(() => null)
689 : null
690 // Placed but hidden (the side panel collapsed) counts as not shown.
691 const isShown = !!opened?.isPlaced && (await $.ui.panes().catch(() => [])).some(pane => pane.id === PANE_ID && pane.isShown !== false)
692 const held = (await read($, blast)).held
693 if (held?.id === id && held.report) $.ui.toast(`Blast Radius is holding ${held.report.command}. Choose ${held.report.action} or Cancel ${isShown ? 'in the side pane' : 'above the prompt'}.`)
694 void devLog($, { event: 'blast:show', id, before: before ? `${before.owner}/${before.reason}` : null,
695 decision: out.decision?.kind, opened: opened ?? null, panes: await $.ui.panes().catch(() => 'error') }).catch(() => undefined)
696 if (!isShown) {
697 const now = await $.clock.now()
698 await setBlast($, state => withHeld(state, id, held => ({ ...held, isInBand: true })))
699 await update($, arbiter, state => pushCard(state, { id: 'blast', kind: 'blast', at: now }))
700 }
701}
702
703/** The hold is over: the pane goes back to what it showed before, or closes. */
704async function releaseBlast($: EngineInterface, id: string): Promise<void> {
705 if (blastHolder.id === id) blastHolder.id = null
706 const state = await read($, blast)
707 if (state.held?.id !== id) return
708 await setBlast($, current => release(current, id))
709 await update($, arbiter, current => dropCard(current, 'blast'))
710 await releaseAuto($, 'blast')
711}
712
713/**
714 * Decisions by held id. The hold loop reads these, not `$.state`: a hook waiting inside its own
715 * dispatch doesn't see state that other hooks write meanwhile (seen in tests, 2.1.295).
716 */
717const blastDecisions = new Map<string, BlastDecision>()
718
719/** The id holding now, for the same reason: one hold at a time, claimed with no await in between. */
720const blastHolder: { id: string | null } = { id: null }
721
722function decideBlast($: EngineInterface, id: string, decision: BlastDecision): void {
723 if (blastHolder.id !== id) return // a press on a hold that's already over
724 if (!blastDecisions.has(id)) blastDecisions.set(id, decision)
725 void setBlast($, state => decide(state, id, decision)).catch(() => undefined)
726}
727
728function blastActions($: EngineInterface, id: string): BlastActions {
729 return {
730 proceed: () => decideBlast($, id, 'proceed'),
731 cancel: () => decideBlast($, id, 'cancel'),
732 toggleMore: () => void setBlast($, state => withHeld(state, id, held => ({ ...held, isExpanded: !held.isExpanded }))).catch(() => undefined),
733 }
734}
735
736/** Rows whose path is gone after the command ran: struck through. */
737async function goneRows($: EngineInterface, report: BlastReport, cwd: string, kind: RiskKind): Promise<string[]> {
738 if (!['rm', 'find-delete', 'git-clean'].includes(kind)) return []
739 const rows = report.rows.slice(0, 200)
740 const gone = await Promise.all(rows.map(async row =>
741 ((await $.fs.exists(row.path.startsWith('/') ? row.path : join(cwd, row.path)).catch(() => true)) ? '' : row.path)))
742 return gone.filter(Boolean)
743}
744
745/** Which call each held id belongs to, so a failing hook's `.catch` can let go of its own hold. */
746const blastCalls = new Map<string, string>()
747
748// ---- Agents (display only) ------------------------------------------------------------------------
749
750async function setAgents($: EngineInterface, change: (state: AgentsState) => AgentsState): Promise<AgentsState> {
751 const out: { state?: AgentsState } = {}
752 await update($, agents, state => (out.state = change(state)))
753 const line = out.state ? agentsStatus(out.state) : 'idle'
754 await update($, statuses, current => (current.agents === line ? current : { ...current, agents: line }))
755 return out.state ?? initialAgents()
756}
757
758async function isAgentsOn($: EngineInterface): Promise<boolean> {
759 return (await read($, prefs)).features.agents
760}
761
762/** The session's cost as /cost reports it, for the Cost tile; -1 when the engine has none. */
763async function refreshSessionCost($: EngineInterface): Promise<void> {
764 const usage = await $.session.usage().catch(() => null)
765 const cost = usage?.cost?.usd
766 await setAgents($, state => ({ ...state, sessionCost: typeof cost === 'number' ? cost : -1 }))
767}
768
769/** Shows the Agents pane, or closes it when it already shows (×N on the band, /sb-agents). */
770async function toggleAgentsPane($: EngineInterface): Promise<'opened' | 'closed' | 'queued'> {
771 if ((await read($, arbiter)).pane?.owner === 'agents') {
772 await closePane($)
773 return 'closed'
774 }
775 return (await showPane($, agentsView((await read($, agents)).title))) ? 'opened' : 'queued'
776}
777
778const agentsTimers: { close?: { cancel: () => void }; ticks: number } = { ticks: 0 }
779
780/** The pane opened by itself: it closes 8 s after the last agent ends, unless you used it. */
781function scheduleAgentsClose($: EngineInterface): void {
782 agentsTimers.close?.cancel()
783 agentsTimers.close = $.clock.after(AUTO_CLOSE_MS.agents ?? 8_000, () => {
784 void (async () => {
785 if (agentsRunning(await read($, agents)) || !shouldAutoClose(await read($, arbiter), 'agents')) return
786 await releaseAuto($, 'agents')
787 })().catch(() => undefined)
788 })
789}
790
791function agentsActions($: EngineInterface): AgentsActions {
792 return {
793 toggleFinished: () => void setAgents($, state => ({ ...state, isFinishedOpen: !state.isFinishedOpen })).catch(() => undefined),
794 togglePane: () => void toggleAgentsPane($).catch(() => undefined),
795 hideBand: () => void setAgents($, state => ({ ...state, hiddenGroup: state.group })).catch(() => undefined),
796 }
797}
798
799// ---- Reflect ---------------------------------------------------------------------------------------
800
801/** Messages waiting for the check (at most 20), worked through off the prompt's own dispatch. */
802const reflectQueue: { texts: string[]; isDraining: boolean; writing: Promise<unknown> } =
803 { texts: [], isDraining: false, writing: Promise.resolve() }
804
805async function setReflect($: EngineInterface, change: (state: ReflectState) => ReflectState): Promise<ReflectState> {
806 const out: { state?: ReflectState } = {}
807 await update($, reflect, state => (out.state = change(state)))
808 const project = await $.session.root().catch(() => here.cwd)
809 const line = out.state ? reflectStatus(out.state, project) : 'listening'
810 await update($, statuses, current => (current.reflect === line ? current : { ...current, reflect: line }))
811 return out.state ?? initialReflect()
812}
813
814async function saveReflectItems($: EngineInterface): Promise<void> {
815 await $.store.set(ITEMS_KEY, capped((await read($, reflect)).items))
816}
817
818/** Where a rule goes: this project's CLAUDE.md or yours for every project. */
819async function claudeMdOf($: EngineInterface, scope: ReflectScope): Promise<string> {
820 return scope === 'project' ? join(posix(await $.session.root()), 'CLAUDE.md') : join(here.home || '~', '.claude/CLAUDE.md')
821}
822
823const readOr = async ($: EngineInterface, path: string) => (await $.fs.exists(path).catch(() => false)) ? $.fs.read(path) : ''
824
825/** Shows the card for an item when idle (the arbiter's card slot); 30 minutes unseen and it leaves the band. */
826async function showRuleCard($: EngineInterface, id: string): Promise<void> {
827 const now = await $.clock.now()
828 await update($, arbiter, state => pushCard(state, { id: `reflect:${id}`, kind: 'reflect', at: now, expiresAt: now + CARD_TTL_MS }))
829}
830
831/** One message, end to end: `remember:` skips the model; otherwise a cheap classify, then the check. */
832async function checkMessage($: EngineInterface, text: string): Promise<void> {
833 const project = posix(await $.session.root())
834 const told = remembered(text)
835 let verdict: Verdict | undefined
836 if (told) {
837 const rule = cleanRule(told.rule)
838 if (!rule) {
839 $.ui.toast('Reflect didn\'t keep that: one line, no secrets, at most 200 characters.')
840 return
841 }
842 verdict = { isRule: true, rule, scope: told.isProject ? 'project' : 'global', confidence: 1 }
843 } else {
844 // Found here, with no model call: Reflect spends no tokens.
845 verdict = correction(text)
846 if (!verdict) return
847 }
848 const found = verdict
849 const bullets = bulletsOf(await readOr($, await claudeMdOf($, found.scope)).catch(() => ''))
850 const now = await $.clock.now()
851 const id = `${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`
852 const out: { kind?: string; id?: string } = {}
853 await setReflect($, state => {
854 const result = recordRule(state, { ...found, project, quote: text.slice(0, 160), isExplicit: !!told }, bullets, now, id)
855 out.kind = result.recorded.kind
856 if ('item' in result.recorded) out.id = result.recorded.item.id
857 return result.state
858 })
859 await saveReflectItems($)
860 if ((out.kind === 'new' || out.kind === 'again') && out.id) await showRuleCard($, out.id)
861}
862
863/** A message you sent joins the check queue when it is worth checking (cheap tests only). */
864async function queueReflect($: EngineInterface, text: string, originKind: string): Promise<void> {
865 const state = await read($, reflect)
866 if (state.isPaused || state.isConflicted || reflectQueue.texts.length >= 20 || !isCandidate(text, originKind)) return
867 if (!(await read($, prefs)).features.reflect) return
868 reflectQueue.texts.push(text.trim())
869 $.clock.after(0, () => drainReflect($))
870}
871
872function drainReflect($: EngineInterface): void {
873 if (reflectQueue.isDraining) return
874 reflectQueue.isDraining = true
875 void (async () => {
876 try {
877 for (let text = reflectQueue.texts.shift(); text !== undefined; text = reflectQueue.texts.shift()) {
878 // A failed model call or file read loses this message only.
879 await checkMessage($, text).catch(() => undefined)
880 }
881 } finally {
882 reflectQueue.isDraining = false
883 }
884 })()
885}
886
887/** Writes across sessions one at a time: a lock folder, stale after 5 s. */
888const LOCK_SCRIPT = 'dir="$HOME/.switchboard/locks"; mkdir -p "$dir"; lock="$dir/claude-md.lock"; ' +
889 'for i in $(seq 1 50); do if mkdir "$lock" 2>/dev/null; then exit 0; fi; ' +
890 'perl -e \'exit((time - (stat shift)[9]) > 5 ? 0 : 1)\' "$lock" && rmdir "$lock" 2>/dev/null; sleep 0.1; done; exit 1'
891/** Replaces the target with the temp file in one step, keeping its mode; follows a symlink to its file. */
892const MOVE_SCRIPT = 'target="$1"; tmp="$2"; if [ -L "$target" ]; then target=$(perl -MCwd=abs_path -e \'print abs_path(shift)\' "$target"); fi; ' +
893 'if [ -e "$target" ]; then mode=$(stat -f %Lp "$target" 2>/dev/null || stat -c %a "$target"); chmod "$mode" "$tmp"; fi; mv -f "$tmp" "$target"'
894
895/** Saves a rule into CLAUDE.md: locked, re-read just before the write, written atomically. */
896async function saveRule($: EngineInterface, id: string, scope: ReflectScope, rule?: string): Promise<void> {
897 const job = reflectQueue.writing.then(async () => {
898 const state = await read($, reflect)
899 const item = state.items.find(one => one.id === id)
900 if (!item || item.status !== 'pending') return
901 const text = rule ?? item.rule
902 const path = await claudeMdOf($, scope)
903 const locked = await $.process.run(['bash', '-c', LOCK_SCRIPT], { timeoutMs: 8_000 }).catch(() => null)
904 if (locked?.exitCode !== 0) {
905 $.ui.toast('Reflect: another session is writing CLAUDE.md. Try again in a moment.')
906 return
907 }
908 try {
909 const before = await readOr($, path)
910 const written = withRule(before, text)
911 if (written.kind === 'broken') {
912 $.ui.toast(`Reflect: the switchboard markers in ${path} are broken near line ${written.line}. Fix them by hand; nothing was written.`)
913 return
914 }
915 if (written.kind === 'added') {
916 const tmp = `${path}.switchboard-${Date.now().toString(36)}.tmp`
917 await $.fs.write(tmp, written.text)
918 const moved = await $.process.run(['bash', '-c', MOVE_SCRIPT, 'reflect', path, tmp], { timeoutMs: 5_000 }).catch(() => null)
919 if (moved?.exitCode !== 0) {
920 await $.process.run(['rm', '-f', tmp], { timeoutMs: 3_000 }).catch(() => undefined) // never left beside your CLAUDE.md
921 $.ui.toast(`Reflect couldn't write ${path}. Check that the file and its folder are writable; the rule is still waiting.`)
922 return
923 }
924 }
925 const now = await $.clock.now()
926 const project = posix(await $.session.root())
927 await setReflect($, current => ({
928 ...decideRule(current, id, 'saved', scope, now),
929 savedNow: [...current.savedNow, { rule: text, scope, project }],
930 }))
931 await saveReflectItems($)
932 await update($, arbiter, current => dropCard(current, `reflect:${id}`))
933 $.ui.toast(written.kind === 'already' ? `Already in ${path}.` : `Saved to ${path}. Claude follows it from now on.`)
934 } finally {
935 await $.process.run(['bash', '-c', 'rmdir "$HOME/.switchboard/locks/claude-md.lock" 2>/dev/null; exit 0'], { timeoutMs: 3_000 }).catch(() => undefined)
936 }
937 })
938 reflectQueue.writing = job.catch(() => undefined)
939 await job
940}
941
942function reflectActions($: EngineInterface, id: string, scope: ReflectScope): ReflectActions {
943 const later = (work: () => Promise<unknown>) => void work().catch(() => undefined)
944 return {
945 save: chosen => later(() => saveRule($, id, chosen)),
946 choose: chosen => later(() => setReflect($, state => ({ ...state, chosen: { ...state.chosen, [id]: chosen } }))),
947 startReword: () => later(() => setReflect($, state => ({ ...state, rewording: id }))),
948 cancelReword: () => later(() => setReflect($, state => ({ ...state, rewording: '' }))),
949 submitReword: text => later(async () => {
950 const rule = cleanRule(text)
951 if (!rule) {
952 $.ui.toast('Reflect: one line, no secrets, at most 200 characters.')
953 return
954 }
955 const now = await $.clock.now()
956 await setReflect($, state => ({ ...rewordRule(state, id, rule, now), rewording: '' }))
957 await saveRule($, id, scope, rule)
958 }),
959 skip: () => later(async () => {
960 const now = await $.clock.now()
961 await setReflect($, state => decideRule(state, id, 'skipped', undefined, now))
962 await saveReflectItems($)
963 await update($, arbiter, current => dropCard(current, `reflect:${id}`))
964 }),
965 }
966}
967
968// ---- Cache Tax -------------------------------------------------------------------------------------
969
970/** Keep-warm's one timer, and whether a ping is out now. */
971const cacheTimer: { pending?: { cancel: () => void }; isPinging: boolean } = { isPinging: false }
972
973async function setCache($: EngineInterface, change: (state: CacheState) => CacheState): Promise<CacheState> {
974 const now = await $.clock.now()
975 const out: { state?: CacheState } = {}
976 await update($, cache, state => (out.state = { ...change(state), now }))
977 const line = out.state ? cacheStatus(out.state, now) : 'watching'
978 await update($, statuses, current => (current.cache === line ? current : { ...current, cache: line }))
979 return out.state ?? initialCache()
980}
981
982async function isCacheOn($: EngineInterface): Promise<boolean> {
983 return (await read($, prefs)).features.cache
984}
985
986/** Sets the one timer: a ping while warm, else only the deadline's cleanup; none while off. */
987async function armCache($: EngineInterface): Promise<void> {
988 cacheTimer.pending?.cancel()
989 cacheTimer.pending = undefined
990 const state = await read($, cache)
991 const timer = nextTimer(state, await $.clock.now())
992 if (timer.kind === 'off') return
993 cacheTimer.pending = $.clock.after(timer.inMs, () => {
994 cacheTimer.pending = undefined
995 void (timer.kind === 'ping' ? pingCache($) : expireCache($)).catch(() => undefined)
996 })
997}
998
999async function expireCache($: EngineInterface): Promise<void> {
1000 const state = await read($, cache)
1001 if (state.deadline && (await $.clock.now()) >= state.deadline) await stopWarm($, '')
1002 else await armCache($)
1003}
1004
1005/** Keep-warm on for `windowMs`, pinging every `every` (0: the default for the cache's TTL). */
1006async function startWarm($: EngineInterface, windowMs: number, every: number): Promise<CacheState> {
1007 const now = await $.clock.now()
1008 const state = await setCache($, current => armWarm(current, windowMs, every, now))
1009 await $.store.set(CACHE_KEYS.deadline(here.sessionId), state.deadline)
1010 if (every) await $.store.set(CACHE_KEYS.every(here.sessionId), every)
1011 else await $.store.delete(CACHE_KEYS.every(here.sessionId))
1012 await armCache($)
1013 return state
1014}
1015
1016/** Keep-warm off; `why` says it stopped by itself (shown, and told once). */
1017async function stopWarm($: EngineInterface, why: string, isForGood = false): Promise<void> {
1018 cacheTimer.pending?.cancel()
1019 cacheTimer.pending = undefined
1020 await setCache($, state => ({ ...disarmWarm(state, why), ...(isForGood ? { isAlways: false } : {}) }))
1021 await $.store.delete(CACHE_KEYS.deadline(here.sessionId))
1022 await $.store.delete(CACHE_KEYS.every(here.sessionId))
1023 if (isForGood) await $.store.delete(CACHE_KEYS.always)
1024 if (why) $.ui.toast(`Cache Tax: keep-warm stopped, ${why}.`)
1025}
1026
1027/** A ping: one tiny fork of this conversation, so the next turn reads the cache instead of re-writing it. */
1028async function pingCache($: EngineInterface): Promise<void> {
1029 const state = await read($, cache)
1030 const now = await $.clock.now()
1031 if (!state.deadline) return
1032 if (!(await isCacheOn($))) return stopWarm($, '')
1033 // A turn running now keeps the cache warm by itself, and its end sets the timer again.
1034 if (here.turn.isRunning || cacheTimer.isPinging) return
1035 const usage = await $.session.usage().catch(() => null)
1036 const isNearLimit = (usage?.rateLimits ?? []).some(limit => limit.percentUsed >= 90)
1037 const check = pingCheck(state, now, isNearLimit)
1038 if (check.kind === 'stop') return stopWarm($, check.why)
1039 if (check.kind === 'later') {
1040 cacheTimer.pending = $.clock.after(60_000, () => void pingCache($).catch(() => undefined))
1041 return
1042 }
1043 if (check.kind === 'skip') return armCache($)
1044 cacheTimer.isPinging = true
1045 try {
1046 const reply = await $.model.fork({ prompt: PING_PROMPT }).catch((error: unknown) =>
1047 ({ isAnswered: false as const, reason: 'failed', status: null, message: error instanceof Error ? error.message : String(error) }))
1048 const result = onPing(await read($, cache), reply, await $.clock.now())
1049 await setCache($, () => result.state)
1050 if (result.stop) {
1051 await stopWarm($, result.stop)
1052 return
1053 }
1054 } finally {
1055 cacheTimer.isPinging = false
1056 }
1057 await armCache($)
1058}
1059
1060/** A main-thread turn ended: the context, a cold write paid (keep-warm then holds it 3 h), the timer. */
1061async function cacheTurnDone($: EngineInterface, usage: { model?: string; input_tokens: number; output_tokens: number;
1062 cache_read_input_tokens: number; cache_creation_input_tokens: number } | undefined, durationMs: number): Promise<void> {
1063 if (!(await isCacheOn($))) return
1064 const now = await $.clock.now()
1065 const live = (await $.session.usage().catch(() => null))?.context?.tokens ?? 0
1066 const done: { miss?: { tokens: number; usd: number | null } | null; learned?: CacheTtl | null } = {}
1067 const state = await setCache($, current => {
1068 const result = onCacheTurnDone(current, usage, live, durationMs, now)
1069 done.miss = result.miss
1070 done.learned = result.learned
1071 return result.state
1072 })
1073 if (done.learned) await $.store.set(CACHE_KEYS.ttls, state.ttls)
1074 await update($, arbiter, current => dropCard(current, 'cache:held'))
1075 if (state.deadline && now >= state.deadline) await stopWarm($, '')
1076 // A cold write was paid: say so, never start keep-warm by itself (its pings cost tokens; you choose it).
1077 if (done.miss && !state.deadline) {
1078 $.ui.toast(`Cache Tax: a cold write of ${fmtTok(done.miss.tokens)} tokens was paid (${fmtUsd(done.miss.usd)}). /sb-cache warm 3h keeps it warm, at a small cost per ping.`)
1079 }
1080 await armCache($)
1081}
1082
1083/** The engine's own session start: a resume or fork says how old and how big the cache is; /clear resets. */
1084async function cacheSessionStart($: EngineInterface, e: { source: string; model?: string; context_tokens?: number;
1085 seconds_since_last_response?: number; prompt_cache_likely_expired?: boolean; estimated_cache_write_usd?: number }): Promise<void> {
1086 if (!(await isCacheOn($))) return
1087 if (e.source === 'clear') {
1088 await stopWarm($, '')
1089 await setCache($, onCacheClear)
1090 await update($, arbiter, current => dropCard(current, 'cache:held'))
1091 return
1092 }
1093 const now = await $.clock.now()
1094 const out: { line: string } = { line: '' }
1095 await setCache($, state => {
1096 const result = onResume(state, e, now)
1097 out.line = result.line
1098 return result.state
1099 })
1100 if (!(await read($, cache)).model) {
1101 const model = await $.session.model().catch(() => '')
1102 await setCache($, state => ({ ...state, model: typeof model === 'string' ? model : '' }))
1103 }
1104 if (out.line) $.ui.log(out.line)
1105 await armCache($)
1106}
1107
1108/** Session start: the guard, always and the learned TTLs (global), this session's keep-warm, the model. */
1109async function cacheLoad($: EngineInterface): Promise<void> {
1110 const now = await $.clock.now()
1111 const guard = (await $.store.get(CACHE_KEYS.guard).catch(() => undefined)) === 'warn' ? 'warn' as const : 'hold' as const
1112 const isAlways = (await $.store.get(CACHE_KEYS.always).catch(() => undefined)) === true
1113 const stored = await $.store.get(CACHE_KEYS.ttls).catch(() => undefined)
1114 const ttls: Record<string, CacheTtl> = {}
1115 for (const [model, ttl] of Object.entries(stored && typeof stored === 'object' ? stored : {})) if (ttl === '5m' || ttl === '1h') ttls[model] = ttl
1116 const deadline = await $.store.get(CACHE_KEYS.deadline(here.sessionId)).catch(() => undefined)
1117 const every = await $.store.get(CACHE_KEYS.every(here.sessionId)).catch(() => undefined)
1118 const commands = await $.command.list().catch(() => [])
1119 const isConflicted = commands.some(command => command.name === 'keepwarm')
1120 const model = await $.session.model().catch(() => '')
1121 const live = (await $.session.usage().catch(() => null))?.context?.tokens ?? 0
1122 const isLive = typeof deadline === 'number' && deadline > now
1123 if (!isLive && deadline !== undefined) {
1124 await $.store.delete(CACHE_KEYS.deadline(here.sessionId))
1125 await $.store.delete(CACHE_KEYS.every(here.sessionId))
1126 }
1127 await setCache($, state => ({
1128 ...state, guard, isAlways, ttls: { ...ttls, ...state.ttls }, isConflicted,
1129 model: state.model || (typeof model === 'string' ? model : ''), ctx: state.ctx || live,
1130 ...(isLive ? { deadline, every: typeof every === 'number' && every >= MIN_PING_MS ? every : 0 } : {}),
1131 }))
1132 if (isConflicted) return
1133 if (isAlways && !isLive && !(await read($, cache)).deadline) await startWarm($, DEFAULT_WINDOW_MS, 0)
1134 else await armCache($)
1135}
1136
1137/** Pay and send, Start fresh and Show cache status. Each runs in its own dispatch, after the press ends. */
1138function cacheActions($: EngineInterface): CacheActions {
1139 const later = (work: () => Promise<unknown>) => void $.clock.after(1, () => void work().catch(() => undefined))
1140 return {
1141 pay: () => later(async () => {
1142 const held = (await read($, cache)).held
1143 if (!held) return
1144 await setCache($, onPay)
1145 await update($, arbiter, current => dropCard(current, 'cache:held'))
1146 await queueReflect($, held.text, 'composer')
1147 await $.prompt.submit({ text: held.text, asUser: true })
1148 }),
1149 fresh: () => later(async () => {
1150 const held = (await read($, cache)).held
1151 if (!held) return
1152 await setCache($, state => ({ ...state, held: null }))
1153 await update($, arbiter, current => dropCard(current, 'cache:held'))
1154 const said = here.lastAnswer.replace(/\s+/g, ' ').trim()
1155 const note = said ? `\n\n(Where we were: Claude last said “${said.length > 300 ? `${said.slice(0, 300)}…` : said}”)` : ''
1156 await $.command.run({ command: 'clear' })
1157 await $.prompt.fill({ text: `${held.text}${note}` })
1158 }),
1159 status: () => later(async () => $.ui.log(cacheCardText(await read($, cache), await $.clock.now()))),
1160 }
1161}
1162
1163// ---- Gmail through Claude's connector (polled from the Inbox session only) ------------------------
1164
1165const GMAIL_TOOLS = ['search_threads', 'get_thread']
1166/** The connector's server name from the tools this session has; found once, then kept. */
1167const gmailLink: { server: string; cursorSec: number; isPolling: boolean } = { server: '', cursorSec: 0, isPolling: false }
1168const POLL_MS = 60_000
1169
1170async function findGmailServer($: EngineInterface): Promise<string> {
1171 if (gmailLink.server) return gmailLink.server
1172 const tools = await $.tool.list().catch(() => [])
1173 const servers = new Map<string, { names: Set<string>; isGmail: boolean }>()
1174 for (const tool of tools) {
1175 if (!tool.mcp) continue
1176 const match = /^mcp__(.+?)__(.+)$/.exec(tool.name)
1177 if (!match) continue
1178 const [, server = '', name = ''] = match
1179 const known = servers.get(server) ?? { names: new Set<string>(), isGmail: false }
1180 known.names.add(name)
1181 // The desktop app names claude.ai connectors by an id, so the name alone can't say it's Gmail.
1182 known.isGmail ||= /gmail/i.test(server) || /gmail/i.test(tool.description)
1183 servers.set(server, known)
1184 }
1185 for (const [server, known] of servers) {
1186 if (known.isGmail && GMAIL_TOOLS.every(name => known.names.has(name))) gmailLink.server = server
1187 }
1188 return gmailLink.server
1189}
1190
1191/** One check: new inbox mail through the connector, handed to the watcher's ingest (the same de-dup and rules). */
1192async function pollGmail($: EngineInterface): Promise<void> {
1193 if (gmailLink.isPolling) return
1194 gmailLink.isPolling = true
1195 try {
1196 const server = await findGmailServer($)
1197 if (!server) {
1198 await setInbox($, state => ({ ...state, sources: { ...state.sources, gmail: { state: 'setup', message: "Gmail isn't on in this session. Turn it on in this session's connectors, or connect it in claude.ai." } } }))
1199 return
1200 }hooks/core/arbiter.ts 166 lines1// The arbiter: the one place that decides what Switchboard shows (BUILD_PLAN.md section 4).
2// Features ask it; they never open panes or draw band rows themselves. Pure functions over a plain
3// state value, so register.tsx keeps the state in `$.state` and every rule here is unit-tested.
4//
5// - One side pane (id `switchboard`), its content swapped by priority: a Blast Radius hold, then
6// what you opened, then the Inbox pane in the Inbox session, then automatic opens.
7// - One card slot above the prompt, with a queue: meeting, Blast fallback, Cache Tax hold,
8// needs-you, Reflect (idle only).
9// - One status row under it. The band is omitted entirely when there is nothing to say.
10
11import type { ArbiterState, Card, CardKind, PaneReason, PaneView } from '../../types/index.d.ts'
12
13export type { ArbiterState, Card, CardKind, PaneReason, PaneView }
14
15export const PANE_ID = 'switchboard'
16/** The side pane's width asked for on every open (about 660 px on the desktop): a width you drag it to wins. */
17export const PANE_COLUMNS = 84
18
19const CARD_RANK: Record<CardKind, number> = { meeting: 8, blast: 7, cache: 6, check: 5, rule: 4, needs: 2, reflect: 1 }
20
21export const MAX_CARDS = 20
22/** How long an automatically opened pane stays after its reason ends, unless you touched it. */
23export const AUTO_CLOSE_MS: Readonly<Record<string, number>> = { agents: 8_000, files: 20_000 }
24
25export function initialArbiter(): ArbiterState {
26 return { pane: null, under: null, blockedAuto: [], cards: [] }
27}
28
29const autoKey = (view: PaneView) => `${view.owner}:${view.view}`
30
31export type PaneDecision =
32 /** Open (or retitle) the pane with this view now. */
33 | { kind: 'show'; view: PaneView }
34 /** Waits behind a hold; it shows when the hold is released. */
35 | { kind: 'queued' }
36 /** Not shown: something with a better claim holds the pane, or you closed it before. */
37 | { kind: 'refused'; reason: string }
38
39/** A feature asks for the pane. */
40export function requestPane(state: ArbiterState, view: PaneView): { state: ArbiterState; decision: PaneDecision } {
41 const current = state.pane
42 const show = (next: ArbiterState) => ({ state: next, decision: { kind: 'show', view } as const })
43
44 switch (view.reason) {
45 case 'hold':
46 if (current?.reason === 'hold') return { state, decision: { kind: 'queued' } } // one hold at a time
47 // Automatic opens aren't brought back after a hold; what you opened is.
48 return show({ ...state, pane: view, under: current && current.reason !== 'auto' ? current : state.under })
49 case 'user':
50 if (current?.reason === 'hold') return { state: { ...state, under: view }, decision: { kind: 'queued' } }
51 return show({ ...state, pane: view })
52 case 'auto':
53 if (state.blockedAuto.includes(autoKey(view))) return { state, decision: { kind: 'refused', reason: 'you closed it' } }
54 if (!current || (current.owner === view.owner && current.reason === 'auto')) return show({ ...state, pane: view })
55 // A pane you haven't touched gives way while the work runs, and comes back when it ends.
56 if (current.reason !== 'hold' && !current.isTouched) {
57 return show({ ...state, pane: view, under: current.reason === 'auto' ? state.under : { ...current } })
58 }
59 return { state, decision: { kind: 'refused', reason: 'pane in use' } }
60 }
61}
62
63/** The pane closed. `person`: you closed it (✕ or Esc). */
64export function paneClosed(state: ArbiterState, origin: 'person' | 'plugin' | 'unload'): ArbiterState {
65 const current = state.pane
66 if (!current) return state
67 if (origin === 'person') {
68 const blockedAuto = current.reason === 'auto' && !state.blockedAuto.includes(autoKey(current))
69 ? [...state.blockedAuto, autoKey(current)] : state.blockedAuto
70 // You closed it on purpose: nothing pops up in its place.
71 return { ...state, pane: null, under: null, blockedAuto }
72 }
73 // Switchboard closed it (a command, a feature off): nothing comes back later either.
74 return origin === 'plugin' ? { ...state, pane: null, under: null } : { ...state, pane: null }
75}
76
77export type ReleaseDecision = { kind: 'show'; view: PaneView } | { kind: 'close' } | { kind: 'none' }
78
79/** The owner is done with the pane (a hold was decided, a run ended): what was under it comes back. */
80export function releasePane(state: ArbiterState, owner: string): { state: ArbiterState; decision: ReleaseDecision } {
81 if (state.pane?.owner !== owner) return { state, decision: { kind: 'none' } }
82 if (state.under) return { state: { ...state, pane: state.under, under: null }, decision: { kind: 'show', view: state.under } }
83 return { state: { ...state, pane: null }, decision: { kind: 'close' } }
84}
85
86/** A new turn: panes you closed may open by themselves again (closing one holds only for its turn). */
87export const clearAutoBlocks = (state: ArbiterState): ArbiterState => (state.blockedAuto.length ? { ...state, blockedAuto: [] } : state)
88
89/** You clicked or typed in the pane. */
90export function touchPane(state: ArbiterState): ArbiterState {
91 return state.pane && !state.pane.isTouched ? { ...state, pane: { ...state.pane, isTouched: true } } : state
92}
93
94/** Whether an automatic open by `owner` should close now that its reason ended (after AUTO_CLOSE_MS). */
95export function shouldAutoClose(state: ArbiterState, owner: string): boolean {
96 const pane = state.pane
97 return !!pane && pane.reason === 'auto' && pane.owner === owner && !pane.isTouched
98}
99
100/** Asks for the card slot. The same id replaces its earlier card; the queue keeps the newest MAX_CARDS. */
101export function pushCard(state: ArbiterState, card: Card): ArbiterState {
102 const others = state.cards.filter(one => one.id !== card.id)
103 const cards = [...others, card]
104 return { ...state, cards: cards.length > MAX_CARDS ? dropLowest(cards) : cards }
105}
106
107function dropLowest(cards: Card[]): Card[] {
108 // Over the cap: the oldest of the lowest rank goes first, so a meeting is never pushed out.
109 const sorted = [...cards].sort((a, b) => CARD_RANK[a.kind] - CARD_RANK[b.kind] || a.at - b.at)
110 const gone = sorted[0]
111 return cards.filter(one => one !== gone)
112}
113
114export function dropCard(state: ArbiterState, id: string): ArbiterState {
115 return state.cards.some(one => one.id === id) ? { ...state, cards: state.cards.filter(one => one.id !== id) } : state
116}
117
118/** Every card of these kinds goes (a feature was switched off). */
119export function dropCardsOf(state: ArbiterState, kinds: readonly CardKind[]): ArbiterState {
120 return state.cards.some(one => kinds.includes(one.kind)) ? { ...state, cards: state.cards.filter(one => !kinds.includes(one.kind)) } : state
121}
122
123export function pruneCards(state: ArbiterState, now: number): ArbiterState {
124 const cards = state.cards.filter(one => one.expiresAt === undefined || one.expiresAt > now)
125 return cards.length === state.cards.length ? state : { ...state, cards }
126}
127
128export type BandContext = {
129 now: number
130 /** No turn is running. */
131 isIdle: boolean
132 /** The app shows a survey there: we show nothing. */
133 hasSurvey: boolean
134}
135
136/** The one card to show now, or null. A lower card waits; it never shows on top of a higher one. */
137export function currentCard(state: ArbiterState, context: BandContext): Card | null {
138 if (context.hasSurvey) return null
139 const eligible = state.cards.filter(one =>
140 (one.expiresAt === undefined || one.expiresAt > context.now) && (one.kind !== 'reflect' || context.isIdle))
141 if (eligible.length === 0) return null
142 return [...eligible].sort((a, b) => CARD_RANK[b.kind] - CARD_RANK[a.kind] || a.at - b.at)[0] ?? null
143}
144
145export type StatusParts = {
146 /** A flow is running (Agents progress row). */
147 hasAgents: boolean
148 /** Inbox is on: the bell's unread count; undefined when Inbox is off. */
149 bellCount?: number
150}
151
152export type BandPlan = {
153 card: Card | null
154 status: StatusParts
155 /** False: draw nothing at all, pass the band on (`next(e)`). */
156 isShown: boolean
157}
158
159/** What the band above the prompt shows. Quiet means no band at all: a bell with 0 alone is omitted. */
160export function bandPlan(state: ArbiterState, context: BandContext, status: StatusParts): BandPlan {
161 if (context.hasSurvey) return { card: null, status, isShown: false }
162 const card = currentCard(state, context)
163 const isShown = card !== null || status.hasAgents || (status.bellCount ?? 0) > 0
164 return { card, status, isShown }
165}
166hooks/core/features.ts 123 lines1// The features Switchboard is made of, and the slash commands that reach them. Nothing here calls
2// the engine: register.tsx reads these tables. Each feature is separate; none depends on another.
3
4import type { FeatureId } from '../../types/index.d.ts'
5
6export type { FeatureId }
7
8/** Theme color names (see theme.ts) a feature's icon tile uses. */
9export type Tone = 'amber' | 'orange' | 'teal' | 'pink' | 'red' | 'green' | 'blue' | 'faint'
10
11export type Feature = {
12 id: FeatureId
13 name: string
14 /** The command the Switchboard pane shows for it. */
15 command: string
16 description: string
17 tone: Tone
18 /** The build phase that brings it (BUILD_PLAN.md section 6). 0 means it works now. */
19 phase: number
20 /** Surfaces it can run on; absent means every one. */
21 surfaces?: readonly string[]
22 isOnByDefault: boolean
23}
24
25/** In the order the Switchboard pane lists them (screen 2). */
26export const FEATURES: readonly Feature[] = [
27 { id: 'inbox', name: 'Inbox', command: '/sb-inbox', tone: 'amber', phase: 7, isOnByDefault: true,
28 description: 'Slack, Gmail, Calendar, GitHub, Jira · own session on Haiku' },
29 { id: 'agents', name: 'Agents', command: '/sb-agents', tone: 'orange', phase: 4, isOnByDefault: true,
30 description: 'Watch subagents work: steps, time and cost' },
31 { id: 'files', name: 'Files', command: '/sb-files', tone: 'teal', phase: 1, isOnByDefault: true,
32 description: 'Your project, live with what Claude reads and edits' },
33 { id: 'replay', name: 'Replay', command: '/sb-replay', tone: 'pink', phase: 2, isOnByDefault: true,
34 description: "Step through each turn's edits, one at a time" },
35 { id: 'blast', name: 'Blast Radius', command: '/sb-blast', tone: 'red', phase: 3, isOnByDefault: true,
36 description: 'Holds commands that delete or overwrite things' },
37 { id: 'reflect', name: 'Reflect', command: '/sb-reflect', tone: 'green', phase: 5, isOnByDefault: true,
38 description: 'Turns your corrections into rules for Claude' },
39 { id: 'cache', name: 'Cache Tax', command: '/sb-cache', tone: 'orange', phase: 6, isOnByDefault: true,
40 description: 'Holds a costly message when the cache has gone cold' },
41 { id: 'browser', name: 'Browser', command: '/sb-browser', tone: 'faint', phase: 11, isOnByDefault: true,
42 surfaces: ['terminal'], description: 'Live web pages in the side pane · kitty or Ghostty only' },
43]
44
45export function featureById(id: FeatureId): Feature {
46 const found = FEATURES.find(feature => feature.id === id)
47 if (!found) throw new Error(`unknown feature ${id}`)
48 return found
49}
50
51/** Whether a feature can run where this session is drawn (Browser needs the terminal). */
52export function isAvailable(feature: Feature, surfaces: readonly string[]): boolean {
53 return !feature.surfaces || feature.surfaces.some(surface => surfaces.includes(surface))
54}
55
56/** Where a command may run. */
57export type CommandScope = 'any' | 'inbox-session' | 'terminal'
58
59export type CommandDef = {
60 name: string
61 /** The feature it belongs to; null for `/switchboard`, which is always there. */
62 feature: FeatureId | null
63 description: string
64 argumentHint?: string
65 /** Runs while a turn is going (BUILD_PLAN.md 4.5). */
66 isImmediate: boolean
67 scope: CommandScope
68}
69
70export const COMMANDS: readonly CommandDef[] = [
71 { name: 'switchboard', feature: null, isImmediate: true, scope: 'any',
72 description: 'Switchboard: turn features on or off and see what each is doing' },
73 { name: 'sb-files', feature: 'files', isImmediate: true, scope: 'any', argumentHint: '[path | auto on|off]',
74 description: 'Files: your project as a live tree, with what Claude reads and edits' },
75 { name: 'sb-replay', feature: 'replay', isImmediate: true, scope: 'any', argumentHint: '[turn]',
76 description: "Replay: step through a turn's edits one by one" },
77 { name: 'sb-blast', feature: 'blast', isImmediate: true, scope: 'any', argumentHint: '[wait 1m | wait none | off 1h | on]',
78 description: 'Blast Radius: held commands, history and pause' },
79 { name: 'sb-agents', feature: 'agents', isImmediate: true, scope: 'any',
80 description: 'Agents: show the subagents working now, with model, context, cost and time' },
81 { name: 'sb-reflect', feature: 'reflect', isImmediate: true, scope: 'any', argumentHint: '[pause | resume]',
82 description: 'Reflect: rules waiting for your review' },
83 { name: 'sb-cache', feature: 'cache', isImmediate: true, scope: 'any', argumentHint: '[status | warm 6h|always|off | guard hold|warn]',
84 description: 'Cache Tax: cache status, keep-warm and the guard' },
85 { name: 'sb-inbox', feature: 'inbox', isImmediate: true, scope: 'any', argumentHint: '[here | sources | demo on|off | restart]',
86 description: 'Inbox: open it, its sources, or make this the Inbox session' },
87 { name: 'sb-auto', feature: 'inbox', isImmediate: false, scope: 'inbox-session', argumentHint: '<person>: <what to do> | rules | on | off | resume | delete <n>',
88 description: 'Auto: set up a rule that replies, reviews or books for you, or open the Auto pane' },
89 { name: 'sb-pause', feature: 'inbox', isImmediate: true, scope: 'any', argumentHint: '[1h]',
90 description: 'Auto: pause every rule and running task' },
91 { name: 'sb-browser', feature: 'browser', isImmediate: true, scope: 'terminal', argumentHint: '<url>',
92 description: 'Browser: a live web page in the side pane' },
93]
94
95export type CommandContext = {
96 enabled: ReadonlySet<FeatureId>
97 isInboxSession: boolean
98 surfaces: readonly string[]
99}
100
101/** Why a command can't run here, or undefined when it can. The same words the menu hides it for. */
102export function blockedReason(command: CommandDef, context: CommandContext): string | undefined {
103 if (command.scope === 'terminal' && !context.surfaces.includes('terminal')) {
104 return `/${command.name} only works in a kitty or Ghostty terminal.`
105 }
106 if (command.scope === 'inbox-session' && !context.isInboxSession) {
107 return `/${command.name} works in the Inbox session. Run /sb-inbox to open or make it.`
108 }
109 if (command.feature && !context.enabled.has(command.feature)) {
110 return `${featureById(command.feature).name} is off. Turn it on in /switchboard.`
111 }
112 return undefined
113}
114
115/** Hidden from the typeahead whenever it can't run (BUILD_PLAN.md 4.5). */
116export function isHidden(command: CommandDef, context: CommandContext): boolean {
117 return blockedReason(command, context) !== undefined
118}
119
120export function commandByName(name: string): CommandDef | undefined {
121 return COMMANDS.find(command => command.name === name)
122}
123hooks/core/store.ts 83 lines1// What Switchboard keeps across sessions in `$.store`, as typed keys with defaults and migrations.
2// Pure: register.tsx does the reads and writes. Secrets never go here (BUILD_PLAN.md 5.2).
3
4import type { FeatureId, Settings } from '../../types/index.d.ts'
5import { FEATURES } from './features.ts'
6
7export type { Settings }
8
9export const SETTINGS_KEY = 'settings'
10export const SETTINGS_VERSION = 2 as const
11/** We use at most 1 MiB of the store's 4 MiB (BUILD_PLAN.md 5.2). */
12
13/** Blast Radius waits this long for an answer, then runs the command (your call, 2026-10-12). */
14export const BLAST_WAIT_MS = 60_000
15
16export function defaultSettings(): Settings {
17 const features = {} as Record<FeatureId, boolean>
18 for (const feature of FEATURES) features[feature.id] = feature.isOnByDefault
19 return {
20 version: SETTINGS_VERSION,
21 features,
22 autoOpen: { agents: true, files: true },
23 blastWaitMs: BLAST_WAIT_MS,
24 quietHours: { from: '20:00', to: '09:00' },
25 alerts: { leadMin: 5, isSound: true, isNotify: true, isQuiet: true },
26 }
27}
28
29/** The meeting card's lead times to choose from (BUILD_PLAN.md P8). */
30export const LEAD_CHOICES = [1, 3, 5, 10] as const
31
32const isRecord = (value: unknown): value is Record<string, unknown> =>
33 typeof value === 'object' && value !== null && !Array.isArray(value)
34
35const isClock = (value: unknown): value is string =>
36 typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
37
38/**
39 * Reads whatever the store held into valid settings: unknown keys dropped, wrong types replaced
40 * by defaults, features added since filled in. Never throws, so a damaged store can't stop a session.
41 */
42export function readSettings(raw: unknown): Settings {
43 const settings = defaultSettings()
44 if (!isRecord(raw)) return settings
45 if (isRecord(raw.features)) {
46 for (const feature of FEATURES) {
47 const value = raw.features[feature.id]
48 if (typeof value === 'boolean') settings.features[feature.id] = value
49 }
50 }
51 if (isRecord(raw.autoOpen)) {
52 if (typeof raw.autoOpen.agents === 'boolean') settings.autoOpen.agents = raw.autoOpen.agents
53 // Version 1 kept Files' old default (off); from version 2 (and any newer) your choice stands.
54 if (typeof raw.autoOpen.files === 'boolean' && typeof raw.version === 'number' && raw.version >= SETTINGS_VERSION) settings.autoOpen.files = raw.autoOpen.files
55 }
56 if (raw.blastWaitMs === null) settings.blastWaitMs = null
57 else if (typeof raw.blastWaitMs === 'number' && raw.blastWaitMs >= 5_000 && raw.blastWaitMs <= 24 * 3_600_000) settings.blastWaitMs = raw.blastWaitMs
58 if (isRecord(raw.quietHours) && isClock(raw.quietHours.from) && isClock(raw.quietHours.to)) {
59 settings.quietHours = { from: raw.quietHours.from, to: raw.quietHours.to }
60 }
61 if (typeof raw.inboxSessionId === 'string' && raw.inboxSessionId.length > 0 && raw.inboxSessionId.length <= 200) {
62 settings.inboxSessionId = raw.inboxSessionId
63 }
64 if (raw.inboxDemo === true) settings.inboxDemo = true
65 if (isRecord(raw.alerts)) {
66 const alerts = raw.alerts
67 if (typeof alerts.leadMin === 'number' && (LEAD_CHOICES as readonly number[]).includes(alerts.leadMin)) settings.alerts.leadMin = alerts.leadMin
68 if (typeof alerts.isSound === 'boolean') settings.alerts.isSound = alerts.isSound
69 if (typeof alerts.isNotify === 'boolean') settings.alerts.isNotify = alerts.isNotify
70 if (typeof alerts.isQuiet === 'boolean') settings.alerts.isQuiet = alerts.isQuiet
71 }
72 return settings
73}
74
75export function withFeature(settings: Settings, id: FeatureId, isOn: boolean): Settings {
76 return { ...settings, features: { ...settings.features, [id]: isOn } }
77}
78
79export function enabledFeatures(settings: Settings): Set<FeatureId> {
80 return new Set(FEATURES.filter(feature => settings.features[feature.id]).map(feature => feature.id))
81}
82
83hooks/features/blast/classify.ts 255 lines1// Blast Radius: which shell commands are risky enough to hold. Pure.
2// Ported from blast-radius (anthropics/claude-code-playground, Apache-2.0, Copyright 2026 Anthropic
3// PBC): the segment walk, cd/pushd tracking, sudo and prefix stripping, and the rm, git and
4// migration rules. Added: `find -delete` / `-exec rm`, `xargs rm`, `bash -c "<inner>"` (classified
5// again), `timeout N`, `doas`, and targets that use a variable.
6
7export type RiskKind = 'rm' | 'find-delete' | 'xargs-rm' | 'git-reset' | 'git-clean' | 'git-push-force' | 'git-checkout' | 'migrate'
8
9export type Risk = {
10 kind: RiskKind
11 /** The risky part of the command as typed ("rm -rf build"). */
12 label: string
13 /** Where it runs, relative to the session folder (a `cd` before it), or null for the session folder. */
14 dir: string | null
15 /** rm: its paths. find: its starting paths. */
16 targets: string[]
17 /** git: the words after the subcommand. find: the whole expression after the paths. */
18 args: string[]
19 /** migrate: which tool. */
20 tool: string
21 /** A target is a variable or a substitution: it can't be measured, but it is still held. */
22 usesVariable: boolean
23}
24
25const SUDO_VALUE_OPTIONS = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U'])
26const DOAS_VALUE_OPTIONS = new Set(['-u', '-C'])
27const TIMEOUT_VALUE_OPTIONS = new Set(['-s', '-k', '--signal', '--kill-after'])
28const READ_ONLY = new Set(['ls', 'cat', 'echo', 'printf', 'grep', 'rg', 'find', 'less', 'head', 'tail', 'cd', 'git'])
29const PREFIXES = new Set(['command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!'])
30const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash'])
31
32const isVariable = (word: string) => /\$|`/.test(word)
33
34/** A segment that only sets variables (`A=1 B="x y"`), as name and value pairs; null otherwise. */
35function assignments(segment: string): [string, string][] | null {
36 const words = tokenize(segment.trim().replace(/^export\s+/, ''))
37 if (!words.length || !words.every(word => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word))) return null
38 return words.map(word => {
39 const at = word.indexOf('=')
40 return [word.slice(0, at), word.slice(at + 1)]
41 })
42}
43
44/** `$NAME` and `${NAME}` replaced with values set earlier on the line; a value that itself uses `$` or a backtick stays as it is. */
45function withVars(segment: string, vars: ReadonlyMap<string, string>): string {
46 if (!vars.size) return segment
47 return segment.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, braced: string | undefined, bare: string | undefined) => {
48 const value = vars.get(braced ?? bare ?? '')
49 return value !== undefined && !isVariable(value) ? value : whole
50 })
51}
52const cut = (text: string) => (text.length > 80 ? `${text.slice(0, 79)}…` : text)
53
54/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
55export function tokenize(text: string): string[] {
56 const words: string[] = []
57 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
58 let match: RegExpExecArray | null
59 while ((match = re.exec(text)) !== null) words.push(match[1] ?? match[2] ?? match[3] ?? '')
60 return words
61}
62
63function joinDir(dir: string | null, arg: string | undefined): string {
64 if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) return arg ?? '~'
65 return dir ? `${dir}/${arg}` : arg
66}
67
68/**
69 * Splits a command line into segments at `&&`, `||`, `;`, `|` and newlines, outside quotes (so a
70 * `bash -c "a && b"` stays one segment and is classified again inside).
71 */
72export function segments(command: string): string[] {
73 const out: string[] = []
74 let current = ''
75 let quote = ''
76 for (let i = 0; i < command.length; i++) {
77 const char = command[i]!
78 if (quote) {
79 current += char
80 if (char === '\\' && quote === '"' && i + 1 < command.length) current += command[++i]
81 else if (char === quote) quote = ''
82 continue
83 }
84 if (char === '"' || char === "'") {
85 quote = char
86 current += char
87 continue
88 }
89 const two = command.slice(i, i + 2)
90 if (two === '&&' || two === '||') {
91 out.push(current)
92 current = ''
93 i++
94 continue
95 }
96 if (char === ';' || char === '|' || char === '\n') {
97 out.push(current)
98 current = ''
99 continue
100 }
101 current += char
102 }
103 out.push(current)
104 return out.filter(segment => segment.trim())
105}
106
107/** The first risky segment of a shell command, or null. */
108export function classify(command: string, depth = 0): Risk | null {
109 if (depth > 3) return null
110 let dir: string | null = null
111 const scopes: (string | null)[] = []
112 const pushed: (string | null)[] = []
113 // `S=/tmp/x; rm -rf $S/out`: a variable set earlier on the line is read in, so it can be measured.
114 const vars = new Map<string, string>()
115 for (const original of segments(command)) {
116 const raw = withVars(original, vars)
117 const assigned = assignments(raw)
118 if (assigned) {
119 for (const [name, value] of assigned) vars.set(name, value)
120 continue
121 }
122 const opens = raw.match(/^\s*\(+/)?.[0].trim().length ?? 0
123 const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, '')
124 const closes = tail.match(/\)+\s*$/)?.[0].trim().length ?? 0
125 for (let k = 0; k < opens; k++) scopes.push(dir)
126 const found = classifySegment(raw, dir, pushed, depth)
127 if (found && 'kind' in found) return found
128 if (found) dir = found.cd
129 for (let k = 0; k < closes && scopes.length; k++) dir = scopes.pop() ?? null
130 }
131 return null
132}
133
134/** Drops what can stand before the real command without changing it: VAR=x, sudo, doas, timeout, nice ... */
135function stripPrefixes(words: string[]): string[] {
136 const out = [...words]
137 for (let guard = 0; guard < 8; guard++) {
138 const before = out.length
139 while (out.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(out[0]!)) out.shift()
140 if (out[0] === 'sudo' || out[0] === 'doas') {
141 const values = out[0] === 'sudo' ? SUDO_VALUE_OPTIONS : DOAS_VALUE_OPTIONS
142 out.shift()
143 while (out.length && out[0]!.startsWith('-')) {
144 const option = out.shift()!
145 if (values.has(option)) out.shift()
146 }
147 }
148 while (out.length && PREFIXES.has(out[0]!)) out.shift()
149 if (out[0] === 'nice') {
150 out.shift()
151 const option: string | undefined = out[0]
152 if (option === '-n') out.splice(0, 2)
153 else if (/^-\d+$/.test(option ?? '')) out.shift()
154 }
155 if (out[0] === 'timeout') {
156 out.shift()
157 while (out.length && out[0]!.startsWith('-')) {
158 const option = out.shift()!
159 if (TIMEOUT_VALUE_OPTIONS.has(option)) out.shift()
160 }
161 out.shift() // the duration
162 }
163 if (out.length === before) break
164 }
165 return out
166}
167
168function classifySegment(segment: string, dir: string | null, pushed: (string | null)[], depth: number): Risk | { cd: string | null } | null {
169 const text = segment.trim().replace(/^[({]+\s*/, '').replace(/\s*[)}]+$/, '')
170 const words = stripPrefixes(tokenize(text))
171 const [first, ...args] = words
172 if (first === undefined) return null
173 const cmd = first.replace(/^\\/, '').replace(/^.*\//, '')
174 const risk = (kind: RiskKind, extra: Partial<Risk> = {}): Risk =>
175 ({ kind, label: cut(text), dir, targets: [], args: [], tool: '', usesVariable: false, ...extra })
176
177 if (cmd === 'cd') return { cd: args[0] === '-' ? '-' : joinDir(dir, args[0]) }
178 if (cmd === 'pushd') {
179 pushed.push(dir)
180 return { cd: joinDir(dir, args[0]) }
181 }
182 if (cmd === 'popd') return { cd: pushed.length ? pushed.pop() ?? null : '-' }
183
184 // bash -c "<inner>": the inner command is classified on its own.
185 if (SHELLS.has(cmd)) {
186 const at = args.findIndex(arg => arg === '-c' || /^-[a-z]*c$/.test(arg))
187 const inner = at >= 0 ? args[at + 1] : undefined
188 if (inner) {
189 const found = classify(inner, depth + 1)
190 return found ? { ...found, dir: found.dir ?? dir } : null
191 }
192 return null
193 }
194
195 if (cmd === 'rm') {
196 const flags = args.filter(arg => arg.startsWith('-') && arg !== '-')
197 const isRecursive = flags.some(flag => flag === '--recursive' || (/^-[^-]/.test(flag) && /[rR]/.test(flag)))
198 const isForce = flags.some(flag => flag === '--force' || (/^-[^-]/.test(flag) && flag.includes('f')))
199 if (isRecursive || isForce) {
200 const targets = args.filter(arg => !arg.startsWith('-') || arg === '-')
201 return risk('rm', { targets, usesVariable: targets.some(isVariable) })
202 }
203 }
204
205 if (cmd === 'find') {
206 const expressionAt = args.findIndex(arg => arg.startsWith('-') || arg === '(' || arg === '!' || arg === '\\(')
207 const targets = expressionAt < 0 ? args : args.slice(0, expressionAt)
208 const expression = expressionAt < 0 ? [] : args.slice(expressionAt)
209 const deletes = expression.includes('-delete')
210 const execAt = expression.findIndex(arg => arg === '-exec' || arg === '-execdir' || arg === '-ok')
211 const execsRm = execAt >= 0 && /(^|\/)rm$/.test(expression[execAt + 1] ?? '')
212 if (deletes || execsRm) {
213 return risk('find-delete', { targets: targets.length ? targets : ['.'], args: expression, usesVariable: args.some(isVariable) })
214 }
215 }
216
217 if (cmd === 'xargs') {
218 // xargs' own options come first; the command it runs follows.
219 let at = 0
220 while (at < args.length && args[at]!.startsWith('-')) at += /^-[IEdLnPs]$/.test(args[at]!) ? 2 : 1
221 const runs = stripPrefixes(args.slice(at))
222 if ((runs[0] ?? '').replace(/^.*\//, '') === 'rm') return risk('xargs-rm', { usesVariable: true })
223 }
224
225 if (cmd === 'git') {
226 let gitDir = dir
227 let i = 0
228 while (i < args.length && args[i]!.startsWith('-')) {
229 if (args[i] === '-C' && i + 1 < args.length) {
230 gitDir = joinDir(gitDir, args[i + 1])
231 i += 2
232 } else if (args[i] === '-c' && i + 1 < args.length) i += 2
233 else i += 1
234 }
235 const sub = args[i]
236 const rest = args.slice(i + 1)
237 const git = (kind: RiskKind) => risk(kind, { args: rest, dir: gitDir })
238 if (sub === 'reset' && rest.includes('--hard')) return git('git-reset')
239 if (sub === 'clean' && !rest.some(arg => arg === '-n' || arg === '--dry-run')) return git('git-clean')
240 if (sub === 'push' && rest.some(arg => arg === '--force' || arg === '-f' || arg.startsWith('--force-with-lease') || /^\+/.test(arg))) {
241 return git('git-push-force')
242 }
243 const isStagedOnly = sub === 'restore' && rest.includes('--staged') && !rest.includes('--worktree') && !rest.includes('-W')
244 if ((sub === 'checkout' || sub === 'restore') && rest.includes('.') && !isStagedOnly) return git('git-checkout')
245 }
246
247 const joined = words.join(' ')
248 if (/\balembic\s+upgrade\b/.test(joined)) return risk('migrate', { tool: 'alembic' })
249 if (/\bdb:migrate(?!:status\b)/.test(joined)) return risk('migrate', { tool: 'rails' })
250 if (/\bprisma\s+migrate\s+(deploy|dev|reset)\b/.test(joined)) return risk('migrate', { tool: 'prisma' })
251 if (/\bmanage\.py\s+migrate\b/.test(joined)) return risk('migrate', { tool: 'django' })
252 if (!READ_ONLY.has(cmd) && args.includes('migrate')) return risk('migrate', { tool: 'unknown' })
253 return null
254}
255hooks/features/blast/measure.ts 250 lines1// Blast Radius: what a held command would change, as the report the pane draws (screen F5). The
2// shell scripts take every path as an argument, never as source; register.tsx runs them with
3// `$.process.run` (time there is free under the hook budget) and hands the output here. Pure.
4// The rm, git and migration measures follow blast-radius (Apache-2.0); per-folder sizes, the largest
5// files, the tracked check and find's dry run are new.
6
7import type { BlastReport, BlastRow } from '../../../types/index.d.ts'
8import { basename, dirname } from '../files/paths.ts'
9import type { Risk } from './classify.ts'
10
11export type { BlastReport, BlastRow }
12
13/** Rows the card shows before "Show more". */
14export const SHOWN_ROWS = 8
15
16/**
17 * Expands the paths (globs by compgen, no command substitution), then prints:
18 * `T files bytes found links`, `D count bytes folder` per folder, `F bytes path` for the 50 largest
19 * files, and `G n` (paths git tracks; -1 outside git). Written with `@{` for bash's `${`, which a
20 * template literal would read as its own.
21 */
22export const RM_SCRIPT = String.raw`
23shopt -s nullglob dotglob
24paths=()
25for p in "$@"; do
26 case "$p" in "~"|"~/"*) p="$HOME@{p#\~}";; esac
27 if [[ "$p" == *[*?[]* ]]; then
28 while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
29 elif [[ -e "$p" || -L "$p" ]]; then
30 paths+=("$p")
31 fi
32done
33if (( @{#paths[@]} == 0 )); then printf 'T\t0\t0\t0\t0\n'; exit 0; fi
34for i in "@{!paths[@]}"; do case "@{paths[$i]}" in /*) ;; *) paths[$i]="./@{paths[$i]}";; esac; done
35tmp=$(mktemp "@{TMPDIR:-/tmp}/sb-blast.XXXXXX") || exit 1
36trap 'rm -f "$tmp"' EXIT
37links=$(find "@{paths[@]}" -type l 2>/dev/null | wc -l | tr -d ' ')
38find "@{paths[@]}" -type f -exec wc -c {} + 2>/dev/null | awk -v out="$tmp" -v found="@{#paths[@]}" -v links="$links" '
39{ n = $1; p = $0; sub(/^[ \t]*[0-9]+[ \t]/, "", p); if (p == "total") next
40 files++; bytes += n; d = p; if (!sub(/\/[^\/]*$/, "", d)) d = "."; dc[d]++; db[d] += n
41 printf "F\t%.0f\t%s\n", n, p > out }
42END { printf "T\t%.0f\t%.0f\t%d\t%d\n", files, bytes, found, links; for (d in dc) printf "D\t%.0f\t%.0f\t%s\n", dc[d], db[d], d }'
43sort -t "$(printf '\t')" -k2,2nr "$tmp" | head -n 50
44if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
45 printf 'G\t%s\n' "$(git ls-files -- "@{paths[@]}" 2>/dev/null | wc -l | tr -d ' ')"
46else
47 printf 'G\t-1\n'
48fi
49`.replaceAll('@{', '${')
50
51export type RmMeasure = {
52 files: number
53 bytes: number
54 /** Paths that exist after expansion. */
55 found: number
56 links: number
57 folders: { path: string; count: number; bytes: number }[]
58 largest: { path: string; bytes: number }[]
59 /** Files git tracks among them; -1 outside git. */
60 tracked: number
61}
62
63const clean = (path: string) => path.replace(/^\.\//, '')
64
65export function parseRm(stdout: string): RmMeasure {
66 const out: RmMeasure = { files: 0, bytes: 0, found: 0, links: 0, folders: [], largest: [], tracked: -1 }
67 for (const line of stdout.split('\n')) {
68 const [tag, a, b, c, d] = line.split('\t')
69 if (tag === 'T') Object.assign(out, { files: Number(a), bytes: Number(b), found: Number(c), links: Number(d ?? 0) })
70 else if (tag === 'D' && c !== undefined) out.folders.push({ count: Number(a), bytes: Number(b), path: clean(line.split('\t').slice(3).join('\t')) })
71 else if (tag === 'F' && b !== undefined) out.largest.push({ bytes: Number(a), path: clean(line.split('\t').slice(2).join('\t')) })
72 else if (tag === 'G') out.tracked = Number(a)
73 }
74 out.folders.sort((x, y) => y.bytes - x.bytes)
75 return out
76}
77
78/** "812 B", "4.2 KB", "286 KB". */
79export function size(bytes: number): string {
80 if (!Number.isFinite(bytes) || bytes < 1024) return `${Math.max(0, Math.round(bytes || 0))} B`
81 const units = ['KB', 'MB', 'GB', 'TB']
82 let value = bytes / 1024
83 let unit = 0
84 while (value >= 1024 && unit < units.length - 1) {
85 value /= 1024
86 unit++
87 }
88 return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`
89}
90
91const plural = (count: number, one: string, many = `${one}s`) => `${count} ${count === 1 ? one : many}`
92
93type Base = Pick<BlastReport, 'command' | 'where'>
94
95function report(base: Base, fields: Partial<BlastReport>): BlastReport {
96 return { ...base, verb: '', amount: '', warn: '', rows: [], more: 0, moreBytes: -1, note: '', action: 'Run it', isEmpty: false, ...fields }
97}
98
99/** rm and find -delete: folders by size with their largest files under each; bars against the total. */
100export function rmReport(base: Base, measure: RmMeasure, isPartial = false): BlastReport {
101 if (!measure.found) {
102 return report(base, { verb: 'deletes', amount: 'nothing', note: 'None of its paths exist.', isEmpty: true })
103 }
104 const total = Math.max(1, measure.bytes)
105 const rows: BlastRow[] = []
106 const listed = new Set<string>()
107 for (const folder of measure.folders.slice(0, 20)) {
108 const name = folder.path === '.' ? './' : `${folder.path}/`
109 rows.push({ kind: 'folder', name, path: folder.path, isIndented: false, count: plural(folder.count, 'file'), size: size(folder.bytes),
110 bar: folder.bytes / total })
111 for (const file of measure.largest.filter(one => dirname(one.path) === folder.path || (folder.path === '.' && !one.path.includes('/')))) {
112 listed.add(file.path)
113 rows.push({ kind: 'file', name: basename(file.path), path: file.path, isIndented: true, count: '', size: size(file.bytes), bar: file.bytes / total })
114 }
115 }
116 const files = measure.files + measure.links
117 const amount = files ? `${isPartial ? 'at least ' : ''}${plural(files, 'file')} · ${size(measure.bytes)}` : `${plural(measure.found, 'empty folder')}`
118 const warn = measure.tracked === 0 || measure.tracked === -1
119 ? `None of these are tracked by git, so they can't be restored.`
120 : measure.tracked < files ? `Only ${measure.tracked} of these are tracked by git; the rest can't be restored.` : ''
121 const shownBytes = measure.largest.filter(one => listed.has(one.path)).reduce((sum, one) => sum + one.bytes, 0)
122 return report(base, {
123 verb: 'will delete', amount, warn, rows, more: Math.max(0, files - listed.size), moreBytes: Math.max(0, measure.bytes - shownBytes),
124 action: files ? `Delete ${plural(files, 'file')}` : 'Delete', note: isPartial ? 'Measuring took too long; these are the files found so far.' : '',
125 })
126}
127
128/** What can't be measured is still held, and says why. */
129export function unmeasuredReport(base: Base, why: string): BlastReport {
130 return report(base, { verb: 'will run', amount: 'unmeasured', note: why, action: 'Run it anyway' })
131}
132
133/** `find ... -delete` or `-exec rm`: the same find with `-print` in their place, to list what it would remove. */
134/**
135 * The topmost of the paths a find lists (a folder takes what is under it), at most `cap`, and whether
136 * more were cut. Linear: each path checks its own parents, so 100,000 paths take milliseconds.
137 */
138export function topmost(paths: readonly string[], cap = 2_000): { top: string[]; isPartial: boolean } {
139 const listed = new Set(paths.map(path => path.replace(/\/+$/, '')).filter(Boolean))
140 // Kept when no folder above it is listed too (find -delete lists children before their folder).
141 const top = [...listed].filter(path => {
142 const parts = path.split('/')
143 for (let i = 1; i < parts.length; i++) if (listed.has(parts.slice(0, i).join('/'))) return false
144 return true
145 })
146 return { top: top.slice(0, cap), isPartial: top.length > cap }
147}
148
149export function findDryRun(expression: readonly string[]): string[] {
150 const out: string[] = []
151 for (let i = 0; i < expression.length; i++) {
152 const word = expression[i]!
153 if (word === '-delete') out.push('-print')
154 else if (word === '-exec' || word === '-execdir' || word === '-ok' || word === '-okdir') {
155 while (i < expression.length && !/^(\\?;|\+)$/.test(expression[i]!)) i++
156 out.push('-print')
157 } else out.push(word)
158 }
159 return out
160}
161
162/** Numstat rows (`git diff --numstat --relative`): dirty files with their line counts. */
163export function dirtyReport(base: Base, numstat: string, isReset: boolean): BlastReport {
164 const rows: BlastRow[] = []
165 let add = 0
166 let del = 0
167 for (const line of numstat.split('\n')) {
168 const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/)
169 if (!match) continue
170 const a = match[1] === '-' ? 0 : Number(match[1])
171 const d = match[2] === '-' ? 0 : Number(match[2])
172 add += a
173 del += d
174 rows.push({ kind: 'file', name: basename(match[3]!), path: match[3]!, isIndented: false,
175 count: match[1] === '-' ? 'binary' : `+${a} −${d}`, size: dirname(match[3]!) === '.' ? '' : dirname(match[3]!), bar: -1 })
176 }
177 if (!rows.length) return report(base, { verb: 'discards', amount: 'nothing', note: 'There are no uncommitted changes.', isEmpty: true })
178 return report(base, {
179 verb: 'will discard', amount: `changes in ${plural(rows.length, 'file')} · +${add} −${del}`, rows,
180 warn: isReset ? 'Staged and unstaged changes go, and uncommitted work can\'t be restored.' : 'Unstaged changes go, and they can\'t be restored.',
181 action: 'Discard changes',
182 })
183}
184
185/** `git clean -n` output: what it would remove. */
186export function cleanReport(base: Base, dryRun: string): BlastReport {
187 const gone = dryRun.split('\n').filter(line => line.startsWith('Would remove ')).map(line => line.slice('Would remove '.length))
188 if (!gone.length) return report(base, { verb: 'removes', amount: 'nothing', note: 'No untracked files match.', isEmpty: true })
189 return report(base, {
190 verb: 'will remove', amount: `${plural(gone.length, 'untracked path')}`, warn: 'Untracked files aren\'t in git, so they can\'t be restored.',
191 rows: gone.map(path => ({ kind: path.endsWith('/') ? 'folder' : 'file', name: path, path: path.replace(/\/$/, ''), isIndented: false,
192 count: '', size: '', bar: -1 })),
193 action: `Remove ${plural(gone.length, 'path')}`, note: 'From git clean -n.',
194 })
195}
196
197/** Where a force push lands, from its arguments: remote, the ref pushed, the branch it overwrites. */
198export function pushTarget(args: readonly string[]): { remote: string; source: string; branch: string } {
199 const positional = args.filter(arg => !arg.startsWith('-'))
200 const remote = positional[0] ?? 'origin'
201 const spec = (positional[1] ?? '').replace(/^\+/, '')
202 const [source, branch] = spec.includes(':') ? spec.split(':') as [string, string] : [spec, spec]
203 return { remote, source: source || 'HEAD', branch: (branch ?? '').replace(/^refs\/heads\//, '') }
204}
205
206/** `git log --oneline <source>..<remote>/<branch>`: the remote commits the push would drop. */
207export function pushReport(base: Base, ref: string, log: string | null): BlastReport {
208 if (log === null) {
209 return report(base, { verb: 'will overwrite', amount: ref, action: 'Force push',
210 note: `There's no local copy of ${ref}, so the commits it would drop can't be listed. Run git fetch first.` })
211 }
212 const dropped = log.split('\n').filter(Boolean)
213 if (!dropped.length) return report(base, { verb: 'drops', amount: `no commits on ${ref}`, isEmpty: true })
214 return report(base, {
215 verb: 'will drop', amount: `${plural(dropped.length, 'commit')} on ${ref}`,
216 warn: 'Anyone who has these commits will have to recover them by hand.',
217 rows: dropped.map(line => ({ kind: 'commit', name: line, path: line.slice(0, 12), isIndented: false, count: '', size: '', bar: -1 })),
218 action: 'Force push', note: `As of the last fetch.`,
219 })
220}
221
222export const MIGRATION_LISTERS: Record<string, { argv: string[]; isPending: (line: string) => boolean; strip: (line: string) => string }> = {
223 django: { argv: ['python3', 'manage.py', 'showmigrations', '--plan'], isPending: line => line.startsWith('[ ]'), strip: line => line.slice(4) },
224 alembic: { argv: ['alembic', 'history', '-r', 'current:head'], isPending: line => line.includes('->'), strip: line => line },
225 rails: { argv: ['bin/rails', 'db:migrate:status'], isPending: line => /^\s*down\b/.test(line), strip: line => line.trim() },
226 prisma: { argv: ['npx', '--no-install', 'prisma', 'migrate', 'status'], isPending: line => /^\s{2}\S/.test(line), strip: line => line.trim() },
227}
228
229export function migrateReport(base: Base, tool: string, listed: string | null): BlastReport {
230 const lister = MIGRATION_LISTERS[tool]
231 if (!lister || listed === null) {
232 return report(base, { verb: 'will run', amount: 'migrations', action: 'Run migrations',
233 note: lister ? `Couldn't list pending migrations (${lister.argv.join(' ')} failed).` : 'Pending migrations can\'t be listed for this tool.' })
234 }
235 const pending = listed.split('\n').filter(lister.isPending).map(lister.strip)
236 if (!pending.length) return report(base, { verb: 'applies', amount: 'nothing pending', isEmpty: true })
237 return report(base, {
238 verb: 'will apply', amount: plural(pending.length, 'migration'),
239 rows: pending.map(name => ({ kind: 'migration', name, path: name, isIndented: false, count: '', size: '', bar: -1 })),
240 action: 'Run migrations', note: `From ${lister.argv.join(' ')}.`,
241 })
242}
243
244/** The report for a risk that can't be measured at all. */
245export function cantMeasure(risk: Risk, base: Base): BlastReport | null {
246 if (risk.kind === 'xargs-rm') return unmeasuredReport(base, 'Its paths come from another command, so what it deletes can\'t be measured.')
247 if (risk.usesVariable) return unmeasuredReport(base, 'It uses a variable, so what it changes can\'t be measured.')
248 return null
249}
250hooks/features/blast/model.ts 120 lines1// Blast Radius's state: the one held command, the ones queued behind it, pauses and a short log.
2// Pure; register.tsx holds the tool call and keeps this in `$.state`.
3
4import type { BlastDecision, BlastHeld, BlastReport, BlastRow, BlastState } from '../../../types/index.d.ts'
5import { SHOWN_ROWS, size } from './measure.ts'
6
7export type { BlastDecision, BlastHeld, BlastState }
8
9/** The pane stays this long after a Delete so you see what went, then what was under it comes back. */
10export const CLOSE_AFTER_MS = 3_000
11const MAX_LOG = 20
12
13export function initialBlast(): BlastState {
14 return { held: null, queued: 0, pausedUntil: 0, log: [] }
15}
16
17export function hold(state: BlastState, id: string, kind: string, agent?: string): BlastState {
18 const held: BlastHeld = { id, kind, report: null, decision: null, phase: 'measuring', struck: [], isExpanded: false, isInBand: false,
19 autoRunAt: null, now: 0,
20 ...(agent ? { agent } : {}) }
21 return { ...state, held }
22}
23
24/** Only the call that is held now, and only once. */
25export function decide(state: BlastState, id: string, decision: BlastDecision): BlastState {
26 const held = state.held
27 if (!held || held.id !== id || held.decision !== null) return state
28 return { ...state, held: { ...held, decision } }
29}
30
31export function withHeld(state: BlastState, id: string, change: (held: BlastHeld) => BlastHeld): BlastState {
32 return state.held?.id === id ? { ...state, held: change(state.held) } : state
33}
34
35export function release(state: BlastState, id: string): BlastState {
36 return state.held?.id === id ? { ...state, held: null } : state
37}
38
39export function logged(state: BlastState, command: string, outcome: string, at: number): BlastState {
40 return { ...state, log: [{ command, outcome, at }, ...state.log].slice(0, MAX_LOG) }
41}
42
43/** What to tell Claude when the command is refused: never retry unless asked. */
44export function denyText(why: string, report: BlastReport | null): string {
45 const would = report && report.amount ? ` It would have: ${report.verb} ${report.amount}.` : ''
46 return `Blast Radius held this command and did not run it: ${why}.${would} Do not retry it unless the user asks you to.`
47}
48
49export const WHY: Record<string, string> = {
50 cancel: 'the user pressed Cancel',
51 closed: 'the user closed the Blast Radius pane',
52 interrupted: 'the turn was interrupted',
53 error: 'Blast Radius hit an error while holding it',
54}
55
56/** The rows on screen and what the card says about the rest. */
57export function visibleRows(report: BlastReport, isExpanded: boolean): { rows: BlastRow[]; hidden: number; moreLine: string } {
58 const rows = isExpanded ? report.rows : report.rows.slice(0, SHOWN_ROWS)
59 const moreLine = report.more > 0
60 ? `+ ${report.more} more ${report.more === 1 ? 'file' : 'files'}${report.moreBytes > 0 ? ` · ${size(report.moreBytes)}` : ''}` : ''
61 return { rows, hidden: report.rows.length - rows.length, moreLine }
62}
63
64/**
65 * How long a held command waits before it runs by itself: "30s", "1m", "5m", "1h", "90" (seconds);
66 * "none" (or "off", "never") waits until you choose, as null; undefined when it can't be read.
67 * At least 5 seconds, at most a day.
68 */
69export function waitLength(text: string): number | null | undefined {
70 const value = text.trim().toLowerCase()
71 if (['none', 'off', 'never', 'no'].includes(value)) return null
72 let ms = 0
73 if (/^\d+$/.test(value)) ms = Number(value) * 1_000
74 else {
75 const match = value.match(/^(?:(\d+)h)?\s*(?:(\d+)m(?:in)?)?\s*(?:(\d+)s(?:ec)?)?$/)
76 if (!match || (!match[1] && !match[2] && !match[3])) return undefined
77 ms = ((Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0)) * 60 + Number(match[3] ?? 0)) * 1_000
78 }
79 return ms >= 5_000 && ms <= 24 * 3_600_000 ? ms : undefined
80}
81
82/** "1 min", "30 s", "1 h 30 min", "no time limit". */
83export function waitWords(ms: number | null): string {
84 if (ms === null) return 'until you choose'
85 const seconds = Math.round(ms / 1_000)
86 const h = Math.floor(seconds / 3_600)
87 const m = Math.floor((seconds % 3_600) / 60)
88 const s = seconds % 60
89 return [h ? `${h} h` : '', m ? `${m} min` : '', s ? `${s} s` : ''].filter(Boolean).join(' ')
90}
91
92/** "0:42": what is left before it runs by itself. */
93export function countdown(held: BlastHeld): string {
94 if (held.autoRunAt === null) return ''
95 const seconds = Math.max(0, Math.ceil((held.autoRunAt - held.now) / 1_000))
96 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
97}
98
99/** "1h", "30m", "2h30m", "90" (minutes): how long `/sb-blast off` pauses; 0 when it can't be read. */
100export function pauseLength(text: string): number {
101 const value = text.trim().toLowerCase()
102 if (!value) return 60 * 60_000
103 if (/^\d+$/.test(value)) return Number(value) * 60_000
104 const match = value.match(/^(?:(\d+)h)?\s*(?:(\d+)m(?:in)?)?$/)
105 if (!match || (!match[1] && !match[2])) return 0
106 return (Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0)) * 60_000
107}
108
109/** The one line the Switchboard pane shows for Blast Radius. */
110export function statusLine(state: BlastState, now: number): string {
111 if (state.held) return state.queued ? `holding · ${state.queued} more` : 'holding a command'
112 if (state.pausedUntil > now) return `paused until ${clock(state.pausedUntil)}`
113 return 'watching'
114}
115
116export const clock = (ms: number) => {
117 const date = new Date(ms)
118 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
119}
120hooks/features/blast/view.tsx 112 lines1// The Blast Radius pane (screen F5): pictures with see-through Clients on the desktop, text with real
2// Buttons (1 runs it, 2 cancels, focused) in the terminal and in the band's card slot. Pure.
3
4import type { RenderElement } from 'claude-code'
5import { clickable, paneWidth, withAction, type Basic, type Pictured, type Presses } from '../../ui/click.tsx'
6import type { RiskKind } from './classify.ts'
7import { countdown, visibleRows, type BlastHeld } from './model.ts'
8import { actionsSvg, reportSvg } from './pictures.ts'
9
10export type BlastActions = {
11 proceed: () => void
12 cancel: () => void
13 toggleMore: () => void
14}
15
16function run(actions: BlastActions, key: string): void {
17 if (key === 'blast:proceed') actions.proceed()
18 else if (key === 'blast:cancel') actions.cancel()
19 else if (key === 'blast:more') actions.toggleMore()
20}
21
22/** The keys the pane answers once it has the keyboard: 1 runs it, 2 (or c) cancels. */
23export function blastKey(actions: BlastActions, key: string): boolean {
24 if (key === '1') actions.proceed()
25 else if (key === '2' || key === 'c') actions.cancel()
26 else return false
27 return true
28}
29
30export function blastDesktop(elements: Pictured, held: BlastHeld, kind: RiskKind, queued: number, columns: number,
31 actions: BlastActions, hover: string, presses: Presses): RenderElement {
32 const { Box } = elements
33 const width = paneWidth(columns)
34 const card = reportSvg(width, held, kind)
35 const decision = actionsSvg(width, held, queued, hover)
36 const report = held.report
37 const alt = report
38 ? `${report.command} ${report.verb} ${report.amount}. ${report.where}. ${report.warn} ${visibleRows(report, held.isExpanded).rows.map(row => row.name).join(', ')}`
39 : 'Measuring what this would change'
40 return (
41 <Box flexDirection="column" gap={1}>
42 {clickable(elements, 'blast-report', card.svg, alt, width, card.height, withAction(card.parts, key => run(actions, key)), presses)}
43 {clickable(elements, 'blast-actions', decision.svg, report ? `${report.action}, Cancel` : '', width, decision.height,
44 withAction(decision.parts, key => run(actions, key)), presses)}
45 </Box>
46 )
47}
48
49export function blastText(elements: Basic, held: BlastHeld, queued: number, actions: BlastActions): RenderElement {
50 const { Box, Button, Text } = elements
51 const report = held.report
52 if (!report) return <Text dimColor>Blast Radius: measuring what this would change…</Text>
53 const { rows, hidden, moreLine } = visibleRows(report, held.isExpanded)
54 const struck = new Set(held.struck)
55 return (
56 <Box flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
57 <Box flexDirection="row" gap={1} flexWrap="wrap">
58 <Text bold>{report.command}</Text>
59 <Text dimColor>{report.verb}</Text>
60 <Text color="error" bold>{report.amount}</Text>
61 </Box>
62 <Text dimColor>{[report.where, held.agent ? `from agent ${held.agent}` : ''].filter(Boolean).join(' · ')}</Text>
63 {report.warn ? <Text color="error">{`⚠ ${report.warn}`}</Text> : null}
64 {rows.map((row, index) => (
65 <Box key={`r${index}`} flexDirection="row" gap={1}>
66 <Text bold={row.kind === 'folder'} strikethrough={struck.has(row.path)} dimColor={struck.has(row.path)} wrap="truncate-end">
67 {`${row.isIndented ? ' ' : ' '}${row.name}`}
68 </Text>
69 {row.count ? <Text dimColor>{row.count}</Text> : null}
70 {row.size ? <Text dimColor={row.kind !== 'folder'}>{row.size}</Text> : null}
71 </Box>
72 ))}
73 {moreLine ? <Text dimColor>{` ${moreLine}`}</Text> : null}
74 {hidden > 0 ? <Button key="blast:more" plain label={`Show ${hidden} more`} onPress={actions.toggleMore} /> : null}
75 {report.note ? <Text dimColor>{report.note}</Text> : null}
76 {held.phase === 'waiting' ? (
77 <Box flexDirection="row" gap={2} marginTop={1}>
78 <Button key="blast:proceed" label={report.action} hotkey="1" onPress={actions.proceed} />
79 <Button key="blast:cancel" label="Cancel" hotkey="2" autoFocus onPress={actions.cancel} />
80 <Text dimColor>{`${countdown(held) ? `Runs by itself in ${countdown(held)}` : 'Held by Blast Radius'}${queued ? ` · ${queued} more held` : ''}`}</Text>
81 </Box>
82 ) : (
83 <Text color={held.phase === 'done' ? 'success' : held.phase === 'refused' ? 'error' : undefined} dimColor={held.phase === 'running'}>
84 {held.phase === 'running' ? 'Running it…' : held.phase === 'done' ? 'Done' : held.phase === 'refused' ? "Didn't run" : 'Measuring…'}
85 </Text>
86 )}
87 </Box>
88 )
89}
90
91/**
92 * The row above the prompt while a command is held, whatever the pane does: what is held, the action
93 * (key 1) and Cancel (key 2, focused). If the side pane is hidden or closed, this still answers it.
94 */
95export function blastRow(elements: Basic, held: BlastHeld, queued: number, actions: BlastActions): RenderElement | null {
96 const { Box, Button, Text } = elements
97 const report = held.report
98 if (!report || held.phase !== 'waiting') return null
99 return (
100 <Box key="blast-row" flexDirection="row" gap={1} flexWrap="wrap">
101 <Text color="error" bold>⚠ Blast Radius is holding</Text>
102 <Text bold>{report.command}</Text>
103 <Text dimColor>{report.verb}</Text>
104 <Text color="error">{report.amount}</Text>
105 {countdown(held) ? <Text dimColor>{`· runs by itself in ${countdown(held)}`}</Text> : null}
106 {queued ? <Text dimColor>{`· ${queued} more held`}</Text> : null}
107 <Button key="blast:proceed" label={report.action} hotkey="1" onPress={actions.proceed} />
108 <Button key="blast:cancel" label="Cancel" hotkey="2" autoFocus onPress={actions.cancel} />
109 </Box>
110 )
111}
112hooks/features/agents/model.ts 203 lines1// Agents (screen F1): display only. Switchboard starts no agents and calls no model for this; it
2// watches the subagents Claude runs (`agent.spawn`, `turn.step`, `turn.complete`, `$.agent.list()`)
3// and draws them. Runs started in one turn are one group, titled from that turn's prompt.
4// The tracking follows savvy-progress (claude-kit), MIT License, Copyright (c) 2026 johnnyvizz:
5// rounds by description, context from the latest step, cost from step usage. Added: groups, nested
6// runs, teammates, four end states, and rebuilding from `$.agent.list()`.
7
8import type { AgentRun, AgentRunStatus, AgentsState } from '../../../types/index.d.ts'
9import { costOf, tokensOf, windowOf, type Usage } from '../../core/prices.ts'
10
11export type { AgentRun, AgentsState }
12
13/** How each run looks: from the model and effort it actually ran with (its crab and color). */
14export type Look = 'fable' | 'heavy' | 'careful' | 'medium' | 'light' | 'explore'
15
16const MAX_RUNS = 200
17
18export function initialAgents(): AgentsState {
19 return { group: '', title: '', groupStartedAt: 0, runs: [], now: 0, isFinishedOpen: true, hiddenGroup: '', sessionCost: -1 }
20}
21
22/** Case, spaces and punctuation don't matter when a retry is matched to its first round. */
23export const norm = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
24
25/** Explore is the pirate; then Fable, Haiku or low effort, xhigh/max, high, and the rest. */
26export function lookOf(run: Pick<AgentRun, 'type' | 'model' | 'effort'>): Look {
27 if (/^(.*:)?explore$/i.test(run.type)) return 'explore'
28 const model = run.model.toLowerCase()
29 const effort = String(run.effort ?? '')
30 if (/fable|mythos/.test(model)) return 'fable'
31 if (/haiku/.test(model) || effort === 'low') return 'light'
32 if (effort === 'xhigh' || effort === 'max') return 'heavy'
33 if (effort === 'high') return 'careful'
34 return 'medium'
35}
36
37/** A group's title from the prompt that started it: its first sentence, a few words. */
38export function titleOf(prompt: string): string {
39 const first = prompt.replace(/^\/\S+\s*/, '').split(/(?<=[.!?])\s|\n/)[0]?.trim() ?? ''
40 const words = first.split(/\s+/).filter(Boolean)
41 const short = words.slice(0, 6).join(' ')
42 return short ? (words.length > 6 ? `${short}…` : short).replace(/^./, char => char.toUpperCase()) : 'Agents'
43}
44
45export type Spawned = { id: string; type: string; description: string; model: string; parentId?: string; isTeammate?: boolean }
46
47/** A run started. The first one in a new turn starts a new group; finished runs of older groups go. */
48export function onSpawn(state: AgentsState, spawned: Spawned, turn: { id: string; prompt: string }, now: number): AgentsState {
49 const isNewGroup = turn.id !== state.group
50 const kept = isNewGroup ? state.runs.filter(run => run.status === 'running') : state.runs
51 const round = 1 + kept.filter(run => spawned.description && norm(run.description) === norm(spawned.description)).length
52 const run: AgentRun = {
53 id: spawned.id, type: spawned.type, description: spawned.description || spawned.type, model: spawned.model,
54 status: 'running', startedAt: now, contextTokens: 0, contextMax: windowOf(spawned.model), tokens: 0, costUsd: 0, steps: 0, round,
55 group: turn.id, ...(spawned.parentId ? { parentId: spawned.parentId } : {}), ...(spawned.isTeammate ? { isTeammate: true } : {}),
56 }
57 // A retry keeps one row: the earlier round leaves once the new one starts.
58 const others = kept.filter(one => one.id !== run.id &&
59 !(round > 1 && one.status !== 'running' && norm(one.description) === norm(run.description)))
60 return {
61 ...state, runs: [...others, run].slice(-MAX_RUNS), now: Math.max(state.now, now),
62 ...(isNewGroup ? { group: turn.id, title: titleOf(turn.prompt), groupStartedAt: now } : {}),
63 }
64}
65
66const mapRun = (state: AgentsState, id: string, change: (run: AgentRun) => AgentRun): AgentsState => {
67 if (!state.runs.some(run => run.id === id)) return state
68 return { ...state, runs: state.runs.map(run => (run.id === id ? change(run) : run)) }
69}
70
71const addCost = (total: number, cost: number | null) => (total < 0 || cost === null ? -1 : total + cost)
72
73/** One model request of a subagent: its context now, and the tokens and cost so far. */
74export function onUsage(state: AgentsState, id: string, model: string, effort: string | undefined, usage: Usage): AgentsState {
75 return mapRun(state, id, run => ({
76 ...run, model, ...(effort ? { effort } : {}),
77 contextTokens: tokensOf(usage), contextMax: windowOf(model),
78 tokens: run.tokens + tokensOf(usage), costUsd: addCost(run.costUsd, costOf(model, usage)), steps: run.steps + 1,
79 }))
80}
81
82/** The run's turn ended: answered, stopped, refused or errored. A run with no steps seen takes the turn's own usage. */
83export function onComplete(state: AgentsState, id: string, reason: string, now: number, usage?: Usage & { model?: string }): AgentsState {
84 const status: AgentRunStatus = reason === 'answer' ? 'done' : reason === 'aborted' ? 'stopped' : 'failed'
85 return mapRun(state, id, run => {
86 const model = usage?.model || run.model
87 const fallback = run.steps === 0 && usage
88 ? { model, tokens: tokensOf(usage), costUsd: costOf(model, usage) ?? -1, contextTokens: tokensOf(usage) } : {}
89 return { ...run, ...fallback, status, endedAt: now, ...(status === 'failed' ? { failure: reason === 'refusal' ? 'refused' : 'error' } : {}) }
90 })
91}
92
93export type ListedAgent = { id: string; type: string; description: string; status: string; parentId?: string; teammateId?: string }
94
95/**
96 * `$.agent.list()` after a reload, a resume or a compaction: runs it lists that we lost come back, and
97 * runs that ended while we weren't watching take their end state.
98 */
99export function fromList(state: AgentsState, listed: readonly ListedAgent[], now: number): AgentsState {
100 const ended: Record<string, AgentRunStatus> = { completed: 'done', failed: 'failed', killed: 'stopped' }
101 const runs = state.runs.map(run => {
102 const seen = listed.find(one => one.id === run.id)
103 const status = seen ? ended[seen.status] : undefined
104 return status && run.status === 'running' ? { ...run, status, endedAt: now } : run
105 })
106 for (const one of listed) {
107 if (runs.some(run => run.id === one.id)) continue
108 const status = ended[one.status] ?? 'running'
109 if (status !== 'running') continue // only what still runs comes back; finished ones from before stay gone
110 runs.push({
111 id: one.id, type: one.type, description: one.description || one.type, model: '', status, startedAt: now,
112 contextTokens: 0, contextMax: 1_000_000, tokens: 0, costUsd: -1, steps: 0, round: 1, group: state.group,
113 ...(one.parentId ? { parentId: one.parentId } : {}), ...(one.teammateId ? { isTeammate: true } : {}),
114 })
115 }
116 return { ...state, runs: runs.slice(-MAX_RUNS) }
117}
118
119// ---- what the screens show ----------------------------------------------------------------------
120
121export const isRunning = (state: AgentsState) => state.runs.some(run => run.status === 'running')
122
123/** Runs in the order the pane lists them: parents before their children. */
124export function ordered(runs: readonly AgentRun[]): (AgentRun & { depth: number })[] {
125 const out: (AgentRun & { depth: number })[] = []
126 const ids = new Set(runs.map(run => run.id))
127 const walk = (parent: string | undefined, depth: number) => {
128 for (const run of runs) {
129 const isRoot = parent === undefined && (!run.parentId || !ids.has(run.parentId))
130 if (isRoot || (parent !== undefined && run.parentId === parent)) {
131 out.push({ ...run, depth })
132 walk(run.id, depth + 1)
133 }
134 }
135 }
136 walk(undefined, 0)
137 return out
138}
139
140/** The tiles: cost (the session's /cost figure when known, else the runs' estimate), tokens, time. */
141export function totals(state: AgentsState): { cost: number; isExact: boolean; tokens: number; time: number } {
142 const runs = state.runs
143 const tokens = runs.reduce((sum, run) => sum + run.tokens, 0)
144 const start = runs.length ? Math.min(...runs.map(run => run.startedAt)) : state.now
145 const end = isRunning(state) ? state.now : Math.max(start, ...runs.map(run => run.endedAt ?? state.now))
146 const time = Math.max(0, end - start)
147 if (state.sessionCost >= 0) return { cost: state.sessionCost, isExact: true, tokens, time }
148 const cost = runs.reduce((sum, run) => (sum < 0 || run.costUsd < 0 ? -1 : sum + run.costUsd), 0)
149 return { cost, isExact: false, tokens, time }
150}
151
152export const fmtTokens = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`)
153export const fmtCost = (usd: number) => (usd < 0 ? '?' : `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`)
154export function fmtTime(ms: number): string {
155 const seconds = Math.max(0, Math.round(ms / 1000))
156 const h = Math.floor(seconds / 3600)
157 const m = Math.floor((seconds % 3600) / 60)
158 const s = String(seconds % 60).padStart(2, '0')
159 return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
160}
161
162export const elapsed = (run: AgentRun, now: number) => (run.endedAt ?? Math.max(now, run.startedAt)) - run.startedAt
163export const contextPercent = (run: AgentRun) => Math.round((100 * run.contextTokens) / Math.max(1, run.contextMax))
164
165/** The bar under a row: full in its color once done; while it runs, its context use, in grey. */
166export function barOf(run: AgentRun): { fraction: number; isColored: boolean } {
167 if (run.status === 'done') return { fraction: 1, isColored: true }
168 return { fraction: Math.min(1, run.contextTokens / Math.max(1, run.contextMax)), isColored: false }
169}
170
171export type BandSummary = {
172 title: string
173 /** 0..1 of the group's runs finished. */
174 fraction: number
175 /** "2/4 done". */
176 label: string
177 running: number
178 total: number
179 /** The latest running run's look: its crab rides at the end of the bar. */
180 look: Look
181}
182
183/** The status row's Agents part while any of the group runs, unless you hid it (✕). */
184export function bandOf(state: AgentsState): BandSummary | null {
185 const group = state.runs.filter(run => run.group === state.group)
186 const running = group.filter(run => run.status === 'running')
187 if (!running.length || state.hiddenGroup === state.group) return null
188 const finished = group.length - running.length
189 return {
190 title: state.title || 'Agents', fraction: group.length ? finished / group.length : 0, label: `${finished}/${group.length} done`,
191 running: running.length, total: group.length, look: lookOf(running[running.length - 1]!),
192 }
193}
194
195/** Opens by itself while any subagent runs (and the setting is on): your spec, 2026-10-12. */
196export const shouldAutoOpen = (state: AgentsState) => state.runs.some(run => run.status === 'running')
197
198/** The one line the Switchboard pane shows for Agents. */
199export function statusLine(state: AgentsState): string {
200 const running = state.runs.filter(run => run.status === 'running').length
201 return running ? `${running} running` : state.runs.length ? `${state.runs.length} finished` : 'idle'
202}
203hooks/features/agents/view.tsx 117 lines1// The Agents pane (screen F1) and its band row. Display only: pictures with see-through Clients on the
2// desktop, text in the terminal. Pure: register.tsx hands in the elements, the state and the actions.
3
4import type { RenderElement } from 'claude-code'
5import { clickable, paneWidth, withAction, type Basic, type Pictured, type Presses } from '../../ui/click.tsx'
6import { modelName } from '../../core/prices.ts'
7import {
8 bandOf, contextPercent, elapsed, fmtCost, fmtTime, fmtTokens, ordered, totals, type AgentRun, type AgentsState,
9} from './model.ts'
10import { BAND_H, ROW_H, bandSvg, emptySvg, headerSvg, runSvg, sectionSvg } from './pictures.ts'
11
12export type AgentsActions = {
13 toggleFinished: () => void
14 /** ×N on the band: show or hide the pane. */
15 togglePane: () => void
16 /** ✕ on the band: hide the row for this group. */
17 hideBand: () => void
18}
19
20const runAlt = (run: AgentRun, now: number) =>
21 `${run.description}: ${run.status}, ${run.type}${run.model ? `, ${modelName(run.model)}` : ''}${run.effort ? ` ${run.effort}` : ''}, ` +
22 `${run.steps} steps, context ${contextPercent(run)}%, ${fmtTokens(run.tokens)} tokens, ≈${fmtCost(run.costUsd)}, ${fmtTime(elapsed(run, now))}`
23
24export function agentsDesktop(elements: Pictured, state: AgentsState, columns: number, actions: AgentsActions,
25 hover: string, presses: Presses): RenderElement {
26 const { Box } = elements
27 const width = paneWidth(columns)
28 if (!state.runs.length) return <Box flexDirection="column">{clickable(elements, 'agents-empty', emptySvg(width), 'No agents yet', width, 70, [], presses)}</Box>
29 const header = headerSvg(width, state)
30 const sum = totals(state)
31 const all = ordered(state.runs)
32 const running = all.filter(run => run.status === 'running')
33 const finished = all.filter(run => run.status !== 'running')
34 const section = (key: string, label: string, toggle: string) => {
35 const picture = sectionSvg(width, label, toggle, hover)
36 return clickable(elements, key, picture.svg, label, width, 28, withAction(picture.parts, () => actions.toggleFinished()), presses)
37 }
38 const rows = (list: typeof all, prefix: string) => list.map((run, index) =>
39 clickable(elements, `${prefix}:${run.id}`, runSvg(width, run, state.now, index === list.length - 1), runAlt(run, state.now), width, ROW_H, [], presses))
40 return (
41 <Box flexDirection="column">
42 {clickable(elements, 'agents-head', header.svg,
43 `${state.title}. Cost ${sum.isExact ? '' : 'about '}${fmtCost(sum.cost)}, ${fmtTokens(sum.tokens)} tokens, ${fmtTime(sum.time)}`,
44 width, header.height, [], presses)}
45 {running.length ? section('agents-running', `Running · ${running.length}`, '') : null}
46 {rows(running, 'run')}
47 {finished.length ? section('agents-finished', `${state.isFinishedOpen ? '▾' : '▸'} Finished · ${finished.length}`, 'agents:finished') : null}
48 {state.isFinishedOpen ? rows(finished, 'done') : null}
49 </Box>
50 )
51}
52
53// ---- terminal and other text surfaces --------------------------------------------------------------
54
55const textBar = (fraction: number, width: number) => {
56 const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width)
57 return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`
58}
59
60export function agentsText(elements: Basic, state: AgentsState, actions: AgentsActions): RenderElement {
61 const { Box, Button, Text } = elements
62 if (!state.runs.length) return <Text dimColor>No agents yet. When Claude starts subagents, they show here as they work.</Text>
63 const sum = totals(state)
64 const all = ordered(state.runs)
65 const running = all.filter(run => run.status === 'running')
66 const finished = all.filter(run => run.status !== 'running')
67 const row = (run: (typeof all)[number]) => (
68 <Box key={run.id} flexDirection="column" marginLeft={Math.min(3, run.depth) * 2}>
69 <Box flexDirection="row" gap={1}>
70 <Text bold>{run.description}</Text>
71 {run.round > 1 ? <Text dimColor>{`round ${run.round}`}</Text> : null}
72 {run.status === 'done' ? <Text color="success">✓</Text> : null}
73 {run.status === 'stopped' ? <Text dimColor>stopped</Text> : null}
74 {run.status === 'failed' ? <Text color="error">{`failed: ${run.failure ?? 'error'}`}</Text> : null}
75 </Box>
76 <Text dimColor>{[run.type, run.model ? modelName(run.model) : '', run.effort ?? ''].filter(Boolean).join(' · ')}</Text>
77 <Text dimColor>{`${run.steps} steps · ctx ${contextPercent(run)}% · ${fmtTokens(run.tokens)} ≈${fmtCost(run.costUsd)} ${fmtTime(elapsed(run, state.now))}`}</Text>
78 </Box>
79 )
80 return (
81 <Box flexDirection="column">
82 <Text bold>{state.title || 'Agents'}</Text>
83 <Text>{`Cost ${sum.isExact ? '' : '≈'}${fmtCost(sum.cost)} · Tokens ${fmtTokens(sum.tokens)} · Time ${fmtTime(sum.time)}`}</Text>
84 {running.length ? <Text dimColor bold>{`RUNNING · ${running.length}`}</Text> : null}
85 {running.map(row)}
86 {finished.length
87 ? <Button key="agents:finished" plain label={`${state.isFinishedOpen ? '▾' : '▸'} FINISHED · ${finished.length}`} onPress={actions.toggleFinished} />
88 : null}
89 {state.isFinishedOpen ? finished.map(row) : null}
90 </Box>
91 )
92}
93
94/** The band row while agents run: one picture on the desktop; `█░` text and two Buttons in the terminal. */
95export function agentsBand(elements: Basic | Pictured, isDesktop: boolean, state: AgentsState, columns: number, actions: AgentsActions,
96 hover: string, presses: Presses): RenderElement | null {
97 const band = bandOf(state)
98 if (!band) return null
99 if (isDesktop && 'Svg' in elements) {
100 const width = paneWidth(columns)
101 const picture = bandSvg(width, band, hover)
102 return clickable(elements, 'band-agents', picture.svg, `${band.title}: ${band.label}, ${band.running} running`, width, BAND_H,
103 withAction(picture.parts, key => (key === 'band:agents-hide' ? actions.hideBand() : actions.togglePane())), presses)
104 }
105 const { Box, Button, Text } = elements
106 return (
107 <Box key="band-agents" flexDirection="row" gap={1}>
108 <Text color="suggestion">●</Text>
109 <Text bold>{band.title}</Text>
110 <Text color="suggestion">{textBar(band.fraction, 16)}</Text>
111 <Text dimColor>{band.label}</Text>
112 <Button key="band:agents" plain label={`×${band.running}`} onPress={actions.togglePane} />
113 <Button key="band:agents-hide" plain label="✕" onPress={actions.hideBand} />
114 </Box>
115 )
116}
117hooks/features/reflect/model.ts 122 lines1// Reflect (screen F6): suggestions found in what you said, counted up when you say them again, and
2// decided from a card above the prompt. Pure; register.tsx runs the checks, the store and the writes.
3
4import type { ReflectItem, ReflectScope, ReflectState } from '../../../types/index.d.ts'
5import { SAME, similarity, type Verdict } from './rules.ts'
6
7export type { ReflectItem, ReflectScope, ReflectState }
8
9export const ITEMS_KEY = 'reflect.items'
10export const PAUSED_KEY = 'reflect.paused'
11export const MAX_ITEMS = 300
12/** A pending suggestion leaves the band after this long unseen (it stays in /sb-reflect). */
13export const CARD_TTL_MS = 30 * 60_000
14/** Suggestions untouched this long are dropped. */
15export const MAX_AGE_MS = 90 * 24 * 3_600_000
16/** "Don't save" this many times on similar rules: that rule isn't suggested again. */
17export const SKIP_LIMIT = 3
18
19export function initialReflect(): ReflectState {
20 return { items: [], quotes: {}, isPaused: false, chosen: {}, rewording: '', savedNow: [], isConflicted: false }
21}
22
23const isItem = (value: unknown): value is ReflectItem => {
24 const item = value as ReflectItem
25 return !!item && typeof item.id === 'string' && typeof item.rule === 'string' && (item.scope === 'project' || item.scope === 'global') &&
26 typeof item.project === 'string' && ['pending', 'saved', 'skipped'].includes(item.status)
27}
28
29/** Stored items back, anything damaged or older than 90 days dropped. */
30export function fromStore(raw: unknown, now: number): ReflectItem[] {
31 return (Array.isArray(raw) ? raw : []).filter(isItem).filter(item => now - (item.lastAt ?? 0) < MAX_AGE_MS)
32 .map(item => ({ ...item, count: Math.max(1, item.count ?? 1), confidence: item.confidence ?? 0.7 }))
33}
34
35/** At most 300 kept: decided ones go first, oldest first, before any pending one. */
36export function capped(items: readonly ReflectItem[]): ReflectItem[] {
37 if (items.length <= MAX_ITEMS) return [...items]
38 const decided = items.filter(item => item.status !== 'pending').sort((a, b) => a.lastAt - b.lastAt)
39 const drop = new Set(decided.slice(0, items.length - MAX_ITEMS).map(item => item.id))
40 const kept = items.filter(item => !drop.has(item.id))
41 return kept.slice(-MAX_ITEMS)
42}
43
44/** Items that apply here: this project's and every global one. */
45export const here = (items: readonly ReflectItem[], project: string) =>
46 items.filter(item => item.scope === 'global' || item.project === project)
47
48export type Found = Verdict & { project: string; quote: string; isExplicit: boolean }
49
50export type Recorded =
51 | { kind: 'new'; item: ReflectItem }
52 | { kind: 'again'; item: ReflectItem }
53 /** Already in the CLAUDE.md it would go to: stored as saved, never shown. */
54 | { kind: 'in-file'; item: ReflectItem }
55 /** You turned down similar rules 3 times. */
56 | { kind: 'declined' }
57
58/**
59 * A rule found: counts up a similar pending one (×N), is kept as saved when the target CLAUDE.md
60 * has it already, is dropped after 3 "Don't save"s on similar rules, and is a new suggestion otherwise.
61 */
62export function record(state: ReflectState, found: Found, bullets: readonly string[], now: number, id: string): { state: ReflectState; recorded: Recorded } {
63 const near = here(state.items, found.project)
64 const same = near.find(item => item.status === 'pending' && similarity(item.rule, found.rule) >= SAME)
65 if (same) {
66 const item = { ...same, count: same.count + 1, lastAt: now, confidence: Math.max(same.confidence, found.confidence) }
67 return { state: { ...state, items: state.items.map(one => (one.id === same.id ? item : one)), quotes: { ...state.quotes, [same.id]: found.quote } },
68 recorded: { kind: 'again', item } }
69 }
70 const declined = near.filter(item => item.status === 'skipped' && similarity(item.rule, found.rule) >= SAME)
71 if (!found.isExplicit && declined.reduce((sum, item) => sum + item.count, 0) >= SKIP_LIMIT) return { state, recorded: { kind: 'declined' } }
72 const isInFile = bullets.some(bullet => similarity(bullet, found.rule) >= SAME)
73 const item: ReflectItem = {
74 id, rule: found.rule, scope: found.scope, project: found.project, confidence: found.confidence, count: 1,
75 status: isInFile ? 'saved' : 'pending', firstAt: now, lastAt: now,
76 }
77 const items = capped([...state.items, item])
78 // Quotes only for items still kept: capping drops the oldest, and their quotes go with them.
79 const kept = new Set(items.filter(one => one.status === 'pending').map(one => one.id))
80 const quotes = Object.fromEntries(Object.entries(isInFile ? state.quotes : { ...state.quotes, [id]: found.quote }).filter(([key]) => kept.has(key)))
81 return {
82 state: { ...state, items, quotes },
83 recorded: isInFile ? { kind: 'in-file', item } : { kind: 'new', item },
84 }
85}
86
87export function decide(state: ReflectState, id: string, status: 'saved' | 'skipped', scope: ReflectScope | undefined, now: number): ReflectState {
88 return {
89 ...state, rewording: state.rewording === id ? '' : state.rewording,
90 items: state.items.map(item => (item.id === id && item.status === 'pending' ? { ...item, status, lastAt: now, ...(scope ? { scope } : {}) } : item)),
91 }
92}
93
94export function reword(state: ReflectState, id: string, rule: string, now: number): ReflectState {
95 return { ...state, items: state.items.map(item => (item.id === id ? { ...item, rule, lastAt: now, confidence: 1 } : item)) }
96}
97
98/** The pending suggestions here, newest first: the card shows the first. */
99export const pendingHere = (state: ReflectState, project: string) =>
100 here(state.items, project).filter(item => item.status === 'pending').sort((a, b) => b.lastAt - a.lastAt)
101
102/** The scope the card's options show selected: what you picked, or the suggestion's own. */
103export const scopeOf = (state: ReflectState, item: ReflectItem): ReflectScope => state.chosen[item.id] ?? item.scope
104
105/** "95% sure". */
106export const sureText = (confidence: number) => `${Math.round(Math.min(1, Math.max(0, confidence)) * 100)}% sure`
107
108/** What the system prompt gets after a save this session (the rule is in CLAUDE.md too). */
109export function composedRules(state: ReflectState, project: string): string {
110 const rules = state.savedNow.filter(one => one.scope === 'global' || one.project === project)
111 if (!rules.length) return ''
112 return ['Rules the user just saved to CLAUDE.md (follow them from now on):', ...rules.map(one => `- ${one.rule}`)].join('\n')
113}
114
115/** The one line the Switchboard pane shows for Reflect. */
116export function statusLine(state: ReflectState, project: string): string {
117 if (state.isConflicted) return 'claude-reflect is installed; use one'
118 if (state.isPaused) return 'paused'
119 const waiting = pendingHere(state, project).length
120 return waiting ? `${waiting} waiting` : 'listening'
121}
122hooks/features/reflect/rules.ts 143 lines1// Reflect: which messages to check, the model's instructions, reading its answer, and writing a rule
2// into CLAUDE.md. Pure.
3// Ported from claude-reflect's mod (mod/hooks/register.tsx, v3.3.0), MIT License, Copyright (c) 2025
4// Bayram Annakov: the trivial and secret checks, ordered word-pair similarity, `remember:`, the rule
5// cleaning, the classifier prompt and the marked section. Changed: our markers, the rule written in
6// the language the user used (BUILD_PLAN.md decision 8), and `this project` in a `remember:`.
7
8/** Longer messages are task briefs and pastes, not corrections (unless they say "remember:"). */
9export const MAX_PROMPT = 500
10export const MAX_RULE = 200
11/** Two rules this alike (ordered word pairs) are the same rule. */
12export const SAME = 0.6
13/** A bullet this alike already in CLAUDE.md: nothing is written. */
14export const IN_FILE = 0.8
15
16export const START = '<!-- switchboard: learned rules (edit freely) -->'
17export const END = '<!-- end switchboard -->'
18
19/** Your own messages: typed, or sent through Remote Control. Plugins, the SDK, peers and schedules aren't you. */
20export const HUMAN = new Set(['composer', 'bridge'])
21
22/** Acknowledgements and go-aheads: nothing to learn, so no model call. */
23const TRIVIAL =
24 /^(y|yes|yep|ok|okay|k|go|go on|go ahead|continue|proceed|next|done|thanks|thank you|ty|great|nice|cool|lgtm|sure|fine|da|да|ок|окей|ага|го|давай|дальше|продолжай|спасибо|отлично|sí|si|oui|ja|haan|han|theek|ٹھیک|جی|ہاں|\d{1,3})[\s.!)]*$/i
25
26const SECRET = [
27 /\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs]|glpat)[-_][A-Za-z0-9_-]{8,}/i,
28 /\bAKIA[0-9A-Z]{16}\b/,
29 /\bAIza[0-9A-Za-z_-]{20,}/,
30 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, // a JWT
31 /(?=[A-Za-z0-9_-]*\d)(?=[A-Za-z0-9_-]*[A-Za-z])[A-Za-z0-9_-]{32,}/, // a long token: letters and digits, no path or dots
32 /(password|passwd|passphrase|secret|token|api[ _-]?key|пароль|токен|ключ)\s*(\bis\b|:|=)\s*\S{4,}/i,
33 /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
34]
35
36export const looksSecret = (text: string) => SECRET.some(pattern => pattern.test(text))
37export const isTrivial = (text: string) => TRIVIAL.test(text.trim())
38
39/** `remember: <rule>`: the rule as typed, and whether it names this project. */
40export function remembered(text: string): { rule: string; isProject: boolean } | undefined {
41 const told = /^\s*remember\s*:\s*([\s\S]+)$/i.exec(text)?.[1]?.trim()
42 if (told === undefined) return undefined
43 const isProject = /\b(this|the) (project|repo|repository)\b/i.test(told)
44 return { rule: told.replace(/\s*\(?\b(for |in )?(this|the) (project|repo|repository)\b\)?\s*$/i, '').trim() || told, isProject }
45}
46
47/** Whether a message you sent is worth checking at all (cheap, before any model call). */
48export function isCandidate(text: string, origin: string): boolean {
49 const trimmed = text.trim()
50 return HUMAN.has(origin) && !trimmed.startsWith('/') && !isTrivial(trimmed) && !looksSecret(trimmed) &&
51 (trimmed.length <= MAX_PROMPT || remembered(trimmed) !== undefined)
52}
53
54/** Ordered word pairs, short words kept: "pnpm, not npm" and "npm, not pnpm" share none. */
55export function pairs(text: string): Set<string> {
56 const words = text.toLowerCase().replace(/[^\p{L}\p{N}+#.\s]/gu, ' ').split(/\s+/).filter(Boolean)
57 const out = new Set<string>()
58 if (words.length === 1) out.add(words[0] ?? '')
59 for (let i = 0; i + 1 < words.length; i++) out.add(`${words[i]} ${words[i + 1]}`)
60 return out
61}
62
63/** Overlap of two rules' ordered word pairs: 1 the same, 0 nothing in common. */
64export function similarity(a: string, b: string): number {
65 const x = pairs(a)
66 const y = pairs(b)
67 if (!x.size || !y.size) return 0
68 let both = 0
69 for (const pair of x) if (y.has(pair)) both++
70 return both / (x.size + y.size - both)
71}
72
73/** The one check every rule passes before it is kept or written, whoever wrote it. */
74export function cleanRule(raw: string): string | undefined {
75 const rule = raw.trim().replace(/^[-*]\s+/, '').replace(/^["“']|["”']$/g, '').trim()
76 if (!rule || rule.length > MAX_RULE) return undefined
77 if (/[\r\n\u0000-\u001f\u007f]/.test(rule) || rule.includes('<!--') || rule.includes('-->')) return undefined
78 if (looksSecret(rule)) return undefined
79 return rule
80}
81
82export type Verdict = { isRule: boolean; rule: string; scope: 'global' | 'project'; confidence: number }
83
84const PROJECT_WORDS = /\s*\b(?:(?:in|for|on|of)\s+)?(?:this|the|our) (?:project|repo|repository|codebase|app)\b|\s+here\b|\s*\bin this folder\b/gi
85const GLOBAL_WORDS = /\b(every|all|any) (project|repo|repositor(y|ies))s?\b|\beverywhere\b|\bglobally\b|\bin general\b/i
86
87/** Sentences of a message, without trailing punctuation clutter. */
88const sentences = (text: string) => text.split(/(?<=[.!;])\s+|\n+/).map(one => one.trim()).filter(Boolean)
89
90/**
91 * A standing rule in what you wrote, found on this computer with no model call (zero tokens): "always ...",
92 * "never ...", "don't ...", "stop ...", "use X instead of Y", "prefer X over Y", "from now on ...".
93 * Questions and one-off redirects ("wrong file", "try again") are left alone. You still approve every
94 * rule on the card, so a miss costs a click, never a wrong rule.
95 */
96export function correction(text: string): Verdict | undefined {
97 for (const raw of sentences(text)) {
98 const sentence = raw.replace(/^[-–—*\s]+/, '').replace(/^(no|nope|actually|wait|ok|okay|hey|please|pls)[-–—,!.\s]+/i, '').replace(/^please\s+/i, '').trim()
99 if (sentence.endsWith('?') || sentence.length < 10) continue
100 let rule = ''
101 let match: RegExpExecArray | null
102 if ((match = /^from now on,?\s+(.+)$/i.exec(sentence))) rule = match[1]!
103 else if ((match = /(?:^|\b(?:we|you|i|to|should)\s+)(always|never)\s+(.{4,})$/i.exec(sentence))) rule = `${match[1]} ${match[2]}`
104 else if ((match = /^(don't|do not|stop|avoid)\s+(.{4,})$/i.exec(sentence))) rule = `${match[1]!.toLowerCase() === 'stop' ? 'Stop' : match[1]} ${match[2]}`
105 else if ((match = /^(?:use|run|call)\s+(.+?)\s+(?:instead of|not|rather than)\s+(.+)$/i.exec(sentence))) rule = `Use ${match[1]}, not ${match[2]}`
106 else if ((match = /^prefer\s+(.+?)\s+(?:over|to)\s+(.+)$/i.exec(sentence))) rule = `Prefer ${match[1]} over ${match[2]}`
107 if (!rule) continue
108 const line = cleanRule(`${rule.charAt(0).toUpperCase()}${rule.slice(1)}`.replace(/[.!\s]+$/, '') + '.')
109 if (!line || line.split(/\s+/).length < 3) continue
110 const scope = GLOBAL_WORDS.test(sentence) ? 'global' : 'project'
111 return { isRule: true, rule: line.replace(PROJECT_WORDS, '').replace(/\s{2,}/g, ' ').replace(/\s+\./, '.'), scope, confidence: 0.8 }
112 }
113 return undefined
114}
115
116/** The bullets a CLAUDE.md already has, anywhere in it. */
117export const bulletsOf = (file: string) =>
118 file.split('\n').filter(line => /^\s*[-*] /.test(line)).map(line => line.replace(/^\s*[-*]\s+/, ''))
119
120export type Written = { kind: 'added' | 'already'; text: string } | { kind: 'broken'; line: number }
121
122/**
123 * The file with the rule added inside the marked section (a new section when there are no markers),
124 * unchanged when a similar bullet is anywhere in it, or `broken` (with the line to fix) when the markers
125 * are one without the other, repeated, or out of order: never guess where to write.
126 */
127export function withRule(file: string, rule: string): Written {
128 if (bulletsOf(file).some(bullet => similarity(bullet, rule) >= IN_FILE)) return { kind: 'already', text: file }
129 const starts = file.split(START).length - 1
130 const ends = file.split(END).length - 1
131 if (!starts && !ends) {
132 const gap = file === '' || file.endsWith('\n\n') ? '' : file.endsWith('\n') ? '\n' : '\n\n'
133 return { kind: 'added', text: `${file}${gap}${START}\n## Learned rules\n- ${rule}\n${END}\n` }
134 }
135 const start = file.indexOf(START)
136 const end = file.indexOf(END)
137 if (starts !== 1 || ends !== 1 || end < start) {
138 const at = starts !== 1 ? (starts ? file.indexOf(START, start + 1) : end) : end
139 return { kind: 'broken', line: file.slice(0, Math.max(0, at)).split('\n').length }
140 }
141 return { kind: 'added', text: `${file.slice(0, end)}- ${rule}\n${file.slice(end)}` }
142}
143