SLOPSHOPPER

switchboard

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

newpanebandguardcommandprompt
★ 2v1.0.0-beta.2MITupdated 2026-10-15hassamali898/switchboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · switchboard
│ ┃ Inbox ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ● RIGHT NOW │ ⏺ Read(src/auth.ts) │ ┃ │ Nothing needs you │ ⎿ Read 6 lines │ ┃ │ 0 new today · 0 sources live · $0.00 │ ⏺ Update(src/auth.ts) │ ┃ │ today on Haiku │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ⚙ Sources │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Find a message or a person: ⏎ find │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ● All 0 │ ┃ › /switchboard │ ┃ NEEDS YOU · 0 ⎿ switchboard: Opened Switchboard. All features are on. │ ┃ │ ┃ Nothing waiting on you. │ ┃ │ ┃ EVERYTHING ELSE · 0 │ ┃ │ ┃ All caught up. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Inbox
╭──────────────────────────────────────────────────────────╮ │ ● RIGHT NOW │ │ Nothing needs you │ │ 0 new today · 0 sources live · $0.00 today on Haiku │ ╰──────────────────────────────────────────────────────────╯ ⚙ Sources Find a message or a person: ⏎ find ● All 0 NEEDS YOU · 0 Nothing waiting on you. EVERYTHING ELSE · 0 All caught up.
README

Switchboard beta

Website · Releases · MIT

Switchboard

One Claude Code plugin with separate features you switch on or off from one pane (/sb), in the desktop Code tab and the terminal:

  • Inbox — Slack, Gmail, Calendar, GitHub and Jira in one live, color-coded list behind a bell. Drafts three replies on Haiku in its own session; you send with a 5 s Undo. Meeting cards before each meeting.
  • Auto rules — rules you make from the chat (/sb-auto). Ask first by default; nothing ever sends without a saved rule, a named person and a clear request.
  • Files — a live tree of what Claude reads, edits and commits.
  • Replay — every edit of the last turns, step by step (/sb-replay).
  • Blast Radius — holds commands that delete or overwrite things and shows what they would touch.
  • Agents — the subagents Claude runs, with progress, model and cost.
  • Reflect — notices when you correct Claude and offers to save the rule to CLAUDE.md.
  • Cache Tax — holds a costly message when the prompt cache has gone cold.

Everything runs locally: no server, no cloud database; tokens live in your OS keychain.

Layout

PathWhat 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.tsThe 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)

Install

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" under env in ~/.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:

  • builds the plugin's Python environment, so the first session starts at once
  • adds three keys to the 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 Code
  • CLAUDE_CODE_PLUGIN_DIRS, so every session loads the plugin from this folder
  • CLAUDE_CODE_PLUGIN_DIR_WATCH=1, so open desktop sessions reload the plugin when its files change

It 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.

Connect your sources (in the pane)

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

ChoiceWhyGood to know
Connect Slack directly (recommended)Messages show up instantly: Slack sends them to us, so nothing is checked over and overA two-minute setup; some workspaces need an admin to approve the app
Claude's Slack connectionNo Slack app to createNot offered: it could only check repeatedly, which is slow and runs into Slack's limits

The steps:

  1. Open Slack brings up Slack's Create App form, filled in from config/slack-app-manifest.yaml. Create the app and install it to the workspace.
  2. Copy the User OAuth Token (xoxp-…) and press Paste code 1.
  3. Generate an App-Level Token with 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

ChoiceWhyGood to know
Use Claude's Gmail connection (recommended)Nothing to set up: it uses the Gmail you already connected to ClaudeNew 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 directlyMail shows up within seconds, even while Claude Code is closedA one-time Google Cloud setup (about 5 minutes)
  • Claude's connection: Switchboard finds the connection by itself. If Gmail isn't connected to Claude yet, the card links to Claude's connector settings.
  • Directly: turn on Gmail access, create a Desktop app sign-in key, download it, then press Connect Gmail and sign in. Instant updates are set up for you when your Google project allows it; otherwise Gmail is checked every minute and the card says how to turn them on.

Try the demo shows the design's made-up messages. Nothing is sent.

Account safety

Your accounts matter more than speed, so every source works far below its service's limits and stops by itself when anything looks wrong:

  • Limits: a minimum gap between calls, plus hourly and daily caps per source. The rules are in watcher/safety.py and hooks/lib/governor.ts.
  • Gmail through Claude: checks come at most every 15 s, and only while you're active. Each check is one small search, and an email is fetched only when you open it.
  • Slowing down: when a service asks us to slow down (HTTP 429, quota), Switchboard waits as long as it was told, then backs off further. After three requests in a row it pauses that source for 30 minutes. The inbox shows a line with the time it will resume, plus a Resume now button.
  • Rejected sign-ins: Switchboard stops that source until you reconnect it. Retrying bad credentials over and over is what gets accounts locked.
  • Slack catch-up: after a reconnect, Switchboard catches up on your 20 most recent conversations, one call at a time. New connections are spaced at least 20 s apart.
  • One window at a time: only one Claude Code window checks Gmail. The others see the same mail through the shared queue.

Use

Type /sb-inbox to open the inbox. All Switchboard commands start with sb- so they never clash with your own.

CommandWhat it does
/sb-inboxOpen the inbox (everything else is buttons)
/sb-reply <n>Open item #n with reply options

In Settings you can:

  • choose how each source connects
  • see account safety and press Resume when a source is paused
  • switch alerts between emoji and text tags
  • pause or resume checking
  • turn the demo on or off
  • remove Switchboard (it asks before doing so)

Keys in the focused pane:

KeysAction
1–9Open an item
a / b / cPick a reply option
sSend
uUndo
iImprove
eEdit
dSave draft
kSkip
mMark all read
oSettings
EscBack to the prompt

Develop

.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.

Safety

  • Sending. Nothing is sent without your click. Auto rules arrive in phase 4.
  • Guards on every send (watcher/actions.py):
  • the recipient you saw must still be the item's recipient
  • an idempotency key blocks a double send
  • Slack's 40,000-character limit
  • the original is re-read first: deleted blocks the send, edited needs a confirm
  • HTTP 429: wait for Retry-After, then retry once
  • every send goes to the action log with Undo info
  • Message text is data. Drafting prompts treat it as untrusted. Items that match excluded labels, channels or keywords are never sent to Claude.
  • Tokens. They stay in the OS keychain (.env is only a git-ignored fallback) and are redacted from every error message.
Source 53 files
hooks/register.tsx 4077 lines
1// 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 lines
1// 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}
166
hooks/core/features.ts 123 lines
1// 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}
123
hooks/core/store.ts 83 lines
1// 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
83
hooks/features/blast/classify.ts 255 lines
1// 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}
255
hooks/features/blast/measure.ts 250 lines
1// 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}
250
hooks/features/blast/model.ts 120 lines
1// 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}
120
hooks/features/blast/view.tsx 112 lines
1// 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}
112
hooks/features/agents/model.ts 203 lines
1// 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}
203
hooks/features/agents/view.tsx 117 lines
1// 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}
117
hooks/features/reflect/model.ts 122 lines
1// 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}
122
hooks/features/reflect/rules.ts 143 lines
1// 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