Project Sentinel keeps long Claude Code runs on track: a status bar, the Control Room panel, Context Autopilot (automatic handoff to a fresh context), Cache…

Project Sentinel keeps long Claude Code runs on track. It adds a calm status bar above the prompt (what the run is doing, its milestones, how full the context is, the prompt cache, machine load and the plan's five-hour usage, as Claude Code reports it), the Control Room panel beside the conversation, and a set of switches: Context Autopilot hands a run over to a fresh context before the current one fills up (on for a new install, at 70%), Frontier Max asks Claude for its most thorough work at Extra High effort (on for a new install), Cache Guardian keeps the prompt cache in view (and, if you ask, warm while you are away), permission categories ask before or refuse risky kinds of calls, Operations orchestrates the run over time (work you queue for later, decisions Claude leaves for you, watchers that park the run until a result is due and wake it, a guard before a lapsed cache is re-read, an optional run budget), Activity → Agents and a band above the status bar show the subagents running and what each is doing, and Kit is an optional pixel companion that shows what Claude is doing. It works in the terminal and in Claude Desktop's Code tab, needs Claude Code 2.1.289 or newer, and was called Control Room before 1.4.0. An install from before 1.7.0 keeps its own settings.
Install it from a session: /plugin install project-sentinel --marketplace Arjun0014/project-sentinel.
Everything stays on your machine. Project Sentinel sends nothing anywhere itself; the only requests that leave are Claude Code's own model requests, as listed under What it does on your machine.
2 of 10), the context as a bar, the cache's state and the plan's five-hour usage (5H 63% · 2h14m). Type /cr (or /control-room) to open the panel: Overview, Context, Behavior, Guardrails, Activity and Setup. A new install's Overview starts with a short card saying what is on and where things live. /cr status prints the same summary as text, the run's cost included./cr autopilot on|off). When the context reaches the handoff point (70% by default), Project Sentinel asks Claude to write handoff notes to NEXT_SESSION_PROMPT.md, checks the file was written, runs /clear, and asks Claude in the fresh context to read the notes and carry on. The run's milestones and objective carry across. A dev server Claude left running never holds the handoff up; a build or test run is waited for, at most 20 minutes by default; typing "handoff now" (or /cr handoff now) hands off at once.git push you are asked, even in a permission mode that would not ask (Claude Code's question dialog: Run it / Don't run it), and git push --force is refused with a reason Claude can read. Change any category in Guardrails, which shows each value and whether it is the default; /cr profile switches everything at once (Normal, Classic, Frontier, Low resource, Release QA, or your own)./cr cache keep on. Shortly before the prompt cache would lapse, Project Sentinel re-sends the last request so the next one reads from the cache instead of rebuilding it. Off by default; it stops after two idle hours./cr queue Update the README with the findings gives Claude work for later: it goes when the current turn ends (or the milestone under way completes), never into the turn. /cr watch in 2h S-002 result parks the run and, two hours later, wakes Claude to check the result, in this context or in a fresh one from the handoff notes. If the run moved on meanwhile, the watcher only asks you. Claude can also leave you a decision (Activity → Operations → Needs review) and keep working; /cr decide D-1 answers it./cr companion on. Kit paces while Claude thinks, types while it works, reads while it searches, celebrates a green finish and walks off with the notes at a handoff, and wanders the whole status bar in between. Pat its head, boop its nose or pet it: it reacts to where you touch it. /cr motion off holds it still./cr help lists every command.
Project Sentinel is a Claude Code plugin of function hooks. This is everything it does beyond drawing its status bar and panel. Each part answers one question Anthropic's plugin directory asks of a plugin like this one.
Prompts go to the model your session already uses, each as if you had typed it, only when nothing is running, one at a time. They carry the text below, your own words where named, and nothing read from your files' contents; what they name from the session (milestone titles, file names, check commands, background tasks) is listed with each.
Context Autopilot (on for a new install; off for an install from before 1.7.0 unless you turned it on), at most three per handoff:
milestones tool or Claude Code's task list), the project's own documentation and, for durable instructions only, CLAUDE.md; write handoff notes to NEXT_SESSION_PROMPT.md at the project root; start no new work; name a server it left running in the notes so the next context reuses it. When a completed milestone's files changed since it was verified: those milestones' titles, the changed files' names and the checks to rerun (their command lines), asking Claude to rerun them or say in the notes that their verification is stale./clear: the notes file's path and the session number, asking Claude to read the notes and carry on. That first message also carries one context block, contextAutopilot: the run and session numbers, the notes file's path, the names of the policies that are on, the milestones' titles and states, the run's objective (in Claude's words, or your latest request's), the background tasks still running from the previous context (Claude Code's description and task id: reuse a server, record a result when it arrives), any milestones whose verification went stale (as above), and a message you typed while the handoff waited and chose to send to the fresh context (your words).Operations, only for something you set up (work you queued, a decision you answered, a watcher you or its Scout armed, a fresh start you chose):
/clear for a watcher's fresh wake or the Cold Resume Guard's Start fresh: why it is fresh, the notes file's path, what to do next (with Start fresh, your message, so it is not lost). Its first message carries the same contextAutopilot block, plus the watcher's wake, work queued for the fresh context and the decisions still open or answered.It also adds short notes for Claude to read, with the next batch of tool results or your next message (never appended to the transcript), each beginning Control Room · or Project Sentinel ·: that a handoff is near, that machine load is high (and later back to normal), that you changed a setting, that a policy was held or restored, work you queued for after the milestone just completed, your answers to its decisions, that a background task's result arrived while the handoff waits for it (record it in the notes, then end the turn), and, with a run budget set to Finish the milestone, that the budget is reached.
Only /clear, and Claude Code's compaction (/compact where a session compacts only inside a turn): at a Context Autopilot handoff, once the notes file is written (compaction if you chose it), including when you choose Hand off now or /cr fresh now while a handoff waits; at a watcher's fresh wake, only while the run is still where the watcher left it and the handoff notes are newer than its milestones; and when you choose Start fresh or Compact first in the Cold Resume Guard. No other command.
Only TaskStop, when you ask: Stop on a background job Claude started (Guardrails → Machine load, or beside a task a handoff waits for in Context), Stop & hand off or /cr handoff stop (only the work the handoff waits for, never a server; asked first when you type it), or Stop on an agent (Activity → Agents). When you write to an agent there and press Send, the text goes to that agent through Claude Code's own SendMessage delivery. No other tool.
It asks in Claude Code's own question dialog: a call set to Ask; before a message re-reads a large conversation whose prompt cache has lapsed (the Cold Resume Guard); before your next message once a run budget set to Ask is reached; /cr decide; a watcher time that reads two ways; a message you type while a written handoff waits (give it to the fresh context, send it here, or back to the prompt box); and before stopping background work you asked it to stop by typing. Where no one can be asked (a headless run) it does not ask. While a handoff is under way, a short message about the handoff itself ("handoff now", "keep waiting", "cancel the handoff") is carried out instead of being sent to Claude, and the reason shown says what was done. The Cold Resume Guard's Cancel puts your message back in the prompt box ($.prompt.fill). After Start fresh or Compact first, which send that message themselves, it reads the prompt box ($.prompt.read) and empties it only while it holds just that message, which Claude Code put back there; a draft you typed is left alone.
Every tool call passes through one hook. It reads the tool's name and input, in memory, to sort the call into the permission categories, to recognise a heavy job (a build, a test suite, an install) for machine load, to place an edited path inside or outside the project, and to count activity. Then it does one of three things:
It never changes a call's input, never answers a permission check, never allows a call Claude Code would not, and never loosens a deny. To tell whether Claude Code would ask, it queries Claude Code's own verdict, a check that runs nothing. The command patterns in its source (a download piped into a shell, a token sent to a remote host) are there to recognise such commands so it can ask or refuse; it never runs them.
milestones (mcp__project-sentinel__milestones), offered to Claude only where Claude Code has no task list of its own. Its hook answers the call by recording the run's milestones (their titles and states) in memory and in the plugin's store.decision_request (mcp__project-sentinel__decision_request), offered while the Decision Inbox is on (the default). Its hook answers the call by recording Claude's question (with its context, up to four options, whether it blocks, its milestone) for you, and tells Claude to carry on. Its description tells Claude not to use it for status updates, for choices Claude can make itself, or for permission, safety or destructive-action confirmations, which stay Claude Code's own.It answers no other tool in that tool's place.
Its hook on starting a subagent does one of three things. With subagent limits on (off by default) it refuses a new subagent while subagents are turned off, past your limit, or when you decline it in the Ask dialog. With the model router's subagent routing on (the router is off by default) it changes the model of the subagent being started, and nothing else. Otherwise it passes the start on unchanged. It never changes a subagent's permission mode, tools, prompt or description, and never starts one itself.
No shell. Each is one program, named with fixed arguments written out where it is started; nothing from your session is passed to it except, to git hash-object, the paths of a completed milestone's own files. What it prints stays in memory, except those blob ids:
typeperf "\Processor(_Total)\% Processor Time" "\Memory\Available Bytes" -si 2 (Windows' own performance-counter reader, one process while live load is on) and, once, systeminfo /fo csv /nh for the machine's total memory.top -l 0 -s 2 -n 0 (one process while live load is on) and sysctl -n kern.memorystatus_level at each reading./proc/stat and /proc/meminfo.git status --porcelain=v1 --branch --untracked-files=normal in the project, for the branch and the count of uncommitted files.git hash-object --no-filters -- <files> (read-only: it prints each file's Git blob id and writes nothing) for the files a completed milestone's work changed, and git rev-parse --verify -q HEAD for the commit. Run when a milestone is completed, then to compare (at a handoff, a session's start, and at most once a minute after a turn), and only for files whose size or time moved. Never the whole repository. The ids are kept in the run record, to say later that a milestone's verification is stale, never that it is wrong.Live load (machine-wide CPU and memory, every few seconds) is on by default; Setup turns it off. On a Windows installed in a language other than English the counters have other names, so typeperf finds none and the readings show as unavailable.
The session's usage and model from Claude Code, including the plan's rate-limit windows (percent used and reset time of the five-hour and seven-day windows) and whether a request ended on the rate limit; your prompts and tool calls in memory, to show activity and progress (and, while a handoff is under way, whether a short message is about the handoff); file status (never contents) of the handoff notes, of paths Claude edits, to place them inside or outside the project, and of a completed milestone's files, to tell whether they changed; Claude Code's managed policy settings, to know whether its policy section can be added; Claude Code's list of the session's agents (id, type, description, status, parent, name), each agent's start (its type, description, model, parent, a workflow run) and its own tool calls, task list and end; the background tasks Claude Code lists when a turn stops (their description and command), to tell a server from work worth waiting for; what Claude Code says of a resumed session's cache (seconds since the last answer, context size, whether the cache likely expired, its own estimate of re-caching) and its estimate at a model switch; the cache lifetime Claude Code is set to use (the environment variables FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL and ENABLE_PROMPT_CACHING_1H, and the promptCacheTtl setting); Claude's last message as a turn ends, in memory, for the Watcher Scout to see whether Claude said it will check back at a time; and the output of the programs above. In a session that still runs Control Room it reads Control Room's status bar, and stands by until the session restarts.
It reads no credentials: no API keys, tokens, passwords or login files. Once, after its rename, it reads the store it kept when it was called Control Room (its own settings and run records, a JSON file in Claude Code's plugin store) and copies them into its new store; it never writes, moves or deletes that file. The environment variables CLAUDE_CONFIG_DIR, USERPROFILE and HOME are read only to find it.
Nothing it reads leaves your machine through the plugin. It makes no network requests of its own, the programs above get only their fixed arguments, and its records stay in Claude Code's plugin store on this machine. What leaves goes through Claude Code's own model requests, to the model your session uses:
A section of plain-language policies for the features you turned on (Frontier Max, answer styles, subagent limits, machine load, milestones), readable in the panel. It never hides instructions. The model router (off by default) picks a model and effort per step; Frontier Max (on for a new install, at Extra High) raises effort where the model has an effort setting, and never changes the model.
In Claude Code's own plugin store on your machine: your settings, run records (the objective, in Claude's words or your latest request's, milestone titles and states, session and handoff counts, token and cost totals, the project folder) and, with them, the run's operations: work you queued (in your words), Claude's decision questions with their context and options and your answers, watchers (what each waits for, when it wakes, its strategy, and the run's fingerprint when it was armed: a turn count and a digest of the milestones), the run budget, and a short log of what the layer did; each completed milestone's evidence (its files' paths, Git blob ids, sizes and times, the commit, and the checks that passed with their command lines); the background tasks a handoff left running (description, task id); the Quest log's XP; the cache's lifetime once learned, and Claude Code's own cache-write price per model from its estimates; on a new install, when its first-run card was shown and dismissed. In the session's own state (gone when the session ends): when the cache was last written, a handoff under way, and the agents seen. Delete the store file to remove them.
Its hooks as readable TypeScript, its type declarations, this README, LICENSE, and .claude-plugin/icon.png, the directory listing's icon: no code reads, loads or runs that image.
Privacy. No telemetry, no analytics, no network calls of its own. Nothing leaves your machine except through Claude Code's own model requests named above.
Full documentation, configuration, troubleshooting and the source: github.com/Arjun0014/project-sentinel. MIT licensed (see LICENSE). The engine's function-hooks API ("mods") is early access and may change between Claude Code releases.
hooks/register.tsx 582 lines1/**
2 * Control Room — the hooks module.
3 *
4 * This file is deliberately thin: it builds the Host adapter from `$` (the
5 * one place the engine interface is spelled, as `claude plugin validate`
6 * requires) and registers every hook, each a few lines delegating to the
7 * Runtime. Gating hooks carry a `.catch` that falls back to Claude Code's
8 * own behaviour — never to anything looser.
9 */
10
11import { atom, read } from 'claude-code'
12import type { EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code'
13
14import { PANE_ID } from './constants'
15import { POLICY_SECTION_ID } from './core/policy'
16import { isObviouslyDangerous } from './features/permissions/categories'
17import { isEditTool, isShellTool } from './features/permissions/decide'
18import { actionsOf } from './app/actions'
19import { Runtime } from './app/runtime'
20import * as Views from './app/views'
21import type { Host } from './host'
22import { type Kit, uiOf } from './ui/kit'
23import { hudView } from './ui/hud'
24import { TAB_NEEDS, paneView } from './ui/pane/frame'
25import { collapseBar, compactToolRow, hiddenRow } from './ui/rows'
26
27// ---------------------------------------------------------------------------
28// `$.state` atoms: literal references, as the validator requires.
29
30const HUD = { plugin: 'project-sentinel', key: 'hud' } as const
31const PANE = { plugin: 'project-sentinel', key: 'pane' } as const
32const RESOURCES = { plugin: 'project-sentinel', key: 'resources' } as const
33const CHAIN = { plugin: 'project-sentinel', key: 'chain' } as const
34const ACTIVITY = { plugin: 'project-sentinel', key: 'activity' } as const
35const PERMISSIONS = { plugin: 'project-sentinel', key: 'permissions' } as const
36const FOCUS = { plugin: 'project-sentinel', key: 'focus' } as const
37const SPINNER = { plugin: 'project-sentinel', key: 'spinner' } as const
38const AUTOPILOT = { plugin: 'project-sentinel', key: 'autopilot' } as const
39/** Control Room's status bar (the former name), read only: while it runs in this session, Project Sentinel stands by. */
40const FORMER_HUD = { plugin: 'control-room', key: 'hud' } as const
41/** Whether the standby note was shown in this session: kept across reloads, so it shows once. */
42const STANDBY = { plugin: 'project-sentinel', key: 'standby' } as const
43/** The policy section this context's system prompt carries: a reload keeps sending it while the cache is warm. */
44const POLICY = { plugin: 'project-sentinel', key: 'policy' } as const
45/** The orchestration layer's view (Activity → Operations, Overview, Context). */
46const OPS = { plugin: 'project-sentinel', key: 'ops' } as const
47/** The prompt cache's last request in this context: a reload keeps knowing when it lapses. */
48const CACHE_MEMO = { plugin: 'project-sentinel', key: 'cacheMemo' } as const
49const AGENT_LEDGER = { plugin: 'project-sentinel', key: 'agents' } as const
50
51const blank = new Runtime()
52const hudAtom = atom(HUD, Views.hudOf(blank))
53const paneAtom = atom(PANE, Views.paneOf(blank))
54const resourcesAtom = atom(RESOURCES, Views.resourcesOf(blank))
55const chainAtom = atom(CHAIN, Views.chainOf(blank))
56const activityAtom = atom(ACTIVITY, Views.activityOf(blank))
57const permissionsAtom = atom(PERMISSIONS, Views.permissionsOf(blank))
58const focusAtom = atom(FOCUS, Views.focusOf(blank))
59const spinnerAtom = atom(SPINNER, Views.spinnerOf(blank))
60const opsAtom = atom(OPS, Views.opsOf(blank))
61
62// ---------------------------------------------------------------------------
63// The Host: every engine call Control Room makes, spelled once.
64
65function hostOf($: EngineInterface): Host {
66 return {
67 pluginRoot: $.plugin.root,
68 now: () => $.clock.now(),
69 after: (ms, fn) => $.clock.after(ms, fn),
70 every: (ms, fn) => $.clock.every(ms, fn),
71
72 sessionId: () => $.session.id(),
73 sessionRoot: () => $.session.root(),
74 sessionModel: () => $.session.model(),
75 usage: () => $.session.usage(),
76 usageSummary: () => $.session.usage({ breakdown: 'summary' }),
77 version: () => $.session.version(),
78 surfaces: () => $.session.surfaces(),
79 compact: instructions => $.session.compact({ instructions }),
80
81 submit: text => $.prompt.submit({ text }),
82 submitAsUser: text => $.prompt.submit({ text, asUser: true }),
83 fillPrompt: text => $.prompt.fill({ text }).then(r => ({ isFilled: r.isFilled, refusal: r.refusal })),
84 readPrompt: () => $.prompt.read().then(r => ({ text: r.text })),
85 clearContext: () => $.command.run({ command: 'clear', args: '' }),
86 compactCommand: instructions => $.command.run({ command: 'compact', args: instructions }),
87 registerCommand: spec => $.command.register(spec),
88 listCommands: () => $.command.list(),
89
90 listAgents: () => $.agent.list(),
91 listTools: () => $.tool.list(),
92 registerTool: spec => $.tool.register(spec).then(r => r.tool),
93 stopTask: taskId => $.tool.call({ tool: 'TaskStop', task_id: taskId }),
94 sendToAgent: (agentId, text) => $.session.send({ to: { agentId }, text }).then(r => ({ isDelivered: r.isDelivered, reason: r.reason })),
95 classify: (text, labels, model) => $.model.classify(text, labels, model === undefined ? undefined : { model }),
96 fork: prompt => $.model.fork({ prompt }),
97
98 toast: (text, timeoutMs) => $.ui.toast(text, timeoutMs === undefined ? undefined : { timeoutMs }),
99 status: text => $.ui.status(text),
100 open: pane => $.ui.open(pane),
101 close: pane => $.ui.close(pane),
102 panes: () => $.ui.panes(),
103 scrollPaneToTop: () => $.ui.scroll({ in: PANE_ID, to: 'start' }).then(() => undefined),
104 scrollPaneTo: key => $.ui.scroll({ in: PANE_ID, to: { key }, block: 'start' }).then(r => r.deny ?? null),
105 focusPane: key => $.ui.focus({ requestId: PANE_ID, key }).then(r => r.deny ?? null),
106 ask: (question, options, header) => $.ui.ask(question, header === undefined ? options : { options, header }),
107 checkTool: (tool, input) => $.tool.check({ tool, input }),
108 copy: (text, surface) => $.ui.copy({ text, surface }).then(r => r.isCopied),
109
110 readText: path => $.fs.read(path),
111 stat: (path, resolve) => $.fs.stat(path, { resolve }),
112 exists: path => $.fs.exists(path),
113
114 storeGet: key => $.store.get(key),
115 storeSet: (key, value) => $.store.set(key, value),
116 storeDelete: key => $.store.delete(key),
117
118 settings: source => $.settings.read(source === undefined ? undefined : { source }),
119 cacheTtlConfig: async () => ({
120 force5m: await $.env.get('FORCE_PROMPT_CACHING_5M'),
121 envTtl: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
122 setting: ((await $.settings.read().catch(() => ({}))) as Record<string, unknown>).promptCacheTtl,
123 enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
124 }),
125 configEnv: async () => ({
126 claudeConfigDir: await $.env.get('CLAUDE_CONFIG_DIR'),
127 userProfile: await $.env.get('USERPROFILE'),
128 home: await $.env.get('HOME'),
129 }),
130 formerHud: () => $.state.get(FORMER_HUD).then(read => read.value),
131 isStandbyNoted: () => $.state.get(STANDBY).then(read => read.value?.isNoted === true),
132 noteStandby: () => $.state.set(STANDBY, { isNoted: true }).then(() => undefined),
133 // The CPU and memory samplers: each one program by name with fixed arguments, no shell, reading
134 // machine-wide totals only (the 2 in each is SAMPLER_EVERY_SEC).
135 spawnSampler: platform =>
136 platform === 'windows'
137 ? $.process.spawn({ argv: ['typeperf.exe', '\\Processor(_Total)\\% Processor Time', '\\Memory\\Available Bytes', '-si', '2'] })
138 : $.process.spawn({ argv: ['top', '-l', '0', '-s', '2', '-n', '0'] }),
139 systemInfo: () => $.process.run(['systeminfo.exe', '/fo', 'csv', '/nh'], { timeoutMs: 30_000 }),
140 memoryLevel: () => $.process.run(['sysctl', '-n', 'kern.memorystatus_level'], { timeoutMs: 5_000 }),
141 repoRoot: () => $.session.repo().then(r => r?.root ?? null),
142 gitStatus: () => $.process.run(['git', 'status', '--porcelain=v1', '--branch', '--untracked-files=normal'], { timeoutMs: 10_000 }),
143 hashFiles: paths => $.process.run(['git', 'hash-object', '--no-filters', '--', ...paths], { timeoutMs: 10_000 }),
144 gitHead: () => $.process.run(['git', 'rev-parse', '--verify', '-q', 'HEAD'], { timeoutMs: 5_000 }),
145
146 publishHud: v => $.state.set(HUD, v).then(() => undefined),
147 publishPane: v => $.state.set(PANE, v).then(() => undefined),
148 publishResources: v => $.state.set(RESOURCES, v).then(() => undefined),
149 publishChain: v => $.state.set(CHAIN, v).then(() => undefined),
150 publishActivity: v => $.state.set(ACTIVITY, v).then(() => undefined),
151 publishPermissions: v => $.state.set(PERMISSIONS, v).then(() => undefined),
152 publishFocus: v => $.state.set(FOCUS, v).then(() => undefined),
153 publishSpinner: v => $.state.set(SPINNER, v).then(() => undefined),
154 publishOps: v => $.state.set(OPS, v).then(() => undefined),
155
156 saveAutopilotRecord: record => $.state.set(AUTOPILOT, { record }).then(() => undefined),
157 loadAutopilotRecord: () => $.state.get(AUTOPILOT).then(read => read.value?.record ?? null),
158 savePolicyMemo: memo => $.state.set(POLICY, memo).then(() => undefined),
159 loadPolicyMemo: () => $.state.get(POLICY).then(read => read.value ?? null),
160 saveCacheMemo: memo => $.state.set(CACHE_MEMO, memo).then(() => undefined),
161 loadCacheMemo: () => $.state.get(CACHE_MEMO).then(read => read.value ?? null),
162 saveAgentLedger: list => $.state.set(AGENT_LEDGER, list).then(() => undefined),
163 loadAgentLedger: () => $.state.get(AGENT_LEDGER).then(read => read.value ?? null),
164
165 invalidateDescribes: () => $.ui.invalidate('tool.describe'),
166 invalidatePromptContext: () => $.ui.invalidate('prompt.context'),
167 trace: text => $.ui.log(text, { to: 'debug' }),
168 }
169}
170
171const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
172
173export const register: Register = on => {
174 const rt = new Runtime()
175 const actions = actionsOf(rt)
176 const kitOf = (table: Parameters<typeof uiOf>[0], columns: number, surface: RenderSurface, placement?: Kit['placement']): Kit => ({
177 ui: uiOf(table, surface),
178 actions,
179 columns: Math.max(20, columns),
180 surface,
181 now: Date.now(),
182 openPicker: null,
183 placement,
184 })
185
186 // -------------------------------------------------------------------------
187 // Session lifecycle
188
189 on('session.start', async ($, e, next) => {
190 if (rt.host === null) rt.bind(hostOf($))
191 const started = await next(e)
192 // Control Room still runs in this session (an update loaded both): stand by until it restarts.
193 if (await rt.checkStandby()) return started
194 await rt.onSessionStart(e)
195 return started
196 })
197
198 on('session.end', async ($, e, next) => {
199 if (rt.isStandby) return next(e)
200 if (rt.host === null) rt.bind(hostOf($))
201 await rt.onSessionEnd(e).catch(() => undefined)
202 return next(e)
203 })
204
205 // A session begins, or a fresh context after /clear: noted, and passed on unchanged (the fresh
206 // context's notes ride its first prompt instead, so the session's start is Claude Code's own).
207 on('classic.SessionStart', async ($, e, next) => {
208 if (rt.isStandby) return next(e)
209 if (rt.host === null) rt.bind(hostOf($))
210 if (e.permission_mode !== undefined) rt.permissionMode = e.permission_mode
211 await rt.onClassicSessionStart({
212 source: e.source,
213 sessionId: e.session_id,
214 model: e.model,
215 transcriptPath: e.transcript_path,
216 // A resumed session: Claude Code's own word on its cache (the Cold Resume Guard's facts).
217 secondsSince: e.seconds_since_last_response,
218 contextTokens: e.context_tokens,
219 isCacheExpired: e.prompt_cache_likely_expired,
220 usd: e.estimated_cache_write_usd,
221 })
222 return next(e)
223 }).catch(($, e, next) => next(e))
224
225 on('classic.UserPromptSubmit', ($, e, next) => {
226 if (rt.isStandby) return next(e)
227 if (e.permission_mode !== undefined) rt.permissionMode = e.permission_mode
228 return next(e)
229 }).catch(($, e, next) => next(e))
230
231 on('session.attach', async ($, e, next) => {
232 if (rt.isStandby) return next(e)
233 const answer = await next(e)
234 if (!rt.surfaces.includes(e.surface)) rt.surfaces = [...rt.surfaces, e.surface]
235 rt.publisher.mark('pane')
236 return answer
237 })
238
239 on('session.measure', ($, e, next) => {
240 if (rt.isStandby) return next(e)
241 rt.onMeasure(e)
242 return next(e)
243 })
244
245 on('session.compact', async ($, e, next) => {
246 if (rt.isStandby) return next(e)
247 const answer = await next(e)
248 if (e.agentId === undefined) rt.onCompacted(e.trigger, answer)
249 return answer
250 }).catch(($, e, next) => next(e))
251
252 // -------------------------------------------------------------------------
253 // Prompts and the system prompt
254
255 on('prompt.submit', async ($, e, next) => {
256 if (rt.isStandby) return next(e)
257 if (rt.host === null) rt.bind(hostOf($))
258 await rt.ensureLoaded()
259 // A handoff under way first: an instruction about it ("handoff now") is acted on and never sent, and
260 // other words are not let into a context about to be cleared unasked. Then the Cold Resume Guard:
261 // before a message re-reads a large context whose cache has surely lapsed, ask (nothing is sent
262 // yet). The Run Budget asks once at a limit set to Ask. Any of them may keep the message.
263 const held = (await rt.handoffGuard(e)) ?? (await rt.ops.coldGuard(e)) ?? (await rt.ops.budgetGuard(e))
264 if (held !== null) return held
265 // The origin tells a background task's notification (and the turn it starts) from the person's prompt;
266 // its end is timed by the engine's clock, as the handoff's other steps are.
267 const origin: { kind: string } | undefined = e.origin
268 const now = origin?.kind === 'task-notification' ? await $.clock.now() : undefined
269 const extra = rt.onPromptSubmit(e.text, e.origin, e.turnId, now)
270 return next(extra.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...extra] })
271 }).catch(($, e, next) => next(e))
272
273 // The fresh context after Control Room's own handoff: its first message carries the notes as one
274 // more context block, once (the run, the notes file's path, the milestones, the objective).
275 on('prompt.context', ($, e, next) => {
276 if (rt.isStandby) return next(e)
277 const notes = rt.takeFreshContext()
278 return next(notes === null ? e : { ...e, blocks: [...e.blocks, { name: 'contextAutopilot', text: notes }] })
279 }).catch(($, e, next) => next(e))
280
281 // The system prompt's own section. The settings are loaded first, whichever hook is the session's
282 // first: a request is never composed from defaults while the person's settings are still loading.
283 on('prompt.compose', async ($, e, next) => {
284 if (rt.isStandby) return next(e)
285 if (rt.host === null) rt.bind(hostOf($))
286 await rt.ensureLoaded()
287 const answer = await next(e)
288 const section = rt.composeSection(e.outputStyle, e.traits)
289 if (section === null) return answer
290 return { sections: [...answer.sections.filter(s => s.id !== POLICY_SECTION_ID), section] }
291 }).catch(($, e, next) => next(e))
292
293 // -------------------------------------------------------------------------
294 // Turns
295
296 on('turn.start', async ($, e, next) => {
297 if (rt.isStandby) return next(e)
298 await rt.onTurnStart({ turnId: e.turnId, text: e.text })
299 return next(e)
300 })
301
302 on('turn.step', async function* ($, e, next) {
303 if (rt.isStandby) return yield* next(e)
304 if (rt.host === null) rt.bind(hostOf($))
305 await rt.ensureLoaded()
306 const want = rt.stepRequest(e)
307 const request = want.model === undefined && want.effort === undefined ? e : { ...e, ...want }
308 const answer = yield* next(request)
309 rt.stepResponse(e, want, answer)
310 return answer
311 })
312
313 on('turn.complete', async ($, e, next) => {
314 if (rt.isStandby) return next(e)
315 const answer = await next(e)
316 await rt.onTurnComplete({ agentId: e.agentId, reason: e.reason, answer: e.answer })
317 return answer
318 })
319
320 // Model switches: the engine reports the cache's state and lifetime. With a large
321 // warm cache, a switch the person makes is confirmed first (Cache Guardian).
322 on('classic.PreModelSwitch', async ($, e, next) => {
323 if (rt.isStandby) return next(e)
324 const answer = await next(e)
325 if (answer.permissionDecision === 'deny' || answer.permissionDecision === 'ask') return answer
326 const reason = rt.onPreModelSwitch(e)
327 return reason === null ? answer : { ...answer, permissionDecision: 'ask' as const, permissionDecisionReason: reason }
328 }).catch(($, e, next) => next(e))
329
330 on('classic.PostModelSwitch', ($, e, next) => {
331 if (rt.isStandby) return next(e)
332 rt.onPostModelSwitch(e)
333 return next(e)
334 }).catch(($, e, next) => next(e))
335
336 // Notes for Claude while it works (a setting changed, the machine is busy, the handoff is coming)
337 // go with the next batch of tool results, as Claude Code's own hook context: filed with that batch,
338 // so every later request reads the conversation exactly as this one did (app/notes.ts). Main
339 // conversation only: a subagent's batch carries its `agent_id`.
340 on('classic.PostToolBatch', async ($, e, next) => {
341 const answer = await next(e)
342 if (rt.isStandby || e.agent_id !== undefined) return answer
343 const notes = rt.notesForBatch()
344 return notes.length === 0 ? answer : { ...answer, additionalContext: [...(answer.additionalContext ?? []), ...notes] }
345 }).catch(($, e, next) => next(e))
346
347 // Claude Code's record that the API found the conversation's earlier part changed and dropped the
348 // thinking made over it (`thinking_drop`): proof of why the next request rebuilds the cache. Only
349 // the row's kind is read; the row is passed on unchanged.
350 on('session.append', { door: 'attachment' }, async ($, e, next) => {
351 if (!rt.isStandby && e.agentId === undefined && e.message.name === 'thinking_drop') rt.cache.noteHistoryChanged(rt.clock())
352 return next(e)
353 }).catch(($, e, next) => next(e))
354
355 on('classic.Stop', async ($, e, next) => {
356 if (rt.isStandby) return next(e)
357 const answer = await next(e)
358 if (answer.block !== undefined || answer.preventContinuation === true) return answer
359 const block = await rt.onStop({
360 stopHookActive: e.stop_hook_active,
361 lastMessage: e.last_assistant_message ?? '',
362 background: e.background_tasks ?? [],
363 wakeups: e.session_crons ?? [],
364 permissionMode: e.permission_mode,
365 })
366 return block === null ? answer : { ...answer, block }
367 }).catch(($, e, next) => next(e))
368
369 // A turn Claude Code ended on an API error: a rate limit is the plan's limit reached (the status bar's 5H reads it).
370 on('classic.StopFailure', ($, e, next) => {
371 if (!rt.isStandby) rt.onStopFailure(e.error)
372 return next(e)
373 }).catch(($, e, next) => next(e))
374
375 // -------------------------------------------------------------------------
376 // Tools: permissions, resources, activity
377
378 // Control Room's own milestones tool, offered only where Claude Code has no task list.
379 // Registered before the general hook, so it answers the call itself.
380 on('tool.call', { tool: /^mcp__project-sentinel__milestones$/ }, async ($, e, next) => {
381 if (rt.isStandby) return next(e)
382 if (rt.host === null) rt.bind(hostOf($))
383 await rt.ensureLoaded()
384 return { result: rt.recordMilestones(isRecord(e) ? { ...e } : {}, e.agentId) }
385 })
386
387 // The Decision Inbox's tool (Behavior → Decisions): the question waits for the person; Claude is told what to do meanwhile.
388 on('tool.call', { tool: /^mcp__project-sentinel__decision_request$/ }, async ($, e, next) => {
389 if (rt.isStandby) return next(e)
390 if (rt.host === null) rt.bind(hostOf($))
391 await rt.ensureLoaded()
392 return { result: rt.ops.decisionTool(isRecord(e) ? { ...e } : {}, e.agentId) }
393 })
394
395 on('tool.call', async ($, e, next) => {
396 if (rt.isStandby) return next(e)
397 if (rt.host === null) rt.bind(hostOf($))
398 const tool = String(e.tool)
399 const input: Record<string, unknown> = isRecord(e) ? { ...e } : {}
400 const refusal = await rt.beforeTool(tool, input, e.tool_use_id, e.agentId)
401 if (refusal !== null) return { deny: refusal }
402 let answer: Awaited<ReturnType<typeof next>> | undefined
403 try {
404 answer = await next(e)
405 return answer
406 } finally {
407 rt.afterTool(tool, input, e.tool_use_id, answer, e.agentId)
408 }
409 }).catch(($, e, next) => {
410 // Fail safe: a crash in the full classifier still never lets an obvious catastrophe through.
411 const raw = (e as unknown as Record<string, unknown>).command
412 const command = isShellTool(String(e.tool)) && typeof raw === 'string' ? raw : ''
413 if (!next.called && command !== '' && isObviouslyDangerous(command)) {
414 return { deny: 'Control Room Permission Policy: refused a dangerous command (safety fallback).' }
415 }
416 return next(e)
417 })
418
419 // -------------------------------------------------------------------------
420 // Subagents
421
422 on('agent.offer', async ($, e, next) => {
423 if (rt.isStandby) return next(e)
424 if (rt.host === null) rt.bind(hostOf($))
425 await rt.ensureLoaded()
426 return rt.isAgentOffered(e) ? next(e) : { isOffered: false }
427 }).catch(($, e, next) => (rt.settings.subagents.mode === 'block' ? { isOffered: false } : next(e)))
428
429 on('agent.spawn', async ($, e, next) => {
430 if (rt.isStandby) return next(e)
431 if (rt.host === null) rt.bind(hostOf($))
432 const decision = await rt.onAgentSpawn(e)
433 if (decision.deny !== undefined) return { deny: decision.deny }
434 // The agent shows from its spawn (its id, model, background, fork, parent, a workflow run's place);
435 // its slot is released whatever the start's outcome.
436 let answer: Awaited<ReturnType<typeof next>> | undefined
437 try {
438 answer = await next(decision.model === undefined ? e : { ...e, model: decision.model })
439 return answer
440 } finally {
441 rt.onAgentSpawned(e, answer, decision.isReserved === true)
442 }
443 }).catch(($, e, next) =>
444 rt.settings.subagents.mode === 'block' && !next.called ? { deny: 'Control Room: subagents are disabled in this session.' } : next(e),
445 )
446
447 // -------------------------------------------------------------------------
448 // Commands
449
450 on('command.run', { command: ['control-room', 'cr'] }, async ($, e, next) => {
451 if (rt.isStandby) return next(e)
452 if (rt.host === null) rt.bind(hostOf($))
453 if (!rt.isOwnCommand(e.command)) return next(e)
454 await rt.ensureLoaded()
455 return rt.runCommand(e.args)
456 }).catch(($, e, next) =>
457 next.called ? next(e) : { text: 'Control Room could not complete that command (details in the debug log: claude --debug).' },
458 )
459
460 // Kit's module reports that it could not draw: leave it out from now on, so the status bar draws without it.
461 on('ui.message', async ($, e, next) => {
462 if (rt.isStandby) return next(e)
463 const answer = await next(e)
464 if (e.element === 'kit' && isRecord(e.data) && typeof e.data.fault === 'string') {
465 rt.companionFault = e.data.fault.slice(0, 160)
466 rt.publisher.mark('hud')
467 }
468 return answer
469 }).catch(($, e, next) => next(e))
470
471 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
472 if (rt.isStandby) return next(e)
473 const answer = await next(e)
474 if (answer.deny === undefined) {
475 rt.ui.isPaneOpen = false
476 rt.publisher.mark('hud')
477 }
478 return answer
479 }).catch(($, e, next) => next(e))
480
481 // -------------------------------------------------------------------------
482 // Drawing
483
484 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
485 if (rt.isStandby) return next(e)
486 if (e.props.hasSurvey || e.surface === 'mobile') return next(e)
487 const hud = await read($, hudAtom)
488 if (!hud.isVisible) return next(e)
489 const below = await next(e)
490 // Kit, the companion, on a lane of its own: its surface module plays it on the surface's own clock.
491 let stage: RenderElement | null = null
492 if (hud.companion !== null && (e.surface === 'terminal' || e.surface === 'desktop')) {
493 const ui = $.ui.resolve(e)
494 const kitProps = { ...hud.companion, surface: e.surface }
495 // Five rows in the terminal; on Desktop the lane is as tall as the image the module draws.
496 stage =
497 e.surface === 'terminal' ? (
498 <ui.Client key="kit" module="./kit.client.tsx" props={kitProps} height={5} flexGrow={1} />
499 ) : (
500 <ui.Client key="kit" module="./kit.client.tsx" props={kitProps} flexGrow={1} />
501 )
502 }
503 const own = hudView(kitOf($.ui.resolve(e), e.props.bodyColumns, e.surface), hud, stage)
504 if (below.type === 'engine') return own
505 const { Box } = $.ui.resolve(e)
506 return (
507 <Box flexDirection="column">
508 {own}
509 {below}
510 </Box>
511 )
512 })
513
514 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
515 if (rt.isStandby) return next(e)
516 const pane = await read($, paneAtom)
517 const hud = await read($, hudAtom)
518 const needs = TAB_NEEDS[pane.tab]
519 const data = {
520 pane,
521 hud,
522 resources: needs.includes('resources') ? await read($, resourcesAtom) : undefined,
523 chain: needs.includes('chain') ? await read($, chainAtom) : undefined,
524 activity: needs.includes('activity') ? await read($, activityAtom) : undefined,
525 permissions: needs.includes('permissions') ? await read($, permissionsAtom) : undefined,
526 ops: needs.includes('ops') ? await read($, opsAtom) : undefined,
527 }
528 return paneView(kitOf($.ui.resolve(e), e.props.bodyColumns, e.surface, e.props.placement), data)
529 })
530
531 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
532 if (rt.isStandby) return next(e)
533 const focus = await read($, focusAtom)
534 if (!focus.isOn) return next(e)
535 const kit = kitOf($.ui.resolve(e), e.viewport?.columns ?? 100, e.surface)
536 if (focus.expanded.includes(e.props.tool_use_id)) {
537 const { Box } = kit.ui
538 return (
539 <Box flexDirection="column">
540 {collapseBar(kit, e.props.tool_use_id)}
541 {await next(e)}
542 </Box>
543 )
544 }
545 if (focus.tools === 'hidden') return hiddenRow(kit)
546 return compactToolRow(kit, e.props)
547 })
548
549 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
550 if (rt.isStandby) return next(e)
551 const focus = await read($, focusAtom)
552 if (!focus.isOn || focus.expanded.includes(e.props.tool_use_id)) return next(e)
553 const isDiff = isEditTool(e.props.tool)
554 const isShown = isDiff ? focus.diffs : focus.results
555 if (isShown || e.props.isErrored) return next(e)
556 return hiddenRow(kitOf($.ui.resolve(e), 40, e.surface))
557 })
558
559 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
560 if (rt.isStandby) return next(e)
561 const focus = await read($, focusAtom)
562 if (!focus.isOn || focus.tools !== 'hidden' || e.props.isExpanded) return next(e)
563 return hiddenRow(kitOf($.ui.resolve(e), 40, e.surface))
564 })
565
566 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
567 if (rt.isStandby) return next(e)
568 const spinner = await read($, spinnerAtom)
569 if (spinner.line === null) return next(e)
570 return next({ ...e, props: { ...e.props, message: spinner.line } })
571 })
572
573 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
574 if (rt.isStandby) return next(e)
575 const hud = await read($, hudAtom)
576 const label = hud.frontier.isOn && hud.profile.id !== 'frontier' ? 'Frontier Max' : hud.profile.id === 'normal' ? null : hud.profile.name
577 if (label === null) return next(e)
578 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, `◆ ${label}`] } })
579 })
580
581}
582hooks/constants.ts 82 lines1/**
2 * Names and limits shared across Control Room. Everything here is plain data.
3 */
4
5/** The plugin's manifest name; `$.state` contracts are keyed by it. */
6export const PLUGIN = 'project-sentinel'
7
8/** The Control Centre pane's id (also its `ui.render` requestId). */
9export const PANE_ID = 'control-room'
10
11export const PANE_TITLE = 'Control Room'
12
13/** The slash command the person types: `/control-room`. */
14export const COMMAND = 'control-room'
15
16/** A short alias, registered only when no other command already uses it. */
17export const SHORT_COMMAND = 'cr'
18
19/** The names of the entries in the plugin's `$.store` (one JSON file per plugin, shared by all sessions). */
20export const STORE_ENTRIES = {
21 settings: 'settings.v1',
22 runsIndex: 'runs.index.v1',
23 runCounter: 'runs.counter.v1',
24 runPrefix: 'run.v1.',
25 /** Quest log: lifetime XP, achievements and the latest awards. */
26 quest: 'quest.v1',
27 /** The prompt cache: the lifetime learned and Keep warm's verdict on itself. */
28 cache: 'cache.v1',
29 /** A fresh install's first-run card (1.7.0): when it was shown, and when the person dismissed it. */
30 onboarding: 'onboarding.v1',
31} as const
32
33/** The handoff file Context Autopilot asks Claude to create or update. */
34export const DEFAULT_HANDOFF_FILE = 'NEXT_SESSION_PROMPT.md'
35
36export const LIMITS = {
37 /** Recent tool calls kept for the Activity tab. */
38 activityItems: 300,
39 /** Files tracked in the Changes tab. */
40 changedFiles: 200,
41 /** Hunk text kept per changed file (Code elements cap at 10 000 chars). */
42 hunkChars: 6000,
43 /** Runs kept in the Session Chain history. */
44 runsKept: 30,
45 /** Sessions kept per run. */
46 sessionsPerRun: 60,
47 /** Custom profiles a person may save. */
48 customProfiles: 12,
49 /** Debounce for settings and run writes to `$.store`. */
50 persistDebounceMs: 1500,
51 /** Coalescing window for view-model publication. */
52 publishCoalesceMs: 120,
53 /** How often Activity's running times refresh while a tool call runs. */
54 runningTickMs: 5000,
55 /** Minimum gap between two resource pressure notices sent to Claude. */
56 pressureNoticeGapMs: 45_000,
57 /** How long a sample may be stale before monitoring reads as unavailable. */
58 sampleStaleMs: 30_000,
59 /** Delay between the handoff turn ending and the automatic /clear. */
60 clearDelayMs: 1200,
61 /** How long the handoff waits for its /compact command's compaction (a long context takes a while) before it gives up. */
62 compactByCommandMs: 10 * 60_000,
63 /**
64 * How long Control Room waits for the fresh session after its /clear
65 * resolves. The interactive terminal finishes the reset after the command
66 * returns; the Desktop host protocol before it.
67 */
68 clearSettleMs: 15_000,
69 /** Characters of the person's request and Claude's answer the guard reads. */
70 guardTextChars: 6000,
71 /** Model classification timeout for the guard and router. */
72 classifyTimeoutMs: 12_000,
73 /** From this much warm cached context, a model switch the person makes is confirmed first. */
74 guardSwitchTokens: 100_000,
75} as const
76
77/** The plugin's version, as in its manifest (kept in step on release). */
78export const VERSION = '1.7.0'
79
80/** Version floor this release was verified on (Desktop 2.1.289, CLI 2.1.292). */
81export const MIN_ENGINE = '2.1.289'
82hooks/core/policy.ts 118 lines1/**
2 * The priority system. Every hook asks this module what is in force *now*,
3 * so feature interactions are decided in one place instead of by whichever
4 * hook runs first. Order of precedence (higher wins):
5 *
6 * 1. Organisation & engine safety (deny rules, plan mode) — never loosened
7 * 2. Permission Policy — deny > ask > allow
8 * 3. The person's live actions (abort, typed prompts)
9 * 4. Context Autopilot handoff — suspends guard/router
10 * 5. Resource Governor — how, not whether
11 * 6. Subagent Control — hard spawn limits
12 * 7. Frontier Max — vetoes economy downgrades
13 * 8. No-Lazy-Exit Guard
14 * 9. Model Router
15 * 10. Focus View — presentation only
16 */
17
18import type { ModelEffort } from 'claude-code'
19
20import { type AutopilotState, isHandoffActive } from '../features/autopilot'
21import * as prompts from '../features/prompts'
22import { type Ceilings, type PressureLevel, ceilingsOf } from '../features/resources/pressure'
23import type { Settings } from './settings'
24
25export type Live = {
26 autopilot: AutopilotState
27 autopilotThreshold: number | null
28 pressure: PressureLevel
29 permissionMode: string | undefined
30}
31
32export type Effective = {
33 frontier: { isActive: boolean; effort: ModelEffort | null }
34 qa: { isActive: boolean }
35 guard: { isActive: boolean; reason: string | null }
36 router: { isActive: boolean; isMainLoop: boolean; isSubagents: boolean; reason: string | null }
37 subagents: { mode: Settings['subagents']['mode']; limit: number }
38 resources: { ceilings: Ceilings | null }
39 autopilot: { isActive: boolean; isHandoff: boolean }
40}
41
42const EFFORT: Record<Settings['frontier']['effort'], ModelEffort | null> = { max: 'max', xhigh: 'xhigh', high: 'high', keep: null }
43
44export function effective(settings: Settings, live: Live): Effective {
45 const isHandoff = settings.autopilot.enabled && isHandoffActive(live.autopilot)
46 const isPlan = live.permissionMode === 'plan'
47
48 let guardReason: string | null = null
49 if (!settings.guard.enabled) guardReason = 'Off'
50 else if (isHandoff) guardReason = 'Paused during the handoff'
51 else if (isPlan) guardReason = 'Paused in plan mode'
52
53 let routerReason: string | null = null
54 if (settings.router.strategy === 'off') routerReason = 'off'
55 else if (isHandoff) routerReason = 'main conversation held on the session model during the handoff'
56
57 return {
58 frontier: { isActive: settings.frontier.enabled, effort: settings.frontier.enabled ? EFFORT[settings.frontier.effort] : null },
59 qa: { isActive: settings.qa.enabled },
60 guard: { isActive: guardReason === null, reason: guardReason },
61 router: {
62 isActive: settings.router.strategy !== 'off',
63 isMainLoop: settings.router.strategy !== 'off' && settings.router.mainLoop && !isHandoff,
64 isSubagents: settings.router.strategy !== 'off' && settings.router.subagents,
65 reason: routerReason,
66 },
67 subagents: { mode: settings.subagents.mode, limit: settings.subagents.limit },
68 resources: { ceilings: ceilingsOf(settings.resources) },
69 autopilot: { isActive: settings.autopilot.enabled, isHandoff },
70 }
71}
72
73export type PolicySection = { name: string; text: string }
74
75/**
76 * The system-prompt sections in force. Each is a pure function of settings
77 * (never of live figures), so the prompt only changes when the person
78 * changes a setting: one prompt-cache miss per change, none per turn.
79 */
80export function policySections(
81 settings: Settings,
82 autopilotThreshold: number | null,
83 live: { milestonesTool?: string | null; nativeOutputStyle?: string | null } = {},
84): PolicySection[] {
85 const sections: PolicySection[] = []
86 // Only where Control Room offered its milestones tool (Claude Code has no task list of its own here).
87 if (settings.progress.milestones && live.milestonesTool !== undefined && live.milestonesTool !== null) {
88 sections.push({ name: 'Run progress', text: prompts.milestonesPolicy(live.milestonesTool) })
89 }
90 if (settings.frontier.enabled) sections.push({ name: 'Frontier Max', text: prompts.frontierPolicy(settings.frontier.effort) })
91 if (settings.qa.enabled) sections.push({ name: 'Release/QA', text: prompts.qaPolicy() })
92 const ceilings = ceilingsOf(settings.resources)
93 if (ceilings !== null) {
94 sections.push({
95 name: `Resource Governor ${ceilings.level}`,
96 text: prompts.resourcePolicy({ level: ceilings.level, cpu: ceilings.cpu, ram: ceilings.ram, maxHeavy: ceilings.maxHeavy, enforcement: ceilings.enforcement }),
97 })
98 }
99 const sub = prompts.subagentPolicy(settings.subagents.mode, settings.subagents.limit)
100 if (sub !== null) sections.push({ name: 'Subagent limits', text: sub })
101 if (settings.autopilot.enabled) {
102 // Rounded so small window measurements do not churn the prompt cache.
103 const rounded = autopilotThreshold === null ? null : Math.round(autopilotThreshold / 10_000) * 10_000
104 sections.push({ name: 'Context Autopilot', text: prompts.autopilotPolicy(rounded, settings.autopilot.handoffFile) })
105 }
106 // The person's own Claude Code output style is the more specific choice: it wins, and this stands down.
107 const style = live.nativeOutputStyle === undefined || live.nativeOutputStyle === null ? prompts.answerStylePolicy(settings.answers.style) : null
108 if (style !== null) sections.push({ name: 'Answer style', text: style })
109 return sections
110}
111
112export const POLICY_SECTION_ID = 'project-sentinel:policies'
113
114export function policyText(sections: readonly PolicySection[]): string | null {
115 if (sections.length === 0) return null
116 return ['# Project Sentinel — active session policies', 'Set by the user in Control Room, the Project Sentinel plugin’s panel.', '', ...sections.map(s => s.text)].join('\n\n')
117}
118hooks/features/permissions/categories.ts 404 lines1/**
2 * Permission categories and the classifier that maps a tool call to them.
3 *
4 * Shell commands are split into simple commands (see shell.ts) and every
5 * segment is classified; a call can fall in several categories and the
6 * strictest configured state wins. Classification is best-effort pattern
7 * matching, documented as such: it narrows what Claude may do, it is not a
8 * sandbox, and Claude Code's own permission system still applies beneath it.
9 */
10
11import type { PermissionCategory, PermissionState } from '../../core/settings'
12import { type Segment, commandName, segmentsOf } from './shell'
13
14export type CategoryInfo = {
15 id: PermissionCategory
16 label: string
17 description: string
18 examples: string
19}
20
21export const CATEGORY_INFO: Record<PermissionCategory, CategoryInfo> = {
22 install: {
23 id: 'install',
24 label: 'Package installation',
25 description: 'npm/pnpm/yarn/pip/uv/cargo/go/gem/brew/apt/winget installs, npx -y, uvx',
26 examples: 'npm install zod · pip install requests',
27 },
28 network: {
29 id: 'network',
30 label: 'Internet & network access',
31 description: 'WebFetch, WebSearch, curl/wget, ssh/scp, git fetch/pull/clone, gh',
32 examples: 'curl (a web address) · git pull',
33 },
34 download: {
35 id: 'download',
36 label: 'Downloading files',
37 description: 'curl -o / wget / Invoke-WebRequest -OutFile, git clone, release downloads',
38 examples: 'wget (a model file) · git clone',
39 },
40 edit: {
41 id: 'edit',
42 label: 'Project file changes',
43 description: 'Edit, Write and NotebookEdit inside the project',
44 examples: 'Edit src/app.ts',
45 },
46 editOutside: {
47 id: 'editOutside',
48 label: 'Changes outside the project',
49 description: 'Edit, Write and NotebookEdit on paths outside the project root',
50 examples: 'Write ~/.bashrc',
51 },
52 delete: {
53 id: 'delete',
54 label: 'File deletion',
55 description: 'rm, del, Remove-Item, rimraf, git rm, find -delete',
56 examples: 'rm -rf dist',
57 },
58 commit: {
59 id: 'commit',
60 label: 'Git commits',
61 description: 'git commit (including --amend)',
62 examples: 'git commit -m "…"',
63 },
64 push: {
65 id: 'push',
66 label: 'Git push',
67 description: 'git push to any remote (non-force)',
68 examples: 'git push origin main',
69 },
70 gitDestructive: {
71 id: 'gitDestructive',
72 label: 'Force push & destructive Git',
73 description: 'push --force/--delete, reset --hard, clean -f, branch -D, checkout/restore that discard work, stash drop, history rewrites',
74 examples: 'git push --force · git reset --hard',
75 },
76 deploy: {
77 id: 'deploy',
78 label: 'Deploy & publish',
79 description: 'npm/cargo publish, docker push, gh release, vercel/netlify/fly/firebase deploy, kubectl/helm/terraform apply, MCP publish/send/deploy tools',
80 examples: 'npm publish · terraform apply',
81 },
82 dangerous: {
83 id: 'dangerous',
84 label: 'Dangerous system commands',
85 description: 'recursive deletes of root/home/project root, disk formatting, dd to devices, shutdown, registry deletes, a download piped into a shell',
86 examples: 'rm -rf ~ · mkfs on a disk',
87 },
88}
89
90export type Finding = { category: PermissionCategory; evidence: string }
91
92const lower = (w: string | undefined): string => (w ?? '').toLowerCase()
93/** Exact flag match (git and most POSIX tools are case-sensitive: -D is not -d). */
94const has = (args: string[], ...flags: string[]): boolean => args.some(a => flags.includes(a))
95/** Case-insensitive match, for PowerShell parameters. */
96const hasI = (args: string[], ...flags: string[]): boolean => args.some(a => flags.includes(a.toLowerCase()))
97const hasShort = (args: string[], letter: string): boolean =>
98 args.some(a => /^-[a-zA-Z]+$/.test(a) && !a.startsWith('--') && a.slice(1).includes(letter))
99
100/** `git -C dir --no-pager commit` → ['commit', ...rest]. */
101function gitArgs(args: string[]): string[] {
102 let i = 0
103 while (i < args.length) {
104 const a = args[i]!
105 if (a === '-C' || a === '-c' || a === '--git-dir' || a === '--work-tree' || a === '--namespace') i += 2
106 else if (a.startsWith('-')) i += 1
107 else break
108 }
109 return args.slice(i)
110}
111
112const INSTALL_VERBS = new Set(['i', 'install', 'ci', 'add', 'update', 'up', 'upgrade', 'isntall', 'in'])
113const PKG_MANAGERS = new Set(['apt', 'apt-get', 'dnf', 'yum', 'zypper', 'apk', 'port', 'snap', 'flatpak', 'brew', 'choco', 'winget', 'scoop', 'nix-env', 'emerge'])
114
115function classifyInstall(cmd: string, args: string[]): string | null {
116 const a0 = lower(args[0])
117 if ((cmd === 'npm' || cmd === 'pnpm' || cmd === 'bun' || cmd === 'cnpm') && INSTALL_VERBS.has(a0)) return `${cmd} ${a0}`
118 if (cmd === 'yarn' && (args.length === 0 || ['install', 'add', 'upgrade', 'up', 'dlx'].includes(a0))) return `yarn ${a0}`.trim()
119 if ((cmd === 'pnpm' || cmd === 'yarn') && a0 === 'dlx') return `${cmd} dlx`
120 if (cmd === 'npx' && has(args, '-y', '--yes')) return 'npx --yes'
121 if (cmd === 'bunx' || cmd === 'uvx' || cmd === 'pipx' && a0 === 'run') return cmd
122 if ((cmd === 'pip' || cmd === 'pip3' || cmd === 'pipx') && ['install', 'download'].includes(a0)) return `${cmd} ${a0}`
123 if (/^(python3?|py)$/.test(cmd) && lower(args[0]) === '-m' && lower(args[1]) === 'pip' && lower(args[2]) === 'install') return 'python -m pip install'
124 if (cmd === 'uv' && (a0 === 'add' || a0 === 'sync' || (a0 === 'pip' && lower(args[1]) === 'install') || (a0 === 'tool' && lower(args[1]) === 'install'))) return `uv ${a0}`
125 if ((cmd === 'poetry' || cmd === 'pdm' || cmd === 'rye') && ['add', 'install', 'update', 'sync'].includes(a0)) return `${cmd} ${a0}`
126 if ((cmd === 'conda' || cmd === 'mamba' || cmd === 'micromamba') && ['install', 'create', 'update'].includes(a0)) return `${cmd} ${a0}`
127 if (cmd === 'cargo' && ['add', 'install', 'binstall'].includes(a0)) return `cargo ${a0}`
128 if (cmd === 'go' && ['get', 'install'].includes(a0)) return `go ${a0}`
129 if (cmd === 'gem' && a0 === 'install') return 'gem install'
130 if ((cmd === 'bundle' || cmd === 'bundler') && ['install', 'add', 'update'].includes(a0)) return `bundle ${a0}`
131 if (cmd === 'composer' && ['require', 'install', 'update'].includes(a0)) return `composer ${a0}`
132 if (cmd === 'deno' && ['install', 'add'].includes(a0)) return `deno ${a0}`
133 if (cmd === 'dotnet' && ((a0 === 'add' && lower(args[1]) === 'package') || (a0 === 'tool' && lower(args[1]) === 'install'))) return `dotnet ${a0}`
134 if (cmd === 'nuget' && a0 === 'install') return 'nuget install'
135 if (cmd === 'pacman' && args.some(a => /^-S/.test(a))) return 'pacman -S'
136 if (PKG_MANAGERS.has(cmd) && ['install', 'upgrade', 'reinstall', 'add', 'tap'].includes(a0)) return `${cmd} ${a0}`
137 if (['install-module', 'install-package', 'install-script', 'update-module', 'install-psresource'].includes(cmd)) return cmd
138 return null
139}
140
141const NETWORK_CMDS = new Set([
142 'curl', 'wget', 'http', 'https', 'httpie', 'xh', 'ssh', 'scp', 'sftp', 'ftp', 'rsync', 'nc', 'ncat', 'netcat', 'telnet',
143 'invoke-webrequest', 'iwr', 'invoke-restmethod', 'irm', 'start-bitstransfer', 'aria2c', 'gh', 'glab', 'hub', 'socat',
144])
145
146function classifyNetwork(cmd: string, args: string[]): string | null {
147 if (NETWORK_CMDS.has(cmd)) {
148 if (cmd === 'rsync' && !args.some(a => /^[^/\\]*:/.test(a) && !/^[A-Za-z]:[\\/]/.test(a))) return null
149 if (cmd === 'gh' && ['help', '--help', '--version', 'version'].includes(lower(args[0]))) return null
150 return cmd
151 }
152 if (cmd === 'git') {
153 const g = gitArgs(args)
154 const verb = lower(g[0])
155 if (['clone', 'fetch', 'pull', 'push', 'ls-remote'].includes(verb)) return `git ${verb}`
156 if (verb === 'remote' && lower(g[1]) === 'update') return 'git remote update'
157 if (verb === 'submodule' && ['update', 'sync'].includes(lower(g[1]))) return 'git submodule update'
158 }
159 return null
160}
161
162/** Where output is thrown away or shown, not kept as a file: the null device (POSIX, cmd, PowerShell) or stdout. */
163const DISCARD = /^(?:\/dev\/null|nul|\$null|-)$/i
164
165/**
166 * The value of an output option at `args[i]`: `-o FILE`, `-sSLo FILE`, `-oFILE`, `-qO-`, `--output FILE`,
167 * `--output=FILE`. Null when `args[i]` is no such option; undefined when the option has no value.
168 */
169function outputTarget(args: string[], i: number, short: string, long: string): string | undefined | null {
170 const a = args[i]!
171 if (a === long) return args[i + 1]
172 if (a.startsWith(`${long}=`)) return a.slice(long.length + 1)
173 if (a.startsWith('--')) return null
174 // A short option takes the rest of its word, or else the next word.
175 const m = new RegExp(`^-[A-Za-z]*?${short}(.*)$`).exec(a)
176 if (m === null) return null
177 return m[1] === '' ? args[i + 1] : m[1]
178}
179
180/** Whether a fetcher keeps what it fetches in a file: an output option or a redirect naming anything but the void. */
181function writesFile(args: string[], text: string, short: string, long: string): boolean {
182 for (let i = 0; i < args.length; i++) {
183 const target = outputTarget(args, i, short, long)
184 if (target !== null && (target === undefined || !DISCARD.test(target))) return true
185 }
186 // A redirect into a file; `2>&1` and a redirect into the null device keep nothing.
187 return [...text.matchAll(/(?:^|\s)\d?>{1,2}\s*(\S+)/g)].some(m => !DISCARD.test(m[1] ?? '') && !/^&\d$/.test(m[1] ?? ''))
188}
189
190function classifyDownload(cmd: string, args: string[], text: string): string | null {
191 // `-O` names the file after the URL; `-o /dev/null` (a status check: `curl -s -o /dev/null -w "%{http_code}" URL`) keeps nothing.
192 if (cmd === 'curl' && (has(args, '-O', '--remote-name', '--remote-name-all', '-OJ', '-LO') || hasShort(args, 'O') || writesFile(args, text, 'o', '--output'))) return 'curl download'
193 if (cmd === 'wget') {
194 // wget keeps a file named after the URL unless told otherwise: `--spider`, or `-O` into the void or stdout.
195 if (has(args, '--spider')) return null
196 const hasOutput = args.some((_, i) => outputTarget(args, i, 'O', '--output-document') !== null)
197 return hasOutput && !writesFile(args, text, 'O', '--output-document') ? null : 'wget'
198 }
199 if ((cmd === 'invoke-webrequest' || cmd === 'iwr') && hasI(args, '-outfile', '-o')) return 'Invoke-WebRequest -OutFile'
200 if (cmd === 'start-bitstransfer' || cmd === 'aria2c') return cmd
201 if (cmd === 'git' && lower(gitArgs(args)[0]) === 'clone') return 'git clone'
202 if (cmd === 'gh' && ((lower(args[0]) === 'release' && lower(args[1]) === 'download') || (lower(args[0]) === 'repo' && lower(args[1]) === 'clone'))) return `gh ${args[0]} ${args[1]}`
203 if (cmd === 'huggingface-cli' && lower(args[0]) === 'download') return 'huggingface-cli download'
204 if (cmd === 'scp' && args.length >= 2 && /:/.test(args[args.length - 2] ?? '')) return 'scp (remote → local)'
205 return null
206}
207
208const DELETE_CMDS = new Set(['rm', 'rmdir', 'unlink', 'shred', 'del', 'erase', 'rd', 'remove-item', 'ri', 'rimraf', 'trash', 'trash-put', 'srm'])
209
210function classifyDelete(cmd: string, args: string[]): string | null {
211 if (DELETE_CMDS.has(cmd)) return cmd
212 if (cmd === 'git' && lower(gitArgs(args)[0]) === 'rm') return 'git rm'
213 if (cmd === 'find' && has(args, '-delete')) return 'find -delete'
214 if (cmd === 'find' && args.some((a, i) => a === '-exec' && ['rm', 'del', 'unlink'].includes(commandName(args[i + 1] ?? '')))) return 'find -exec rm'
215 if (cmd === 'npx' && lower(args[0]) === 'rimraf') return 'rimraf'
216 if (cmd === 'cmd' && args.some(a => /^(del|rd|rmdir|erase)$/i.test(a))) return 'cmd del'
217 return null
218}
219
220function classifyGit(cmd: string, args: string[]): { category: PermissionCategory; evidence: string } | null {
221 if (cmd !== 'git') return null
222 const g = gitArgs(args)
223 const verb = lower(g[0])
224 const rest = g.slice(1)
225 if (verb === 'push') {
226 const isForce =
227 has(rest, '--force', '-f', '--force-with-lease', '--force-if-includes', '--mirror', '--delete', '-d', '--prune') ||
228 hasShort(rest, 'f') ||
229 rest.some(a => a.startsWith('+') || a.startsWith('--force-with-lease=') || /^:[^/]/.test(a))
230 return isForce ? { category: 'gitDestructive', evidence: 'git push --force/--delete' } : { category: 'push', evidence: 'git push' }
231 }
232 if (verb === 'commit') return { category: 'commit', evidence: has(rest, '--amend') ? 'git commit --amend' : 'git commit' }
233 if (verb === 'reset' && has(rest, '--hard', '--merge', '--keep')) return { category: 'gitDestructive', evidence: 'git reset --hard' }
234 if (verb === 'clean' && (has(rest, '--force') || hasShort(rest, 'f'))) return { category: 'gitDestructive', evidence: 'git clean -f' }
235 if (verb === 'branch' && (has(rest, '-D') || (has(rest, '--delete', '-d') && has(rest, '--force', '-f')))) return { category: 'gitDestructive', evidence: 'git branch -D' }
236 if (verb === 'checkout' && (has(rest, '-f', '--force') || (rest.includes('--') && rest.length > rest.indexOf('--') + 1) || rest.includes('.'))) {
237 return { category: 'gitDestructive', evidence: 'git checkout (discarding changes)' }
238 }
239 if (verb === 'restore' && !has(rest, '--staged', '-S') && rest.length > 0) return { category: 'gitDestructive', evidence: 'git restore (discarding changes)' }
240 if (verb === 'stash' && ['drop', 'clear'].includes(lower(rest[0]))) return { category: 'gitDestructive', evidence: `git stash ${lower(rest[0])}` }
241 if (['filter-branch', 'filter-repo', 'replace'].includes(verb)) return { category: 'gitDestructive', evidence: `git ${verb}` }
242 if (verb === 'reflog' && lower(rest[0]) === 'expire') return { category: 'gitDestructive', evidence: 'git reflog expire' }
243 if (verb === 'gc' && rest.some(a => a.startsWith('--prune'))) return { category: 'gitDestructive', evidence: 'git gc --prune' }
244 if (verb === 'update-ref' && has(rest, '-d')) return { category: 'gitDestructive', evidence: 'git update-ref -d' }
245 if (verb === 'tag' && has(rest, '-d', '--delete')) return { category: 'gitDestructive', evidence: 'git tag -d' }
246 if (verb === 'worktree' && lower(rest[0]) === 'remove' && has(rest, '--force', '-f')) return { category: 'gitDestructive', evidence: 'git worktree remove --force' }
247 return null
248}
249
250function classifyDeploy(cmd: string, args: string[], text: string): string | null {
251 const a0 = lower(args[0])
252 const a1 = lower(args[1])
253 if ((cmd === 'npm' || cmd === 'pnpm' || cmd === 'yarn' || cmd === 'bun') && (a0 === 'publish' || (a0 === 'run' && /^(deploy|release|publish)(:|$)/.test(a1)))) return `${cmd} ${a0}`
254 if ((cmd === 'cargo' || cmd === 'poetry' || cmd === 'flit' || cmd === 'hatch' || cmd === 'gem') && (a0 === 'publish' || a0 === 'push')) return `${cmd} ${a0}`
255 if (cmd === 'twine' && a0 === 'upload') return 'twine upload'
256 if (cmd === 'dotnet' && a0 === 'nuget' && a1 === 'push') return 'dotnet nuget push'
257 if ((cmd === 'mvn' || cmd === 'mvnw') && args.includes('deploy')) return 'mvn deploy'
258 if ((cmd === 'gradle' || cmd === 'gradlew') && args.some(a => /^publish/i.test(a))) return 'gradle publish'
259 if ((cmd === 'docker' || cmd === 'podman') && (a0 === 'push' || (a0 === 'buildx' && has(args, '--push')))) return `${cmd} push`
260 if (cmd === 'gh' && ((a0 === 'release' && ['create', 'upload', 'edit'].includes(a1)) || (a0 === 'workflow' && a1 === 'run'))) return `gh ${a0} ${a1}`
261 if (cmd === 'vercel' && (a0 === 'deploy' || has(args, '--prod') || args.length === 0 || a0 === 'promote')) return 'vercel'
262 if (cmd === 'netlify' && a0 === 'deploy') return 'netlify deploy'
263 if (cmd === 'firebase' && a0 === 'deploy') return 'firebase deploy'
264 if ((cmd === 'fly' || cmd === 'flyctl') && ['deploy', 'launch'].includes(a0)) return 'fly deploy'
265 if (cmd === 'railway' && ['up', 'deploy', 'redeploy'].includes(a0)) return 'railway up'
266 if (cmd === 'heroku' && ['container:release', 'releases:rollback', 'deploy'].includes(a0)) return `heroku ${a0}`
267 if (cmd === 'kubectl' && ['apply', 'create', 'delete', 'replace', 'patch', 'rollout', 'scale', 'set', 'drain'].includes(a0)) return `kubectl ${a0}`
268 if (cmd === 'helm' && ['install', 'upgrade', 'uninstall', 'rollback', 'delete'].includes(a0)) return `helm ${a0}`
269 if ((cmd === 'terraform' || cmd === 'tofu' || cmd === 'terragrunt') && ['apply', 'destroy', 'import'].includes(a0)) return `${cmd} ${a0}`
270 if (cmd === 'pulumi' && ['up', 'destroy', 'update'].includes(a0)) return `pulumi ${a0}`
271 if (cmd === 'cdk' && ['deploy', 'destroy'].includes(a0)) return `cdk ${a0}`
272 if ((cmd === 'serverless' || cmd === 'sls') && ['deploy', 'remove'].includes(a0)) return `${cmd} ${a0}`
273 if (cmd === 'wrangler' && ['deploy', 'publish', 'pages'].includes(a0)) return `wrangler ${a0}`
274 if (cmd === 'supabase' && ((a0 === 'db' && a1 === 'push') || (a0 === 'functions' && a1 === 'deploy'))) return `supabase ${a0} ${a1}`
275 if (cmd === 'aws' && /\b(deploy|update-function-code|put-object|s3 (cp|sync|mv|rm)|create-stack|update-stack|delete-stack)\b/i.test(text)) return 'aws deploy/upload'
276 if (cmd === 'gcloud' && /\b(deploy|app deploy|run deploy|functions deploy|builds submit)\b/i.test(text)) return 'gcloud deploy'
277 if (cmd === 'az' && /\b(webapp|functionapp|deployment|containerapp)\b.*\b(deploy|create|up|update)\b/i.test(text)) return 'az deploy'
278 if (cmd === 'ansible-playbook') return 'ansible-playbook'
279 if ((cmd === 'eas' && ['submit', 'update'].includes(a0)) || (cmd === 'expo' && a0 === 'publish')) return `${cmd} ${a0}`
280 if (cmd === 'fastlane') return 'fastlane'
281 return null
282}
283
284const ROOTISH = /^(\/|\/\*|~|~\/|~\/\*|\$home|\$\{home\}|\$home\/\*|\.|\.\/|\*|\.\/\*|[a-z]:\\?|[a-z]:\/?|[a-z]:\\\*|%userprofile%|\$env:userprofile|\$env:homedrive\\?|\/home|\/users|\/etc|\/usr|\/var|\/system|\/library|c:\\windows|c:\\users)$/i
285
286function classifyDangerous(seg: Segment, prev: Segment | undefined): string | null {
287 const [first, ...args] = seg.words
288 const cmd = commandName(first ?? '')
289 const lowered = args.map(a => a.toLowerCase())
290 const isRecursive = lowered.some(a => /^-[a-z]*r[a-z]*$/i.test(a) || a === '--recursive' || a === '-recurse' || a === '/s')
291 const targets = args.filter(a => !a.startsWith('-') && !(a.startsWith('/') && a.length <= 3 && /^\/[a-z]$/i.test(a)))
292 if ((cmd === 'rm' || cmd === 'remove-item' || cmd === 'ri' || cmd === 'rd' || cmd === 'rmdir' || cmd === 'del' || cmd === 'erase') && isRecursive) {
293 const hit = targets.find(t => ROOTISH.test(t.replace(/["']/g, '').replace(/[\\/]+$/, (m: string) => (m.length > 0 ? m[0]! : ''))))
294 if (hit !== undefined) return `recursive delete of ${hit}`
295 }
296 if (cmd === 'rm' && lowered.includes('--no-preserve-root')) return 'rm --no-preserve-root'
297 if (/^mkfs(\.|$)/.test(cmd) || ['fdisk', 'parted', 'diskpart', 'wipefs', 'format-volume', 'clear-disk', 'initialize-disk', 'sgdisk'].includes(cmd)) return cmd
298 if (cmd === 'format' && args.some(a => /^[a-z]:$/i.test(a))) return 'format drive'
299 if (cmd === 'dd' && args.some(a => /^of=\/dev\//i.test(a))) return 'dd to a device'
300 if (['shutdown', 'reboot', 'halt', 'poweroff', 'stop-computer', 'restart-computer'].includes(cmd)) return cmd
301 if (cmd === 'systemctl' && ['poweroff', 'reboot', 'halt', 'kexec'].includes(lowered[0] ?? '')) return `systemctl ${lowered[0]}`
302 if (cmd === 'init' && ['0', '6'].includes(lowered[0] ?? '')) return `init ${lowered[0]}`
303 if ((cmd === 'chmod' || cmd === 'chown') && isRecursive && targets.some(t => ROOTISH.test(t) && t !== '.' && t !== './')) return `${cmd} -R on a system path`
304 if (cmd === 'reg' && lowered[0] === 'delete') return 'reg delete'
305 if (['remove-itemproperty'].includes(cmd) && args.some(a => /^hk(lm|cu|cr|u|cc):?/i.test(a))) return 'registry delete'
306 if (cmd === 'bcdedit' || (cmd === 'vssadmin' && lowered[0] === 'delete') || (cmd === 'cipher' && lowered.some(a => a.startsWith('/w')))) return cmd
307 if (cmd === 'takeown' && lowered.includes('/r')) return 'takeown /r'
308 if (seg.text.includes(':(){') || /:\(\)\s*\{\s*:\|:&\s*\};:/.test(seg.text)) return 'fork bomb'
309 // Remote code piped straight into an interpreter.
310 if (seg.op === '|' && prev !== undefined) {
311 const from = commandName(prev.words[0] ?? '')
312 const isFetch = ['curl', 'wget', 'iwr', 'invoke-webrequest', 'irm', 'invoke-restmethod'].includes(from)
313 const isRunner = ['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'python', 'python3', 'py', 'perl', 'ruby', 'node', 'iex', 'invoke-expression', 'pwsh', 'powershell'].includes(cmd)
314 if (isFetch && isRunner) return `${from} | ${cmd} (remote code execution)`
315 }
316 if ((cmd === 'iex' || cmd === 'invoke-expression') && /\b(iwr|irm|invoke-webrequest|invoke-restmethod|downloadstring|net\.webclient)\b/i.test(seg.text)) {
317 return 'Invoke-Expression of downloaded code'
318 }
319 return null
320}
321
322/** Every permission category a shell command touches, with evidence. */
323export function classifyShell(command: string): Finding[] {
324 const findings: Finding[] = []
325 const add = (category: PermissionCategory, evidence: string | null) => {
326 if (evidence === null) return
327 if (!findings.some(f => f.category === category && f.evidence === evidence)) findings.push({ category, evidence })
328 }
329 if (/-encodedcommand|\s-enc\s|\s-ec\s/i.test(command)) add('dangerous', 'PowerShell -EncodedCommand (unreadable script)')
330 const segments = segmentsOf(command)
331 segments.forEach((seg, i) => {
332 const [first, ...args] = seg.words
333 if (first === undefined) return
334 const cmd = commandName(first)
335 add('dangerous', classifyDangerous(seg, segments[i - 1]))
336 add('install', classifyInstall(cmd, args))
337 add('download', classifyDownload(cmd, args, seg.text))
338 add('network', classifyNetwork(cmd, args))
339 add('delete', classifyDelete(cmd, args))
340 const git = classifyGit(cmd, args)
341 if (git !== null) add(git.category, git.evidence)
342 add('deploy', classifyDeploy(cmd, args, seg.text))
343 })
344 return findings
345}
346
347const MCP_DEPLOY = /(^|_)(deploy|publish|release|send|post|create_message|send_message|tweet|email|share|upload|apply|merge|transfer|pay|purchase)(_|$)/i
348const MCP_DELETE = /(^|_)(delete|remove|trash|destroy|drop|purge|wipe|archive)(_|$)/i
349
350/** Findings for an MCP tool from its name alone (`mcp__server__tool`). */
351export function classifyMcp(tool: string): Finding[] {
352 const name = tool.split('__').slice(2).join('__')
353 const out: Finding[] = []
354 if (MCP_DELETE.test(name)) out.push({ category: 'delete', evidence: `MCP ${name}` })
355 if (MCP_DEPLOY.test(name)) out.push({ category: 'deploy', evidence: `MCP ${name}` })
356 return out
357}
358
359/**
360 * The last-resort check a failed hook falls back on: only the unmistakable
361 * catastrophes, matched on the raw text, so a crash in the full classifier
362 * never lets one through. A download piped into a shell is found by parts
363 * (a fetcher before a pipe, a shell after it), as the full classifier does.
364 */
365const OBVIOUS =
366 /\brm\s+(-[a-z]*\s+)*-[a-z]*r[a-z]*\s+(-[a-z]*\s+)*(\/|\/\*|~|~\/|\$HOME)(\s|$)|\bmkfs(\.\w+)?\b|\bdd\b[^|;&]*\bof=\/dev\/|\bformat\s+[a-z]:|\bRemove-Item\b[^|;&]*-Recurse[^|;&]*\s([A-Za-z]:\\?|~|\$env:USERPROFILE)(\s|$)/i
367
368const FETCHERS = ['curl', 'wget']
369const SHELLS = ['sh', 'bash', 'zsh', 'dash']
370
371/** A download piped straight into a shell, found by its parts: a fetcher left of a pipe, a shell right after it. */
372function isPipedIntoShell(command: string): boolean {
373 const parts = command.split('|')
374 for (let i = 1; i < parts.length; i++) {
375 const left = (parts[i - 1] ?? '').toLowerCase()
376 const right = (parts[i] ?? '').trim().toLowerCase().split(/\s+/)
377 const runner = (right[0] === 'sudo' ? right[1] : right[0])?.replace(/^.*[\\/]/, '')
378 const words = left.split(/[^a-z0-9_.-]+/)
379 if (FETCHERS.some(f => words.includes(f)) && runner !== undefined && SHELLS.includes(runner)) return true
380 }
381 return false
382}
383
384export const isObviouslyDangerous = (command: string): boolean => OBVIOUS.test(command) || isPipedIntoShell(command)
385
386/** Strictness order: deny > ask > default. */
387const RANK: Record<PermissionState, number> = { deny: 2, ask: 1, default: 0 }
388
389export type Decision = {
390 state: PermissionState
391 category: PermissionCategory | null
392 evidence: string | null
393}
394
395/** The strictest configured state among the findings. */
396export function strictest(findings: readonly Finding[], states: Record<PermissionCategory, PermissionState>): Decision {
397 let best: Decision = { state: 'default', category: null, evidence: null }
398 for (const f of findings) {
399 const state = states[f.category]
400 if (RANK[state] > RANK[best.state]) best = { state, category: f.category, evidence: f.evidence }
401 }
402 return best
403}
404hooks/features/permissions/decide.ts 115 lines1/**
2 * Permission decisions for one tool call, all taken in `tool.call`, before
3 * the call goes on to Claude Code's own permission check:
4 * - Deny refuses the call outright (before any dialog, in every permission
5 * mode, main loop and subagents alike);
6 * - Ask puts the call to the person first, unless Claude Code is about to
7 * ask them itself or refuses it anyway; their yes passes the call on, so
8 * Claude Code's rules and PreToolUse hooks still apply after it;
9 * - Default leaves it to Claude Code.
10 * Control Room never answers a permission check itself, so it can never
11 * loosen one: a settings deny stays a deny, plan mode stays plan mode.
12 */
13
14import type { PermissionCategory, PermissionState } from '../../core/settings'
15import { CATEGORY_INFO, type Decision, type Finding, classifyMcp, classifyShell, strictest } from './categories'
16
17export const SHELL_TOOLS = ['Bash', 'PowerShell'] as const
18export const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'MultiEdit'] as const
19export const NETWORK_TOOLS = ['WebFetch', 'WebSearch'] as const
20
21export const isShellTool = (tool: string): boolean => (SHELL_TOOLS as readonly string[]).includes(tool)
22export const isEditTool = (tool: string): boolean => (EDIT_TOOLS as readonly string[]).includes(tool)
23
24/** The path an editing tool writes, when it names one. */
25export function editPathOf(input: Record<string, unknown>): string | null {
26 const p = input.file_path ?? input.notebook_path ?? input.path
27 return typeof p === 'string' && p.trim() !== '' ? p : null
28}
29
30/**
31 * Findings for a tool call. `isInsideProject` answers for edit tools (the
32 * runtime resolves real paths; null when the path could not be placed, which
33 * counts as outside — the conservative reading).
34 */
35export function findingsFor(tool: string, input: Record<string, unknown>, isInsideProject: boolean | null): Finding[] {
36 if (isShellTool(tool)) {
37 const command = typeof input.command === 'string' ? input.command : ''
38 return classifyShell(command)
39 }
40 if (isEditTool(tool)) {
41 const path = editPathOf(input) ?? '?'
42 return [{ category: isInsideProject === true ? 'edit' : 'editOutside', evidence: `${tool} ${path}` }]
43 }
44 if ((NETWORK_TOOLS as readonly string[]).includes(tool)) {
45 const target = typeof input.url === 'string' ? input.url : typeof input.query === 'string' ? `search: ${input.query}` : tool
46 return [{ category: 'network', evidence: `${tool} ${target}` }]
47 }
48 if (tool.startsWith('mcp__')) return classifyMcp(tool)
49 return []
50}
51
52export function decisionFor(findings: readonly Finding[], states: Record<PermissionCategory, PermissionState>): Decision {
53 return strictest(findings, states)
54}
55
56export function denyMessage(decision: Decision): string {
57 const label = decision.category === null ? 'this action' : CATEGORY_INFO[decision.category].label
58 return `Control Room Permission Policy: "${label}" is set to Deny in this session (${decision.evidence ?? 'matched'}). Do not retry it or work around it; continue without it, or tell the user it is needed.`
59}
60
61export type EngineVerdict = { decision: 'allow' | 'ask' | 'deny'; reason?: string; rule?: string; hook?: string }
62
63/**
64 * Whether Control Room asks the person about an Ask category itself, given
65 * what Claude Code would decide (its `tool.check` verdict, from a query that
66 * runs nothing). Claude Code refusing it: nothing to ask. Claude Code putting
67 * it to the person's own dialog: that dialog is the question. Otherwise (it
68 * would run without asking, a mode that answers for the person, an auto-mode
69 * classifier, or no verdict at all) Control Room asks.
70 */
71export function isOwnQuestionNeeded(engine: EngineVerdict | null, permissionMode: string | undefined): boolean {
72 if (engine === null) return true
73 if (engine.decision === 'deny') return false
74 if (engine.decision === 'allow') return true
75 // An engine ask goes to the person's dialog, except in auto mode, where a classifier settles it.
76 return permissionMode === 'auto'
77}
78
79/** The question put to the person for an Ask category, and the answers it offers. */
80export const APPROVE = 'Run it'
81export const DECLINE = "Don't run it"
82
83/** What a call does, in a few words for the question: the command, the file, the address. */
84export function callWords(tool: string, input: Record<string, unknown>, decision: Decision): string {
85 const command = isShellTool(tool) && typeof input.command === 'string' ? input.command.trim().replace(/\s+/g, ' ') : null
86 const text = command ?? (isEditTool(tool) ? `${tool} ${editPathOf(input) ?? ''}`.trim() : (decision.evidence ?? tool))
87 return text.length > 140 ? `${text.slice(0, 139)}…` : text
88}
89
90export function approvalQuestion(decision: Decision, what: string): string {
91 const label = decision.category === null ? 'This action' : CATEGORY_INFO[decision.category].label
92 return `${label} is set to Ask in Control Room. Let Claude run this? ${what}`
93}
94
95/** What Claude reads when the person declined (or could not be asked). */
96export function declineMessage(decision: Decision, answer: string | null): string {
97 const label = decision.category === null ? 'this action' : CATEGORY_INFO[decision.category].label
98 const said = answer !== null && answer.trim() !== '' && answer !== DECLINE ? ` The user said: "${answer.trim().slice(0, 300)}".` : ''
99 if (answer === null) {
100 return `Control Room Permission Policy: "${label}" is set to Ask and was not approved: the question was dismissed, or nobody could be asked in this session (${decision.evidence ?? 'matched'}). Do not retry it or work around it; continue without it, or tell the user it is needed.`
101 }
102 return `Control Room Permission Policy: the user declined "${label}" (${decision.evidence ?? 'matched'}).${said} Do not retry it or work around it; continue without it, or ask the user.`
103}
104
105/** The states the UI offers for a category, loosest first. */
106export function statesFor(_category: PermissionCategory): PermissionState[] {
107 return ['default', 'ask', 'deny']
108}
109
110/** A state picked or read for a category: a removed Allow is Default; anything unknown is Ask, the safe reading. */
111export function clampState(category: PermissionCategory, state: string): PermissionState {
112 if (state === 'allow') return 'default'
113 return (statesFor(category) as string[]).includes(state) ? (state as PermissionState) : 'ask'
114}
115hooks/app/actions.ts 233 lines1/**
2 * The Control Centre's actions: what every Button, Select and Input calls.
3 * Each goes through the Runtime, which validates, persists, reconfigures
4 * the live systems and republishes the views.
5 */
6
7import { deleteCustomProfile, saveCustomProfile } from '../core/profiles'
8import { defaultSettings } from '../core/settings'
9import { parseDuration } from '../features/when'
10import type { Actions } from '../ui/kit'
11import type { Runtime } from './runtime'
12
13export function actionsOf(rt: Runtime): Actions {
14 return {
15 openPane: () => void rt.openPane(true),
16 closePane: () => void rt.closePane(),
17 togglePane: () => void rt.togglePane(),
18 setTab: tab => rt.setTab(tab),
19 togglePicker: key => rt.togglePicker(key),
20 setActivitySub: sub => {
21 rt.ui.activitySub = sub
22 rt.publisher.mark('pane')
23 },
24 toggleChanges: () => {
25 rt.ui.showAllChanges = !rt.ui.showAllChanges
26 rt.publisher.mark('pane')
27 },
28 toggleGenerated: () => {
29 rt.ui.showGenerated = !rt.ui.showGenerated
30 rt.publisher.mark('activity')
31 },
32 scrollToTop: () => void rt.host?.scrollPaneToTop().catch(() => undefined),
33 update: change => rt.update(draft => void change(draft)),
34 applyProfile: id => void rt.applyProfileById(id),
35 saveProfile: name => {
36 const clean = name.trim()
37 if (clean === '') return
38 rt.update(s => saveCustomProfile(s, clean, Date.now()))
39 rt.host?.toast(`Profile “${clean}” saved`, 3000)
40 },
41 deleteProfile: id => rt.update(s => deleteCustomProfile(s, id)),
42 handoff: () => void rt.requestHandoff().then(text => rt.sayToPerson(text)),
43 fresh: () => void rt.startFreshContext().then(text => rt.sayToPerson(text)),
44 snooze: () => rt.snoozeAutopilot(),
45 handoffNow: () => void rt.handoffNow().then(text => rt.sayToPerson(text)),
46 stopAndHandoff: taskId => void rt.stopAndHandOff(taskId).then(text => rt.sayToPerson(text)),
47 keepWaiting: () => void rt.keepWaiting().then(text => rt.sayToPerson(text)),
48 cancelHandoff: () => rt.sayToPerson(rt.cancelHandoff()),
49 stopTask: taskId => void rt.stopBackgroundTask(taskId),
50 selectFile: path => rt.selectChangedFile(path),
51 clearChanges: () => {
52 rt.activity.clearChanges()
53 rt.ui.selectedPath = null
54 rt.publisher.mark('activity', 'pane')
55 },
56 toggleRow: id => rt.toggleRow(id),
57 toggleFocus: () =>
58 rt.update(s => {
59 s.focus.enabled = !s.focus.enabled
60 }),
61 resetSettings: () => {
62 rt.update(s => ({ ...defaultSettings(), customProfiles: s.customProfiles }))
63 rt.host?.toast('Settings reset to Normal (Autopilot at 70%, Frontier Max at Extra High)', 3000)
64 },
65 copy: (text, surface) => void rt.host?.copy(text, surface),
66 dismissOnboarding: () => rt.dismissOnboarding(),
67
68 openOps: section => void rt.openOps(undefined, section),
69 openAgents: () => void rt.openActivity('agents'),
70 acceptEvidence: key => void rt.acceptEvidence(key).then(text => rt.sayToPerson(text)),
71 toggleOpsRow: id => {
72 const open = rt.ui.ops.expanded
73 rt.ui.ops.expanded = open.includes(id) ? open.filter(x => x !== id) : [...open, id].slice(-12)
74 rt.publisher.mark('ops')
75 },
76 queueAdd: text => {
77 const done = rt.ops.queueAdd(text, rt.ui.ops.queueTarget)
78 rt.ui.ops.error = done.ok ? null : done.error
79 if (done.ok && rt.settings.ui.toasts) rt.host?.toast(`Queued ${done.id} · ${done.when}`, 3000)
80 rt.publisher.mark('ops')
81 },
82 setQueueTarget: target => {
83 rt.ui.ops.queueTarget = target
84 rt.ui.openPicker = null
85 rt.publisher.mark('ops', 'pane')
86 },
87 editQueue: id => {
88 rt.ui.ops.editing = id
89 rt.publisher.mark('ops')
90 },
91 queueEdit: (id, text) => {
92 rt.ops.queueEdit(id, { text })
93 rt.ui.ops.editing = null
94 rt.publisher.mark('ops')
95 },
96 queueRetarget: (id, target) => {
97 rt.ops.queueEdit(id, { target })
98 rt.ui.openPicker = null
99 rt.publisher.mark('ops', 'pane')
100 },
101 queueMove: (id, delta) => rt.ops.queueMove(id, delta),
102 queueNow: id => rt.ops.queueNow(id),
103 queueCancel: id => rt.ops.queueCancel(id),
104
105 answer: (id, text) => {
106 const done = rt.ops.answer(id, text)
107 rt.ui.ops.error = done.ok ? null : done.error
108 rt.publisher.mark('ops')
109 },
110 answerNow: id => rt.ops.answerNow(id),
111 withdraw: id => rt.ops.withdraw(id),
112
113 watchLabel: label => {
114 // A draft: kept as typed, never redrawn per key.
115 rt.ui.ops.watchLabel = label
116 },
117 setWatchStrategy: strategy => {
118 rt.ui.ops.watchStrategy = strategy
119 rt.ui.openPicker = null
120 rt.publisher.mark('ops', 'pane')
121 },
122 watchAdd: when => {
123 const done = rt.ops.armFrom(rt.ui.ops.watchLabel, when, rt.ui.ops.watchStrategy)
124 rt.ui.ops.choices = done.ok ? null : (done.options ?? null)
125 rt.ui.ops.error = done.ok ? null : done.error
126 if (done.ok) {
127 rt.ui.ops.watchLabel = ''
128 if (rt.settings.ui.toasts) rt.host?.toast(`Watcher ${done.words}`, 5000)
129 }
130 rt.publisher.mark('ops')
131 },
132 watchPick: at => {
133 const done = rt.ops.armAt(rt.ui.ops.watchLabel, { kind: 'at', at, words: '' }, rt.ui.ops.watchStrategy)
134 rt.ui.ops.choices = null
135 rt.ui.ops.error = done.ok ? null : done.error
136 if (done.ok) {
137 rt.ui.ops.watchLabel = ''
138 if (rt.settings.ui.toasts) rt.host?.toast(`Watcher ${done.words}`, 5000)
139 }
140 rt.publisher.mark('ops')
141 },
142 watchWake: id => void rt.ops.wakeNow(id),
143 watchCheck: id => void rt.ops.checkNow(id),
144 watchFresh: id => void rt.ops.wakeFreshNow(id),
145 watchSnooze: (id, minutes) => void rt.ops.rescheduleAt(id, rt.clock() + minutes * 60_000),
146 watchReschedule: (id, when) => {
147 const done = rt.ops.reschedule(id, when)
148 rt.ui.ops.error = done.ok ? null : done.error
149 if (done.ok) rt.ui.ops.rescheduling = null
150 rt.publisher.mark('ops')
151 },
152 editWatch: id => {
153 rt.ui.ops.rescheduling = id
154 rt.publisher.mark('ops')
155 },
156 watchPause: id => rt.ops.pause(id),
157 watchResume: id => rt.ops.resume(id),
158 watchDismiss: id => rt.ops.dismiss(id),
159 watchStrategy: (id, strategy) => {
160 rt.ops.setStrategy(id, strategy)
161 rt.ui.openPicker = null
162 rt.publisher.mark('ops', 'pane')
163 },
164 watchNotes: id => void rt.ops.prepareNotes(id),
165 suggestTake: () => {
166 const done = rt.ops.takeSuggestion()
167 if (done.ok && rt.settings.ui.toasts) rt.host?.toast(`Watcher ${done.words}`, 5000)
168 if (!done.ok) void rt.openOps()
169 },
170 suggestAt: when => {
171 const done = rt.ops.takeSuggestion(when)
172 rt.ui.ops.error = done.ok ? null : done.error
173 rt.ui.ops.choices = done.ok ? null : (done.options ?? null)
174 if (done.ok && rt.settings.ui.toasts) rt.host?.toast(`Watcher ${done.words}`, 5000)
175 rt.publisher.mark('ops')
176 },
177 suggestIgnore: () => rt.ops.ignoreSuggestion(),
178
179 heldPutBack: () => rt.ops.heldPutBack(),
180 heldSend: () => rt.ops.heldSend(),
181 heldDiscard: () => rt.ops.heldDiscard(),
182
183 budgetOpen: isOpen => {
184 rt.ui.ops.budgetOpen = isOpen
185 rt.publisher.mark('ops')
186 },
187 budgetCost: text => {
188 const t = text.trim()
189 const n = Number(t.replace(/[$,\s]/g, ''))
190 if (t === '' || t === '0') rt.ops.setBudget({ costUsd: null })
191 else if (Number.isFinite(n) && n > 0) rt.ops.setBudget({ costUsd: Math.round(n * 100) / 100 })
192 else rt.ui.ops.error = `"${t}" is not an amount: try 30 or 12.50.`
193 rt.publisher.mark('ops')
194 },
195 budgetTime: text => {
196 const t = text.trim()
197 const ms = t === '' || t === '0' ? null : parseDuration(t)
198 if (ms === null && t !== '' && t !== '0') rt.ui.ops.error = `"${t}" is not a duration: try 6h or 90m.`
199 else rt.ops.setBudget({ durationMs: ms })
200 rt.publisher.mark('ops')
201 },
202 budgetHandoffs: delta => {
203 const now = rt.ops.current().budget?.handoffs ?? 0
204 const next = Math.max(0, Math.min(50, now + delta))
205 rt.ops.setBudget({ handoffs: next === 0 ? null : next })
206 },
207 budgetAtLimit: atLimit => {
208 rt.ops.setBudget({ atLimit })
209 rt.ui.openPicker = null
210 rt.publisher.mark('ops', 'pane')
211 },
212 budgetClear: () => {
213 rt.ops.setBudget(null)
214 rt.ui.ops.budgetOpen = false
215 rt.publisher.mark('ops')
216 },
217 budgetApprove: () => rt.ops.approveBudget(),
218
219 foreignBring: () => void rt.ops.bringForeign(),
220 foreignDismiss: () => rt.ops.dismissForeign(),
221 agentStop: id => void rt.ops.stopAgent(id),
222 editMessage: id => {
223 rt.ui.ops.messaging = id
224 rt.publisher.mark('ops')
225 },
226 agentMessage: (id, text) => {
227 void rt.ops.messageAgent(id, text)
228 rt.ui.ops.messaging = null
229 rt.publisher.mark('ops')
230 },
231 }
232}
233hooks/app/runtime.ts 3467 lines1/**
2 * The Runtime: Control Room's composition root. It owns the live model,
3 * receives every engine event from `register.tsx`, asks the policy engine
4 * what is in force, runs the feature logic, performs effects through the
5 * Host, and marks view models dirty for the publisher.
6 *
7 * Module memory survives /clear (the module is not reloaded), so the run,
8 * the settings and the autopilot carry straight into the fresh context.
9 */
10
11import type {
12 AgentInfo,
13 AgentOfferInput,
14 AgentSpawnInput,
15 AgentSpawnResult,
16 CommandRunResult,
17 PromptOrigin,
18 RenderSurface,
19 SessionCompactResult,
20 SessionEndInput,
21 SessionStartInput,
22 SessionUsage,
23 ToolCallResult,
24 TurnStepInput,
25 TurnStepResult,
26} from 'claude-code'
27
28import type { PermissionLogEntry, QueueTarget, TabId, WatchStrategy } from '../../types'
29import { COMMAND, LIMITS, MIN_ENGINE, PANE_ID, PANE_TITLE, SHORT_COMMAND, STORE_ENTRIES } from '../constants'
30import { type Effective, POLICY_SECTION_ID, effective, policySections, policyText } from '../core/policy'
31import { applyProfile, findProfile, profileLabel } from '../core/profiles'
32import { DEFAULTS_GENERATION, PERMISSION_LABEL, type Settings, classicSettings, clone, defaultSettings, normalizeSettings } from '../core/settings'
33import { fingerprint } from '../core/hash'
34import { clean } from '../core/text'
35import { versionAtLeast } from '../core/version'
36import { ActivityTracker } from '../features/activity'
37import * as Autopilot from '../features/autopilot'
38import * as Background from '../features/background'
39import { type HandoffIntent, INTENT_WORDS, handoffIntentOf } from '../features/intent'
40import * as Supervisor from '../features/supervisor'
41import * as Chain from '../features/chain'
42import * as Evidence from '../features/evidence'
43import { type GitState, parseStatus } from '../features/git'
44import * as Handoff from '../features/handoff'
45import * as Ops from '../features/ops'
46import * as Plan from '../features/plan'
47import * as PlanUsage from '../features/planUsage'
48import * as Quest from '../features/quest'
49import { VALIDATION_LABEL, summarize } from '../features/validation'
50import { groupOf } from '../features/digest'
51import { answerStyleLabel } from '../core/answers'
52import { type GuardAssessment, assessExit, isRepeat } from '../features/guard'
53import * as Guard from '../features/guard'
54import { APPROVE, DECLINE, approvalQuestion, callWords, declineMessage, decisionFor, denyMessage, editPathOf, findingsFor, isEditTool, isOwnQuestionNeeded, isShellTool } from '../features/permissions/decide'
55import type { Decision } from '../features/permissions/categories'
56import * as prompts from '../features/prompts'
57import { type Pressure, gateHeavy, isOver } from '../features/resources/pressure'
58import { heavyKinds, heavyLabel } from '../features/resources/heavy'
59import { type Family, classifyTask, familyOf, routeMain, routeSubagent } from '../features/router'
60import { decideSpawn, isOffered } from '../features/subagents'
61import { aliveRows, limitCount } from '../features/agents'
62import { doingOf } from '../features/digest'
63import type { Host } from '../host'
64import { ResourceMonitor } from './monitor'
65import { Debounced, type Onboarding, findRunBySession, hasPriorUse, loadHistory, loadIndex, loadSettings, nextRunNumber, onboardingOf, saveRun } from './persist'
66import { CARRIED_MARK, type CarriedMarker, carriedWrites, configDirFromEnv, configDirOf, isFormerHud, readFormerStore } from './formerStore'
67import { CacheGuardian } from './cacheGuardian'
68import { handleCommand } from './commands'
69import { type FreshPurpose, Operations, isTurnOnlyCompact } from './operations'
70import { headlineOf } from './headline'
71import { PolicyLedger, policyRenderOf, recordLine } from './ledger'
72import { type NoteKind, NoteBox } from './notes'
73import { Publisher } from './publisher'
74import * as CacheModel from '../features/cache'
75import { endsWithQuestion } from './headline'
76import type { HudHeadline } from '../../types'
77import * as fmt from '../core/format'
78import { cardKey } from '../ui/primitives'
79
80type TurnKind = Autopilot.TurnKind
81
82type TurnState = {
83 id: string | null
84 isRunning: boolean
85 kind: TurnKind
86 request: string
87 toolCount: number
88 editCount: number
89}
90
91export type UsageFigures = { tokens: number | undefined; window: number | undefined; pct: number | undefined; costUsd: number | undefined }
92
93const OWN_PLUGIN = 'project-sentinel'
94
95/** How long after the load an announcement waits, so the session's window is there to show it. */
96const ANNOUNCE_DELAY_MS = 1500
97/** The evidence of a run that keeps none yet. */
98const EMPTY_EVIDENCE: Evidence.EvidenceStore = Evidence.emptyEvidence()
99
100/** While a cost limit is set, the session's cost is read mid-turn at most this often (`$.session.usage()` costs nothing). */
101const BUDGET_COST_EVERY_MS = 5_000
102/** The notes count as written for a handoff when modified no earlier than this before it began (clocks, file systems). */
103const NOTES_SLACK_MS = 5000
104/** Operations opened at a card or field: the first try this long after, each later one this much later again. */
105const OPS_DRAWN_MS = 300
106const OPS_SCROLL_TRIES = 3
107/** How often the Handoff Supervisor looks at a handoff under way (observation; it moves no step but a wait's end). */
108const SUPERVISE_MS = 30_000
109/** At most this many unrecognised background tasks go to the isolated classifier per look. */
110const CLASSIFY_PER_LOOK = 2
111/** While any agent is alive, Claude Code's agent list is looked at this often (a workflow's agents are never in it). */
112const AGENT_POLL_MS = 2_000
113
114/** The person's choices about a message typed while a handoff is under way, as Claude Code's question dialog offers them. */
115export const HANDOFF_CHOICE = {
116 carry: 'Hand off now, then give it to the fresh context',
117 freshCarry: 'Start fresh now, then give it to the fresh context',
118 queue: 'Give it to the fresh context',
119 here: 'Send it here (this context)',
120 box: 'Back to the prompt box',
121 stop: 'Stop it and hand off',
122 leave: 'Hand off, leave it running',
123 wait: 'Keep waiting',
124} as const
125
126const isOwnPrompt = (origin: PromptOrigin | undefined): boolean => origin?.kind === 'plugin' && origin.name === OWN_PLUGIN
127
128const isPersonOrigin = (origin: PromptOrigin | undefined): boolean =>
129 origin === undefined || origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk'
130
131/** `$.session.compact`'s refusal where a session compacts only inside a turn (headless and SDK sessions: the /compact command does it). */
132
133export class Runtime {
134 host: Host | null = null
135 settings: Settings = defaultSettings()
136 isLoaded = false
137 loading: Promise<void> | null = null
138
139 root = ''
140 cwd = ''
141 sessionId: string | null = null
142 engineVersion: string | null = null
143 surfaces: RenderSurface[] = []
144 permissionMode: string | undefined
145 sessionModel = ''
146 usage: UsageFigures = { tokens: undefined, window: undefined, pct: undefined, costUsd: undefined }
147 autoCompactAt: number | undefined
148 /** The plan's rate-limit windows as Claude Code last reported them (features/planUsage.ts): the status bar's 5H reading. */
149 planUsage: PlanUsage.PlanUsage = PlanUsage.emptyPlanUsage()
150 /** While a five-hour window counts down to its reset: one look a minute (usage is read, never pushed, between responses). */
151 private usageTicker: { cancel: () => void } | null = null
152
153 autopilot: Autopilot.Autopilot = Autopilot.initialAutopilot()
154 run: Chain.Run | null = null
155 history: Chain.Run[] = []
156 activity = new ActivityTracker()
157 monitor: ResourceMonitor
158
159 turn: TurnState = { id: null, isRunning: false, kind: 'other', request: '', toolCount: 0, editCount: 0 }
160 guard = { turnBlocks: 0, sessionBlocks: 0, lastBlockedAnswer: '', last: null as (GuardAssessment & { at: number }) | null }
161 router = {
162 turnModel: null as string | null,
163 lastDecision: null as string | null,
164 /** Model ids the engine reported answering this session, by family: the only ids a request is routed to. */
165 known: new Map<Family, string>(),
166 /** Families whose routed model the engine refused this session. */
167 unavailable: new Set<string>(),
168 /** True when the router sent the last turn to another model than the session's. */
169 wasRouted: false,
170 }
171 agents = {
172 list: [] as AgentInfo[],
173 spawned: 0,
174 denied: 0,
175 asked: 0,
176 allowAll: false,
177 poll: null as { cancel: () => void } | null,
178 /** Spawns allowed whose start is not settled yet: they hold a slot, so spawns made at once never all pass a limit. */
179 reserved: 0,
180 }
181 permissionLog: PermissionLogEntry[] = []
182 permissionCounts = { denied: 0, asked: 0 }
183 resourceStats = { refused: 0, noticesSent: 0, lastNoticeLevel: 'ok' as string }
184 frontier = { lastEffort: null as string | null, isEffortSupported: null as boolean | null }
185 compose = { isReached: false, deliveredFallback: false, isLikelyBypassed: false }
186 /** Notes for Claude, waiting for the next batch of tool results or the next prompt (app/notes.ts). */
187 readonly notesBox = new NoteBox()
188 /** Request by request: how the policies and the effort reached Claude in this context (app/ledger.ts). */
189 readonly ledger = new PolicyLedger()
190 /** What Claude was last told about the machine's load: nothing (ok), or that it is high or critical. */
191 pressureTold: 'ok' | 'high' | 'critical' = 'ok'
192 private pressureToldAt = 0
193 /** Whether the latest request carried Frontier Max (the panel redraws when that changes). */
194 private lastFrontierDelivered: boolean | null = null
195
196 ui = {
197 tab: 'overview' as TabId,
198 activitySub: 'summary' as 'summary' | 'ops' | 'agents' | 'raw',
199 showGenerated: false,
200 selectedPath: null as string | null,
201 expanded: new Set<string>(),
202 isPaneOpen: false,
203 openPicker: null as string | null,
204 showAllChanges: false,
205 /** Activity → Operations: the forms' choices and drafts, and what is open. */
206 ops: {
207 queueTarget: 'boundary' as QueueTarget,
208 editing: null as string | null,
209 watchLabel: '',
210 watchStrategy: 'smart' as WatchStrategy,
211 choices: null as { at: number; label: string }[] | null,
212 error: null as string | null,
213 expanded: [] as string[],
214 budgetOpen: false,
215 rescheduling: null as string | null,
216 messaging: null as string | null,
217 },
218 }
219 notes: string[] = []
220 /** Categories whose saved Allow read as Default at this load (Guardrails says so for the session). */
221 allowRemoved: string[] = []
222 savedAt: number | null = null
223
224 /** Quest log: lifetime XP and achievements (kept while the Quest log style is chosen). */
225 quest: Quest.QuestState = Quest.emptyQuest()
226 /** Checks of each kind that already paid XP this turn, so re-running a passing check earns nothing. */
227 private questGreen = new Set<string>()
228 /** The person's own Claude Code output style, as the last system prompt reported it; it outranks the answer style. */
229 nativeOutputStyle: string | null = null
230
231 /** Milestones finished when the turn began, so the turn's summary can count its own. */
232 turnStartDone = 0
233
234 /** The handoff turn, for Handoff Health: the first activity turn it counts from, and whether the milestones were sent again in it. */
235 private handoffTurn = { fromTurn: -1, isPlanUpdated: false }
236 /** Files the main conversation read in this context: the continuity check after a handoff looks for the notes and the docs. */
237 private reads: string[] = []
238
239 /** The prompt cache: telemetry, the miss doctor, Keep warm and stable policies. */
240 readonly cache: CacheGuardian
241 /** The orchestration layer: the Mission Queue, the Decision Inbox, Watchers, the Run Budget, the Cold Resume Guard, agents. */
242 readonly ops: Operations
243 /** The project's Git state, for the terminal (Desktop shows Git itself); null outside a repository or before the first look. */
244 git: GitState | null = null
245 /** The repository root, once looked up (null: not a repository). */
246 private gitRoot: string | null | undefined = undefined
247 private gitCheckedAt = 0
248 private isGitRefreshing = false
249
250 /**
251 * What the last main turn left behind as it stopped (its Stop event): background jobs still
252 * running, wake-ups scheduled, and whether its last words asked the person something. Between
253 * turns this tells "waiting for a result" from "waiting for you"; a new turn clears it.
254 */
255 lastStop: {
256 /** Each task with its class (features/background.ts): a server never counts as work that will bring the turn back. */
257 background: Autopilot.BackgroundTask[]
258 wakeups: { schedule: string; recurring: boolean }[]
259 isQuestion: boolean
260 at: number
261 } | null = null
262
263 /** What the isolated classifier said a background task is, by its id: it outranks the rules for that task. */
264 private taskVerdicts = new Map<string, Background.TaskVerdict>()
265 /** Tasks the isolated classifier was asked about, once each. */
266 private classifyAsked = new Set<string>()
267
268 /** The Handoff Supervisor's latest look at a handoff under way (features/supervisor.ts); null while none is. */
269 supervisor: Supervisor.Diagnosis | null = null
270 private supervisorTimer: { cancel: () => void } | null = null
271 /** The last diagnosis traced, so the debug log has a line only when it changes. */
272 private supervisorKey = ''
273 /** When the main conversation last showed any activity (a prompt, a turn's start or end, a step, a call). */
274 private quietSince: number | null = null
275
276 /** The background work in flight Claude Code last listed, with each task's class; null before any Stop. */
277 inFlight(): Autopilot.BackgroundTask[] | null {
278 return this.lastStop === null ? null : this.lastStop.background
279 }
280
281 /** The work in flight that will bring a turn back (a test run, a render, an agent): never a server or a watcher. */
282 blockingInFlight(): Autopilot.BackgroundTask[] {
283 return Autopilot.blockingOf(this.lastStop?.background ?? [])
284 }
285
286 /** One task as Claude Code lists it, with its class: what the classifier said of it, else the rules. */
287 private taskOf(b: { id: string; type: string; description: string; command?: string; agent_type?: string; name?: string }): Autopilot.BackgroundTask {
288 const v = this.taskVerdicts.get(b.id) ?? Background.classifyTask({ id: b.id, type: b.type, description: b.description, ...(b.command === undefined ? {} : { command: b.command }), ...(b.agent_type === undefined ? {} : { agentType: b.agent_type }), ...(b.name === undefined ? {} : { name: b.name }) })
289 return { id: b.id, description: clean(b.description || b.command || `a ${b.type} task`, 80), class: v.class, why: v.why, by: v.by, ...(b.command === undefined ? {} : { command: clean(b.command, 200) }) }
290 }
291
292 /** When this context began (system clock): the companion wakes up with it. */
293 contextStartedAt: number | null = null
294 /** Why the companion's surface module failed to draw, if it did: it is left out until the plugin reloads. */
295 companionFault: string | null = null
296 /** While the companion is on, its mood moves on with time (sleepy, asleep): one republish a minute. */
297 private companionTicker: { cancel: () => void } | null = null
298 /** What the last settings change was, in words, for a policy-caused cache miss. */
299 private policyReason: string | null = null
300
301 /** The clear a handoff owes, held back while a turn runs (a prompt the person queued): the turn's end carries it out. */
302 private isClearOwed = false
303 /**
304 * What the next turn to start begins with, as `prompt.submit` named it: a background task's
305 * notification (origin `task-notification`, no running turn), so that turn is known for what it is
306 * whatever its text; null for any other prompt. `turn.start` follows the submit that starts it.
307 */
308 private nextTurn: 'notification' | null = null
309 private startSource: { source: string; sessionId: string } | null = null
310 /** True from Control Room's own /clear until the fresh session it makes is seen (or the wait ends). */
311 private isOwnClear = false
312 /** Resolves when classic.SessionStart{clear} reports the fresh session of Control Room's own /clear. */
313 private onOwnClearSeen: (() => void) | null = null
314 private commandsRegistered = new Set<string>()
315
316 readonly publisher: Publisher
317 private readonly saveSettings: Debounced<Settings>
318 private readonly saveRunLater: Debounced<Chain.Run>
319 private readonly saveQuest: Debounced<Quest.QuestState>
320
321 constructor() {
322 this.publisher = new Publisher(() => this.host, this)
323 this.saveSettings = new Debounced<Settings>(() => this.host, (host, value) => host.storeSet(STORE_ENTRIES.settings, value), LIMITS.persistDebounceMs)
324 this.saveRunLater = new Debounced<Chain.Run>(() => this.host, (host, run) => saveRun(host, run), LIMITS.persistDebounceMs)
325 this.saveQuest = new Debounced<Quest.QuestState>(() => this.host, (host, value) => host.storeSet(STORE_ENTRIES.quest, value), LIMITS.persistDebounceMs)
326 this.monitor = new ResourceMonitor(
327 (pressure, previous) => this.onPressure(pressure, previous),
328 () => this.publisher.mark('resources', 'hud', 'pane'),
329 )
330 this.cache = new CacheGuardian({
331 host: () => this.host,
332 now: () => this.clock(),
333 settings: () => this.settings,
334 isTurnRunning: () => this.turn.isRunning,
335 contextTokens: () => this.usage.tokens ?? 0,
336 standDown: () => this.keepWarmStandDown(),
337 changed: () => this.onCacheChanged(),
338 missed: miss => this.onCacheMiss(miss),
339 verdict: (verdict, reason) => this.onKeepWarmVerdict(verdict, reason),
340 hold: () => this.ops.hold(),
341 })
342 this.ops = new Operations(this)
343 }
344
345 // -------------------------------------------------------------------------
346 // The prompt cache
347
348 /** Milliseconds since the epoch: the system clock, or a test's manual one. Every cache figure is timed by it. */
349 clock(): number {
350 return this.host?.time?.() ?? Date.now()
351 }
352
353 /**
354 * Keep warm stands down only once the old context is about to be thrown away: the notes checked
355 * before a clear, the clear itself, the fresh context resuming. Every earlier step still reads the
356 * old context again: the turn that crosses the threshold, the handoff turn, each background
357 * notification's turn while a written handoff waits, the person's own message while it waits for
358 * them. A stuck or long handoff must never be what lets a large warm cache lapse (seen in a real
359 * run: a handoff held by a preview server stood Keep warm down for two hours and a 775k cache went
360 * cold). A handoff that compacts keeps the cache throughout (compaction re-reads it).
361 */
362 keepWarmStandDown(): string | null {
363 const a = this.settings.autopilot
364 if (!a.enabled) return null
365 const state = this.autopilot.state
366 if (state === 'clearing' || state === 'resuming') return 'A handoff is starting a fresh context, so this cache is about to be discarded'
367 if (state === 'verifying' && a.continuation === 'clear') return 'The handoff notes are being checked before the clear, so this cache is about to be discarded'
368 return null
369 }
370
371 /** The last request the cache memo holds (`$.state`), so a write goes only when it moved. */
372 private memoAt: number | null = null
373
374 /** The cache changed: redraw, and keep its last request where a reload of the plugin finds it. */
375 private onCacheChanged(): void {
376 this.publisher.mark('hud', 'pane', 'ops')
377 const at = this.cache.state.lastRequestAt
378 const host = this.host
379 if (host === null || at === this.memoAt) return
380 this.memoAt = at
381 void host.saveCacheMemo(this.cache.memoOf(this.sessionId)).catch(() => {
382 this.memoAt = null
383 })
384 }
385
386 private onCacheMiss(miss: CacheModel.CacheMiss): void {
387 this.publisher.mark('hud', 'pane')
388 const host = this.host
389 if (host === null || !this.settings.ui.toasts || miss.kind === 'lifecycle' || miss.severity !== 'warn') return
390 host.toast(`Cache rebuilt: ${fmt.tokens(miss.recached)} tokens · ${CacheModel.CAUSE_LABEL[miss.cause]}`, 6000)
391 }
392
393 private onKeepWarmVerdict(verdict: 'yes' | 'no', reason: string | null): void {
394 const host = this.host
395 this.trace(`keep warm: verdict ${verdict === 'yes' ? 'VERIFIED' : 'FAILED, paused'}${reason === null ? '' : ` (${reason})`}`)
396 if (verdict === 'no') this.note(`Keep warm paused itself: ${reason ?? 'it did not keep the prompt cache warm here'}. Turn it on again to retry; Context → Cache has each refresh.`)
397 if (host !== null && this.settings.ui.toasts) {
398 const paused = reason !== null && reason.startsWith('Rebuilt') ? 'the conversation rebuilt its cache though a refresh had kept it warm (cause unknown)' : 'refreshes found the cache gone before its expiry'
399 host.toast(verdict === 'yes' ? 'Keep warm verified: the conversation read its cache past the old expiry' : `Keep warm paused: ${paused}`, 7000)
400 }
401 }
402
403 /**
404 * Before a model switch the person makes: with a large warm cache, Claude
405 * Code is asked to confirm, with what the switch re-sends uncached.
406 */
407 onPreModelSwitch(e: { from_model: string; to_model: string; source: string; context_tokens: number; prompt_cache_warm: boolean; cache_ttl: '5m' | '1h'; estimated_cache_write_usd: number; pricing?: string }): string | null {
408 if (e.cache_ttl === '5m' || e.cache_ttl === '1h') this.cache.noteTtl(e.cache_ttl)
409 // Claude Code's own price for re-caching on the model switched to: the Cold Resume Guard's figure.
410 this.cache.noteWriteRate({ model: e.to_model, ttl: e.cache_ttl === '5m' || e.cache_ttl === '1h' ? e.cache_ttl : null, usd: e.estimated_cache_write_usd, tokens: e.context_tokens, pricing: e.pricing })
411 const s = this.settings.cache
412 if (!s.guardModelSwitch || !e.prompt_cache_warm || e.from_model === e.to_model || e.context_tokens < LIMITS.guardSwitchTokens) return null
413 if (e.source !== 'command' && e.source !== 'picker') return null
414 // Claude Code shows the reason on one line, cut at the terminal's width: the figure first, the model in its own button.
415 const cost = Number.isFinite(e.estimated_cache_write_usd) && e.estimated_cache_write_usd > 0 ? ` (about ${fmt.cost(e.estimated_cache_write_usd)})` : ''
416 return `Control Room: this re-sends ${fmt.tokens(e.context_tokens)} cached tokens uncached${cost}. A fresh context avoids it.`
417 }
418
419 onPostModelSwitch(e: { from_model: string; to_model: string; cache_ttl: '5m' | '1h'; source: string; context_tokens?: number; estimated_cache_write_usd?: number; pricing?: string }): void {
420 const by = e.source === 'auto' || e.source === 'resume' ? 'engine' : 'person'
421 if (e.estimated_cache_write_usd !== undefined && e.context_tokens !== undefined) {
422 this.cache.noteWriteRate({ model: e.to_model, ttl: e.cache_ttl === '5m' || e.cache_ttl === '1h' ? e.cache_ttl : null, usd: e.estimated_cache_write_usd, tokens: e.context_tokens, pricing: e.pricing })
423 }
424 if (e.cache_ttl === '5m' || e.cache_ttl === '1h') this.cache.noteModelSwitch({ from: e.from_model, to: e.to_model, ttl: e.cache_ttl, now: this.clock(), by })
425 if (e.to_model !== '') this.sessionModel = e.to_model
426 }
427
428 // -------------------------------------------------------------------------
429 // Binding and loading
430
431 bind(host: Host): void {
432 if (this.host === null) this.host = host
433 }
434
435 /** Loads settings once (any hook may be the first to need them). */
436 async ensureLoaded(): Promise<void> {
437 if (this.isLoaded) return
438 if (this.loading === null) {
439 this.loading = (async () => {
440 const host = this.host
441 if (host === null) return
442 await this.carryAtLoad()
443 const loaded = await loadSettings(host)
444 this.settings = loaded.settings
445 await this.settleDefaults(loaded.isFresh)
446 this.quest = Quest.questOf(await host.storeGet(STORE_ENTRIES.quest).catch(() => undefined))
447 if (loaded.wasRepaired) this.note('Some saved Control Room settings were invalid and were reset to safe defaults.')
448 if (loaded.allowRemoved.length > 0) {
449 const names = loaded.allowRemoved.join(', ')
450 this.allowRemoved = loaded.allowRemoved
451 this.announce(`Allow was removed: ${names} now use${loaded.allowRemoved.length === 1 ? 's' : ''} Default, so Claude Code's own rules decide.`)
452 // Saved at once, so the note shows in one session rather than every one.
453 this.saveSettings.schedule(this.settings)
454 }
455 this.isLoaded = true
456 })().finally(() => {
457 this.loading = null
458 })
459 }
460 await this.loading
461 }
462
463 /** A fresh install's first-run card: shown until the person dismisses it; null for an existing install. */
464 onboarding: Onboarding | null = null
465
466 /**
467 * Fresh-install defaults (1.7.0) without touching an existing install. No saved settings and no sign of
468 * earlier use: the new defaults (Normal) are saved and the first-run card shows. No saved settings but
469 * earlier use (it never changed a setting): Classic is saved, so nothing changes for it. Saved settings
470 * from before 1.7.0: a Normal is relabelled Classic (the values are untouched, so it does not read
471 * "edited" against the new Normal).
472 */
473 private async settleDefaults(isFresh: boolean): Promise<void> {
474 const host = this.host
475 if (host === null) return
476 if (isFresh) {
477 const isUsed = await hasPriorUse(host)
478 this.settings = isUsed ? classicSettings() : defaultSettings()
479 await host.storeSet(STORE_ENTRIES.settings, this.settings).catch(() => undefined)
480 // Written by this load, not by the person: the former store's carry at the session's start may replace them.
481 this.isFreshDefaults = !isUsed
482 if (!isUsed) {
483 this.onboarding = { at: Date.now(), dismissedAt: null }
484 await host.storeSet(STORE_ENTRIES.onboarding, this.onboarding).catch(() => undefined)
485 // Once, for whoever never opens the panel: what is on, and where it lives.
486 this.announce('Project Sentinel is on: Autopilot protects the context at 70%, Frontier Max at Extra High. /cr opens Control Room.')
487 }
488 this.trace(`settings: ${isUsed ? 'none saved, earlier use found: Classic kept' : 'a fresh install: the 1.7 defaults (Autopilot at 70%, Frontier Max at Extra High)'}`)
489 return
490 }
491 this.onboarding = onboardingOf(await host.storeGet(STORE_ENTRIES.onboarding).catch(() => undefined))
492 if (this.settings.defaults >= DEFAULTS_GENERATION) return
493 const wasNormal = this.settings.profile === 'normal'
494 this.settings = { ...this.settings, defaults: DEFAULTS_GENERATION, profile: wasNormal ? 'classic' : this.settings.profile }
495 await host.storeSet(STORE_ENTRIES.settings, this.settings).catch(() => undefined)
496 this.trace(`settings: saved before 1.7${wasNormal ? ': Normal is now called Classic (values unchanged)' : ''}`)
497 }
498
499 /** The settings in the store are this load's fresh-install defaults, not yet the person's (see carryFormerStore). */
500 private isFreshDefaults = false
501
502 /** The person dismissed the first-run card ("Got it"). */
503 dismissOnboarding(): void {
504 const host = this.host
505 if (this.onboarding === null || this.onboarding.dismissedAt !== null) return
506 this.onboarding = { ...this.onboarding, dismissedAt: Date.now() }
507 void host?.storeSet(STORE_ENTRIES.onboarding, this.onboarding).catch(() => undefined)
508 this.publisher.mark('pane')
509 }
510
511 /** Something that needs a look (settings repaired, an old Claude Code): a line at the top of the panel. */
512 note(text: string): void {
513 if (!this.notes.includes(text)) this.notes = [...this.notes, text].slice(-6)
514 this.publisher.mark('pane')
515 }
516
517 private announced = new Set<string>()
518
519 /**
520 * Something worth saying once that is not a problem (the rename, a removed setting): a toast a
521 * moment after the load, never a warning pinned at the top of the panel.
522 */
523 announce(text: string): void {
524 const host = this.host
525 if (host === null || this.announced.has(text)) return
526 this.announced.add(text)
527 host.after(ANNOUNCE_DELAY_MS, () => {
528 if (this.settings.ui.toasts) host.toast(text, 8000)
529 })
530 }
531
532 effective(): Effective {
533 return effective(this.settings, {
534 autopilot: this.autopilot.state,
535 autopilotThreshold: Autopilot.handoffPoint(this.autopilot),
536 pressure: this.monitor.pressure.level,
537 permissionMode: this.permissionMode,
538 })
539 }
540
541 // -------------------------------------------------------------------------
542 // Session lifecycle
543
544 async onSessionStart(e: SessionStartInput): Promise<void> {
545 const host = this.host
546 if (host === null) return
547 await this.ensureLoaded()
548 this.cwd = e.cwd
549 const [root, id, version, surfaces, model, panes] = await Promise.all([
550 host.sessionRoot().catch(() => e.cwd),
551 host.sessionId().catch(() => null),
552 host.version().catch(() => null),
553 host.surfaces().catch(() => [] as readonly RenderSurface[]),
554 host.sessionModel().catch(() => ''),
555 host.panes().catch(() => []),
556 ])
557 this.root = root
558 this.sessionId = id
559 this.contextStartedAt = Date.now()
560 // The supervisor's quiet counts from the load: a turn that began before it shows itself by its calls.
561 this.noteActivity()
562 this.engineVersion = version?.version ?? null
563 this.surfaces = [...surfaces]
564 this.sessionModel = model
565 // A hot reload starts a fresh Runtime under a panel that is still open.
566 this.ui.isPaneOpen = panes.some(p => p.id === PANE_ID)
567 if (this.engineVersion !== null && !versionAtLeast(this.engineVersion, MIN_ENGINE)) {
568 this.note(`Claude Code ${this.engineVersion} is older than ${MIN_ENGINE}, the version Control Room was verified on; some features may not work.`)
569 }
570
571 await this.registerCommands()
572 await this.offerMilestones()
573 await this.offerDecisions()
574 // Managed settings seat Anthropic's sec-default guard, which continues prompt.compose past
575 // user-installed mods; policies then ride the prompt as context instead.
576 const policy = await host.settings('policy').catch(() => ({}))
577 this.compose.isLikelyBypassed = Object.keys(policy).length > 0
578 await this.attachRun()
579 await this.cache.load()
580 await this.restorePolicyMemo()
581 const memo = await host.loadCacheMemo().catch(() => null)
582 if (memo !== null) this.cache.restoreMemo(memo, this.sessionId)
583 await this.refreshUsage(true)
584 this.reconfigure({ isStartup: true })
585 await this.recoverAutopilot()
586 await this.ops.onLoad()
587 // Agents a runtime before this one saw start (a reload) are read from Claude Code again.
588 void this.refreshAgents()
589 void this.refreshHistory()
590 void this.refreshGit()
591 // A resumed or reloaded run: its verified milestones against the workspace as it is now (Ready to resume).
592 void this.checkEvidence('session start')
593 this.publisher.markAll()
594
595 if (this.settings.ui.openOnStart) void host.open({ id: PANE_ID, title: PANE_TITLE }).catch(() => undefined)
596 }
597
598 /**
599 * After a reload of the plugin in the same context: the section the system prompt already carries
600 * is what the runtime before this one sent, so a held section stays held (and the cache stays warm).
601 */
602 private async restorePolicyMemo(): Promise<void> {
603 const host = this.host
604 if (host === null || this.sessionId === null) return
605 const memo = await host.loadPolicyMemo().catch(() => null)
606 if (memo === null || memo.sessionId !== this.sessionId) return
607 this.cache.restoreDelivered(memo.text)
608 this.memoText = memo.text
609 // The turn under way (if any) keeps sending that system prompt: its requests carry the section.
610 this.ledger.carried(policyRenderOf(memo.text === '' ? null : memo.text, { at: this.clock(), isHeld: false, isLoaded: true }))
611 this.trace(`policy: the system prompt carries section ${memo.text === '' ? 'none' : fingerprint(memo.text)} from before the reload`)
612 }
613
614 /** The milestones tool's full name once offered to Claude; null while it is not. */
615 milestonesTool: string | null = null
616 /** Where run progress comes from in this session. */
617 planSource: 'tasks' | 'milestones' | 'none' = 'none'
618
619 /**
620 * Run progress counts Claude's task list. Claude Code builds that offer
621 * none (its task tools are off by default in 2.1.29x) get Control Room's
622 * milestones tool instead, with a short policy, unless the person turned
623 * it off. Where a task list exists it is used, and nothing is added.
624 */
625 async offerMilestones(): Promise<void> {
626 const host = this.host
627 if (host === null) return
628 const tools = await host.listTools().catch(() => [] as readonly { name: string }[])
629 if (tools.some(t => Plan.isPlanTool(t.name))) {
630 this.planSource = 'tasks'
631 this.publisher.mark('pane')
632 return
633 }
634 if (!this.settings.progress.milestones) {
635 this.planSource = this.milestonesTool === null ? 'none' : 'milestones'
636 this.publisher.mark('pane')
637 return
638 }
639 if (this.milestonesTool === null) {
640 this.milestonesTool = await host
641 .registerTool({ name: prompts.MILESTONES_TOOL.name, description: prompts.MILESTONES_TOOL.description, inputSchema: prompts.MILESTONES_TOOL.inputSchema as unknown as Record<string, unknown> })
642 .catch(() => null)
643 }
644 this.planSource = this.milestonesTool === null ? 'none' : 'milestones'
645 this.publisher.mark('pane')
646 }
647
648 /** The decision tool's full name once offered to Claude; null while it is not. */
649 decisionsTool: string | null = null
650
651 /**
652 * The Decision Inbox: Claude is offered `decision_request` (Behavior → Decisions, on by default),
653 * so a non-urgent choice that is the person's waits in Needs review. Offered once per session; turned
654 * off mid-session, the tool stays until the session ends and answers that the inbox is off.
655 */
656 async offerDecisions(): Promise<void> {
657 const host = this.host
658 if (host === null || !this.settings.ops.decisions || this.decisionsTool !== null) return
659 this.decisionsTool = await host
660 .registerTool({ name: prompts.DECISION_TOOL.name, description: prompts.DECISION_TOOL.description, inputSchema: prompts.DECISION_TOOL.inputSchema as unknown as Record<string, unknown> })
661 .catch(() => null)
662 this.publisher.mark('pane', 'ops')
663 }
664
665 /** The milestones tool's answer: the run plan updated from the whole list. A subagent's list is its own. */
666 recordMilestones(input: Record<string, unknown>, agentId: string | undefined): string {
667 if (!this.settings.progress.milestones) return 'Milestone tracking is off in Control Room; there is no need to call this tool.'
668 // A subagent's milestones are its own plan (Activity → Agents), never the run's.
669 if (agentId !== undefined) return this.ops.noteAgentPlan(agentId, { milestones: input, now: this.clock() }) ? 'Recorded as your own plan.' : 'Recorded.'
670 if (this.run === null) return 'Recorded.'
671 const session = Chain.currentSession(this.run)?.index ?? 1
672 // Claude's own statement of the objective, when it gives one, says it better than the request's first sentence.
673 const objective = typeof input.objective === 'string' ? clean(input.objective, 140) : ''
674 // Another objective of Claude's with none of the plan's milestones is new work: it counts from nothing.
675 const isOtherObjective = objective.length >= 8 && this.run.objectiveBy === 'claude' && this.run.objective !== null && objective !== this.run.objective
676 const base = Plan.isNewWork(this.plan(), input, isOtherObjective) ? Plan.emptyPlan() : this.plan()
677 if (objective.length >= 8 && (objective !== this.run.objective || this.run.objectiveBy !== 'claude')) {
678 this.run = { ...this.run, objective, objectiveBy: 'claude' }
679 this.persistRun()
680 this.publisher.mark('activity', 'hud')
681 }
682 // Outputs Claude names (generated files, renders) join a milestone's evidence before it may complete in this same list.
683 if (Array.isArray(input.milestones)) {
684 let store = this.evidence()
685 for (const m of input.milestones) {
686 if (typeof m !== 'object' || m === null) continue
687 const item = m as Record<string, unknown>
688 if (!Array.isArray(item.outputs) || typeof item.title !== 'string') continue
689 const outputs = item.outputs.filter((o): o is string => typeof o === 'string' && o.trim() !== '').map(o => this.absPath(o.trim()))
690 store = Evidence.noteOutputs(store, Plan.keyOf(clean(item.title, 120)), outputs, Date.now())
691 }
692 this.setEvidence(store)
693 }
694 this.setPlan(Plan.fromMilestones(base, input, session, Date.now()))
695 this.noteWorkStarted()
696 const p = this.progress()
697 return `Recorded: ${p.done} of ${p.total} milestones done${p.current === null ? '' : `, now: ${p.current.subject}`}.`
698 }
699
700 /** A new run plan from Claude's task list: kept with the run, and in the Quest log, newly finished milestones pay. */
701 private setPlan(plan: Plan.Plan): void {
702 if (this.run === null || plan === this.plan()) return
703 const previous = this.plan()
704 const before = Plan.progressOf(previous)
705 const wasDone = new Set(previous.tasks.filter(t => t.status === 'completed').map(t => t.key))
706 this.run = { ...this.run, plan }
707 this.planEvidence(previous, plan)
708 // A milestone done mid-turn is a boundary for work queued after it.
709 this.ops.onPlanChanged(previous, plan)
710 const after = this.progress()
711 // Part of the handoff: its own turn, a retry, or a turn while it waits for background work (the result recorded).
712 if (this.turn.isRunning && (this.turn.kind === 'handoff' || this.turn.kind === 'retry' || this.autopilot.state === 'waiting-background')) this.handoffTurn.isPlanUpdated = true
713 for (const t of plan.tasks) {
714 if (t.status === 'completed' && !wasDone.has(t.key)) this.questEvent('milestone', `Milestone: ${t.subject}`, t.key)
715 }
716 if (after.total >= 3 && after.done === after.total && before.done < before.total) {
717 this.questEvent('fullClear', `Full clear: ${after.done} of ${after.total} milestones`)
718 }
719 this.persistRun()
720 this.publisher.mark('activity', 'hud', 'pane')
721 }
722
723 /**
724 * Milestone evidence follows the plan: a milestone under way starts its work's window, one no longer
725 * completed (reopened) loses its snapshot, one newly completed gets one.
726 */
727 private planEvidence(previous: Plan.Plan, plan: Plan.Plan): void {
728 const now = Date.now()
729 let store = this.evidence()
730 for (const t of plan.tasks) if (Plan.isActive(t.status)) store = Evidence.noteActive(store, t.key, now)
731 const done = new Set(plan.tasks.filter(t => t.status === 'completed').map(t => t.key))
732 const reopened = previous.tasks.filter(t => t.status === 'completed' && !done.has(t.key) && plan.tasks.some(n => n.key === t.key)).map(t => t.key)
733 store = Evidence.dropSnapshots(store, reopened, now)
734 this.setEvidence(store)
735 const wasDone = new Set(previous.tasks.filter(t => t.status === 'completed').map(t => t.key))
736 for (const t of plan.tasks) if (t.status === 'completed' && !wasDone.has(t.key)) void this.snapshotMilestone(t)
737 }
738
739 /**
740 * Quest log: one verified outcome. Only while the person chose the Quest log
741 * style; a milestone pays once per run (by `paidKey`).
742 */
743 private questEvent(kind: Quest.QuestEventKind, text: string, paidKey?: string): void {
744 if (this.settings.answers.style !== 'quest' || this.run === null) return
745 const share = Quest.runQuestOf(this.run.quest)
746 if (paidKey !== undefined && share.paid.includes(paidKey)) return
747 const result = Quest.award(this.quest, { kind, text }, Date.now())
748 this.quest = result.state
749 this.run = { ...this.run, quest: { xp: share.xp + result.xp, paid: paidKey === undefined ? share.paid : [...share.paid, paidKey].slice(-200) } }
750 const shape = Quest.unlockForRun(this.quest, { sessions: this.run.sessions.length, milestonesDone: Quest.runQuestOf(this.run.quest).paid.length }, Date.now())
751 this.quest = shape.state
752 this.saveQuest.schedule(this.quest)
753 this.persistRun()
754 this.publisher.mark('hud', 'activity')
755 if (!this.settings.ui.toasts) return
756 for (const id of [...result.unlocked, ...shape.unlocked]) this.host?.toast(`Achievement: ${Quest.achievementName(id)}`, 5000)
757 if (result.levelUp !== null) this.host?.toast(`Level ${result.levelUp}`, 4000)
758 }
759
760 /** Quest log: achievements a run earns by its shape (a third context), checked when it rolls over. */
761 private questForRunShape(now: number): void {
762 if (this.settings.answers.style !== 'quest' || this.run === null) return
763 const shape = Quest.unlockForRun(this.quest, { sessions: this.run.sessions.length, milestonesDone: Quest.runQuestOf(this.run.quest).paid.length }, now)
764 if (shape.unlocked.length === 0) return
765 this.quest = shape.state
766 this.saveQuest.schedule(this.quest)
767 this.publisher.mark('hud', 'activity')
768 if (this.settings.ui.toasts) for (const id of shape.unlocked) this.host?.toast(`Achievement: ${Quest.achievementName(id)}`, 5000)
769 }
770
771 /** The policy sections in force, with what this session offers. */
772 policies(): ReturnType<typeof policySections> {
773 return policySections(this.settings, this.autopilot.threshold, { milestonesTool: this.milestonesTool, nativeOutputStyle: this.nativeOutputStyle })
774 }
775
776 private async registerCommands(): Promise<void> {
777 const host = this.host
778 if (host === null) return
779 const existing = new Set((await host.listCommands().catch(() => [])).map(c => c.name))
780 try {
781 await host.registerCommand({
782 name: COMMAND,
783 description: 'Open Control Room: context, behavior, guardrails, activity and setup',
784 argumentHint: '[status|queue <text>|watch in 2h <what>|watchers|decisions|decide <id>|budget|resume|evidence|profile <name>|autopilot on|off|<70%|700k>|handoff [now|wait|stop|cancel]|fresh [now]|cache|agents [mode]|help]',
785 })
786 this.commandsRegistered.add(COMMAND)
787 } catch (error) {
788 this.note(`Could not register /${COMMAND}: ${error instanceof Error ? error.message : String(error)}`)
789 }
790 if (!existing.has(SHORT_COMMAND) || this.commandsRegistered.has(SHORT_COMMAND)) {
791 try {
792 await host.registerCommand({ name: SHORT_COMMAND, description: 'Control Room (short for /control-room)', argumentHint: '[sub-command]' })
793 this.commandsRegistered.add(SHORT_COMMAND)
794 } catch {
795 // The alias is a convenience only.
796 }
797 }
798 }
799
800 isOwnCommand(command: string): boolean {
801 return command === COMMAND || (command === SHORT_COMMAND && this.commandsRegistered.has(SHORT_COMMAND))
802 }
803
804 /** Continues the run this session belongs to (reload, resume) or starts one. */
805 private async attachRun(): Promise<void> {
806 const host = this.host
807 if (host === null || this.sessionId === null) return
808 if (this.run !== null && this.run.sessions.some(s => s.id === this.sessionId)) return
809 const now = await host.now()
810 const existing = await findRunBySession(host, this.sessionId)
811 if (existing !== null) {
812 this.run = { ...existing, status: 'active', plan: Plan.planOf(existing.plan), lastHandoff: Handoff.handoffRecordOf(existing.lastHandoff), ops: Ops.opsOf(existing.ops), ...(existing.evidence === undefined ? {} : { evidence: Evidence.evidenceOf(existing.evidence) }) }
813 // Milestones done before this runtime attached are no turn's doing.
814 this.turnStartDone = this.progress().done
815 return
816 }
817 const resumedFrom = this.startSource?.source === 'resume' ? this.startSource.sessionId : null
818 const previous = resumedFrom === null ? null : await findRunBySession(host, resumedFrom)
819 if (previous !== null) {
820 this.run = Chain.rollOver({ ...previous, plan: Plan.planOf(previous.plan), ops: Ops.opsOf(previous.ops), ...(previous.evidence === undefined ? {} : { evidence: Evidence.evidenceOf(previous.evidence) }) }, { end: 'resume', endNote: 'resumed', nextId: this.sessionId, nextStart: 'resume', now })
821 } else {
822 this.run = Chain.newRun({
823 id: Chain.runIdOf(now, Math.random()),
824 number: await nextRunNumber(host),
825 sessionId: this.sessionId,
826 root: this.root,
827 profile: this.settings.profile,
828 start: this.startSource?.source === 'resume' ? 'resume' : 'startup',
829 now,
830 })
831 }
832 this.turnStartDone = this.progress().done
833 this.persistRun()
834 }
835
836 /**
837 * The branch and the uncommitted files, read with one `git status` at the
838 * start and after each turn (at most every 15 s), only where a terminal
839 * draws: Desktop shows Git natively beside the session.
840 */
841 async refreshGit(): Promise<void> {
842 const host = this.host
843 if (host === null || !this.surfaces.includes('terminal') || this.isGitRefreshing) return
844 const now = Date.now()
845 if (now - this.gitCheckedAt < 15_000) return
846 this.isGitRefreshing = true
847 this.gitCheckedAt = now
848 try {
849 if (this.gitRoot === undefined) this.gitRoot = await host.repoRoot().catch(() => null)
850 if (this.gitRoot === null) return
851 const result = await host.gitStatus()
852 const next = result.exitCode === 0 ? parseStatus(result.stdout) : null
853 if (JSON.stringify(next) !== JSON.stringify(this.git)) {
854 this.git = next
855 this.publisher.mark('hud')
856 }
857 } catch {
858 // Git missing or slow: the line is left out.
859 } finally {
860 this.isGitRefreshing = false
861 }
862 }
863
864 async refreshHistory(): Promise<void> {
865 const host = this.host
866 if (host === null) return
867 this.history = await loadHistory(host, this.run?.id ?? null).catch(() => [])
868 this.ops.findForeign()
869 this.publisher.mark('chain')
870 }
871
872 /** Writes a run other than this session's (an ended run whose operations were brought here). */
873 async saveOtherRun(run: Chain.Run): Promise<void> {
874 const host = this.host
875 if (host !== null && run.id !== this.run?.id) await saveRun(host, run).catch(() => undefined)
876 }
877
878 persistRun(isNow = false): void {
879 const run = this.run
880 if (run === null) return
881 if (isNow) {
882 // A write of the run now supersedes a later one waiting with an older copy of it (which would
883 // otherwise put that copy back: a watcher armed just after a turn, lost at the next restart).
884 this.saveRunLater.drop(pending => pending.id === run.id)
885 const host = this.host
886 if (host !== null) void saveRun(host, run).catch(() => undefined)
887 return
888 }
889 this.saveRunLater.schedule(run)
890 }
891
892 // -------------------------------------------------------------------------
893 // Milestone evidence (features/evidence.ts)
894
895 /** The run's milestone evidence: what each milestone's work changed, and what each completed one stood on. */
896 evidence(): Evidence.EvidenceStore {
897 return this.run?.evidence ?? EMPTY_EVIDENCE
898 }
899
900 private setEvidence(next: Evidence.EvidenceStore): void {
901 if (this.run === null || next === this.evidence()) return
902 this.run = { ...this.run, evidence: next }
903 this.persistRun()
904 this.publisher.mark('activity', 'pane')
905 }
906
907 /** A path as the project names it: absolute paths as they are, others under the project's root. */
908 private absPath(path: string): string {
909 if (/^([a-z]:[\\/]|[\\/])/i.test(path) || this.root === '') return path
910 const sep = this.root.includes('\\') && !this.root.includes('/') ? '\\' : '/'
911 return `${this.root.replace(/[\\/]+$/, '')}${sep}${path.replace(/^\.[\\/]/, '').replace(/[\\/]/g, sep)}`
912 }
913
914 /** Files Claude's tools changed: the milestone under way owns them, and a verified milestone's file changed is noted on it. */
915 private noteEvidenceEdits(paths: readonly string[], now: number): void {
916 if (this.run === null) return
917 const ctx = { root: this.root, handoffFile: this.settings.autopilot.handoffFile }
918 // Generated and temporary files (outside the project, temp and build folders, .claude, the handoff notes) are no milestone's evidence.
919 const kept = paths.filter(p => groupOf(p, ctx) !== 'generated')
920 if (kept.length === 0) return
921 const current = this.progress().current
922 this.setEvidence(Evidence.noteEdits(this.evidence(), current === null ? null : { key: current.key, subject: current.subject }, kept, now))
923 }
924
925 /** The checks Claude ran in this context, as Activity's Validation counts them (main conversation). */
926 private checkRuns(): Evidence.CheckRun[] {
927 return this.activity.validationRuns().map(r => ({ kind: r.kind, label: VALIDATION_LABEL[r.kind], command: r.command, status: r.status, endedAt: r.endedAt }))
928 }
929
930 /** Each file's `stat` (size, time; a directory or a missing file as such), with no blob id yet. */
931 private async statFiles(paths: readonly string[]): Promise<Map<string, Evidence.Fingerprint>> {
932 const host = this.host
933 const found = new Map<string, Evidence.Fingerprint>()
934 if (host === null) return found
935 await Promise.all(
936 paths.map(async path => {
937 const st = await host.stat(path, false).catch(() => null)
938 if (st === null) found.set(path, { exists: false, size: null, mtimeMs: null, blob: null })
939 else if (st.kind === 'dir') found.set(path, { exists: true, isDir: true, size: null, mtimeMs: null, blob: null })
940 else found.set(path, { exists: true, size: st.size, mtimeMs: st.mtimeMs, blob: null })
941 }),
942 )
943 return found
944 }
945
946 /** Git's blob ids for those of `paths` that exist as files under the size limit, one `git hash-object` per fifty. */
947 private async hashInto(found: Map<string, Evidence.Fingerprint>, paths: readonly string[]): Promise<void> {
948 const host = this.host
949 if (host === null) return
950 const eligible = paths.filter(p => {
951 const f = found.get(p)
952 return f !== undefined && f.exists && f.isDir !== true && (f.size === null || f.size <= Evidence.HASH_MAX_BYTES)
953 })
954 for (let i = 0; i < eligible.length; i += 50) {
955 const chunk = eligible.slice(i, i + 50)
956 const result = await host.hashFiles(chunk).catch(() => null)
957 if (result === null || result.exitCode !== 0) {
958 this.trace(`evidence: git hash-object ${result === null ? 'could not run' : `failed (exit ${result.exitCode})`}: size and time stand in`)
959 return
960 }
961 const ids = result.stdout.split(/\r?\n/).map(l => l.trim()).filter(l => l !== '')
962 if (ids.length !== chunk.length) return
963 chunk.forEach((p, j) => {
964 const f = found.get(p)
965 const id = ids[j] ?? ''
966 if (f !== undefined && /^[0-9a-f]{40,64}$/.test(id)) found.set(p, { ...f, blob: id })
967 })
968 }
969 }
970
971 /** The commit HEAD points at, in a Git repository; null elsewhere or when Git cannot say. */
972 private async gitHeadOf(): Promise<string | null> {
973 const host = this.host
974 if (host === null) return null
975 if (this.gitRoot === undefined) this.gitRoot = await host.repoRoot().catch(() => null)
976 if (this.gitRoot === null) return null
977 const result = await host.gitHead().catch(() => null)
978 const head = result === null || result.exitCode !== 0 ? '' : result.stdout.trim()
979 return /^[0-9a-f]{40,64}$/.test(head) ? head : null
980 }
981
982 /**
983 * A milestone verified (completed): a snapshot of what it stood on. Its green checks in its window
984 * (from when it was first seen under way), its files' fingerprints, HEAD. Then the earlier snapshots
985 * are compared again: one this milestone changed and re-verified is re-baselined.
986 */
987 private async snapshotMilestone(task: Plan.PlanTask): Promise<void> {
988 if (this.run === null) return
989 const now = Date.now()
990 const store = this.evidence()
991 const since = Evidence.windowStart(store, task.key, this.contextStartedAt ?? 0)
992 const checks = Evidence.greenChecks(this.checkRuns(), since, now)
993 const paths = Evidence.filesFor(store, task.key)
994 const found = await this.statFiles(paths)
995 await this.hashInto(found, paths)
996 const head = paths.length === 0 ? null : await this.gitHeadOf()
997 if (this.run === null) return
998 // Reopened meanwhile: no snapshot of a milestone that is no longer done.
999 if (this.plan().tasks.find(t => t.key === task.key)?.status !== 'completed') return
1000 const ctx = { root: this.root, handoffFile: this.settings.autopilot.handoffFile }
1001 const files = paths.map(path => ({ path, ...(found.get(path) ?? { exists: false, size: null, mtimeMs: null, blob: null }), ...(groupOf(path, ctx) === 'docs' ? { isDoc: true } : {}) }))
1002 const session = Chain.currentSession(this.run)?.index ?? 1
1003 this.setEvidence(Evidence.addSnapshot(this.evidence(), { key: task.key, subject: task.subject, verifiedAt: now, session, checks, files, head }))
1004 this.trace(`evidence: "${task.subject}" verified with ${checks.length === 0 ? 'no check' : checks.map(c => c.label).join(', ')}; ${fmt.plural(files.length, 'file')}${files.some(f => f.blob !== null) ? ' (blob ids)' : ''}${head === null ? '' : ` at ${head.slice(0, 8)}`}`)
1005 this.evidenceCheckedAt = 0
1006 await this.checkEvidence('milestone verified')
1007 }
1008
1009 private isEvidenceChecking = false
1010 private evidenceCheckedAt = 0
1011
1012 /**
1013 * Compares the snapshots with the workspace: each file's `stat`, and a blob id only for one whose size or
1014 * time moved. Run at a handoff (the fresh context's block and Handoff Health read that comparison), a
1015 * session's start or reload, a milestone's completion and (at most once a minute) a turn's end; never
1016 * while nothing is kept.
1017 */
1018 async checkEvidence(reason: string): Promise<void> {
1019 if (this.host === null || this.run === null || this.isEvidenceChecking || this.evidence().snapshots.length === 0) return
1020 this.isEvidenceChecking = true
1021 try {
1022 const store = this.evidence()
1023 const found = await this.statFiles(Evidence.pathsToObserve(store))
1024 await this.hashInto(found, Evidence.pathsToHash(store, found))
1025 if (this.run === null) return
1026 const now = Date.now()
1027 this.evidenceCheckedAt = now
1028 const tasks = this.plan().tasks
1029 const before = this.evidence()
1030 const next = Evidence.compare(before, found, {
1031 active: new Set(tasks.filter(t => Plan.isActive(t.status)).map(t => t.key)),
1032 completed: new Set(tasks.filter(t => t.status === 'completed').map(t => t.key)),
1033 green: Evidence.greenChecks(this.checkRuns(), 0, now),
1034 now,
1035 })
1036 if (next === before) return
1037 for (const snap of next.snapshots) {
1038 const was = before.snapshots.find(b => b.key === snap.key)
1039 if (was !== undefined && (was.status !== snap.status || was.rebaselined.length !== snap.rebaselined.length)) {
1040 const line = Evidence.evidenceLine(snap)
1041 this.trace(`evidence (${reason}): "${snap.subject}" ${snap.status}${line === null ? '' : `: ${line}`}`)
1042 }
1043 }
1044 this.setEvidence(next)
1045 } finally {
1046 this.isEvidenceChecking = false
1047 }
1048 }
1049
1050 /** The completed milestones whose verification is stale now (the last comparison's word). */
1051 staleEvidence(): Evidence.Snapshot[] {
1052 return this.evidence().snapshots.filter(s => s.status === 'stale')
1053 }
1054
1055 /**
1056 * The person accepts stale milestones' files as they are now (one by key, or all): their evidence is
1057 * re-baselined on the files as found, "accepted by you". The words say what was accepted.
1058 */
1059 async acceptEvidence(key: string | null): Promise<string> {
1060 const stale = this.staleEvidence().filter(s => key === null || s.key === key)
1061 if (stale.length === 0) return 'No milestone has stale verification.'
1062 const paths = [...new Set(stale.flatMap(s => s.changes.map(c => c.path)))]
1063 const found = await this.statFiles(paths)
1064 await this.hashInto(found, paths)
1065 this.setEvidence(Evidence.acceptChanges(this.evidence(), key, found, Date.now()))
1066 const names = stale.map(s => `"${s.subject}"`).join(', ')
1067 this.trace(`evidence: accepted by the person as they are now: ${names}`)
1068 return `Accepted ${names} as ${stale.length === 1 ? 'it is' : 'they are'} now: ${stale.length === 1 ? 'its' : 'their'} files are the evidence from here.`
1069 }
1070
1071 /** A stale milestone named by its number in /cr evidence's list or by words of its subject; null when none matches. */
1072 staleEvidenceKey(words: string): string | null {
1073 const stale = this.staleEvidence()
1074 const n = Number(words)
1075 if (Number.isInteger(n) && n >= 1 && n <= stale.length) return stale[n - 1]?.key ?? null
1076 const w = words.trim().toLowerCase()
1077 return w === '' ? null : (stale.find(s => s.subject.toLowerCase().includes(w))?.key ?? null)
1078 }
1079
1080 /** /cr evidence: each verified milestone's evidence in words, the stale ones numbered for /cr evidence accept. */
1081 evidenceText(): string {
1082 const snaps = this.evidence().snapshots
1083 if (snaps.length === 0) return 'No milestone evidence yet: it is kept when a milestone is completed (its green checks and the files its work changed).'
1084 let n = 0
1085 const lines = snaps.map(s => {
1086 const line = Evidence.evidenceLine(s)
1087 const files = s.files.length === 0 ? 'no files' : fmt.plural(s.files.length, 'file')
1088 const checks = s.checks.length === 0 ? 'no check' : [...new Set(s.checks.map(c => c.label))].join(', ')
1089 const head = s.head === null ? '' : ` at ${s.head.slice(0, 8)}`
1090 if (s.status === 'stale') return ` ${++n}. ▲ ${s.subject}: verification stale · ${line ?? ''}`
1091 return ` ${s.status === 'changing' ? '◷' : '✓'} ${s.subject}: ${s.status === 'changing' ? (line ?? 'being changed') : `current${line === null ? '' : ` (${line})`}`} · ${checks} · ${files}${head}`
1092 })
1093 const tail = n === 0 ? [] : ['', `Rerun the named checks, or accept the files as they are now: /cr evidence accept ${n === 1 ? '1' : '<number>|all'}.`]
1094 return [`Milestone evidence: ${fmt.plural(snaps.length, 'verified milestone')}${n === 0 ? ', none stale' : `, ${n} stale`}`, ...lines, ...tail].join('\n')
1095 }
1096
1097 /**
1098 * What the fresh context after Control Room's own handoff is told with its first message (the
1099 * continuation, or whatever the person types first): the run, the notes, the milestones. It is
1100 * one more context block of that message (`prompt.context`), so the session's start is left
1101 * exactly as Claude Code made it.
1102 */
1103 private freshContext: string | null = null
1104
1105 /** The fresh context's notes, once: the first message after a handoff carries them. */
1106 takeFreshContext(): string | null {
1107 const text = this.freshContext
1108 this.freshContext = null
1109 return text
1110 }
1111
1112 /** classic.SessionStart: a session begins (startup, resume, compact) or a fresh context after /clear. */
1113 async onClassicSessionStart(input: {
1114 source: string
1115 sessionId: string
1116 model?: string
1117 transcriptPath?: string
1118 /** resume/fork: Claude Code's word on the cache: seconds since the last answer, the context, whether it likely expired, its estimate of re-caching. */
1119 secondsSince?: number
1120 contextTokens?: number
1121 isCacheExpired?: boolean
1122 usd?: number
1123 }): Promise<void> {
1124 await this.ensureLoaded()
1125 await this.carryAtSessionStart(input.transcriptPath)
1126 if (input.source !== 'clear') {
1127 this.startSource = { source: input.source, sessionId: input.sessionId }
1128 if (input.source === 'compact') this.publisher.mark('chain', 'hud')
1129 if (input.source === 'resume' || input.source === 'fork') {
1130 this.ops.noteResumed({ secondsSince: input.secondsSince, tokens: input.contextTokens, isExpired: input.isCacheExpired, usd: input.usd, model: input.model })
1131 }
1132 return
1133 }
1134 const host = this.host
1135 const now = host === null ? Date.now() : await this.engineNow()
1136 const wasOurs = this.isOwnClear || this.autopilot.state === 'clearing'
1137 const purpose = wasOurs ? this.freshPurpose : null
1138 this.trace(`fresh session ${input.sessionId} after /clear (${purpose !== null ? `Project Sentinel's ${purpose.endNote}` : wasOurs ? "Control Room's handoff" : 'cleared by the person'})`)
1139 if (wasOurs) this.onOwnClearSeen?.()
1140 if (this.run !== null) {
1141 this.run = Chain.rollOver(this.run, {
1142 end: wasOurs ? 'handoff' : 'clear',
1143 endNote: purpose !== null
1144 ? purpose.endNote
1145 : wasOurs
1146 ? `handoff at ${this.autopilot.triggeredTokens === null ? 'manual request' : `${Math.round(this.autopilot.triggeredTokens / 1000)}k tokens`}`
1147 : 'cleared by you',
1148 nextId: input.sessionId,
1149 nextStart: wasOurs ? 'handoff' : 'clear',
1150 now,
1151 })
1152 this.persistRun(true)
1153 this.questForRunShape(now)
1154 }
1155 this.sessionId = input.sessionId
1156 this.usage = { tokens: undefined, window: this.usage.window, pct: undefined, costUsd: 0 }
1157 this.cache.resetForContext()
1158 this.reads = []
1159 this.contextStartedAt = Date.now()
1160 if (wasOurs && this.run !== null) {
1161 const h = this.run.lastHandoff
1162 const index = Chain.currentSession(this.run)?.index ?? 1
1163 if (h !== undefined && h !== null && h.toSession === null && h.fromSession === index - 1) this.run = { ...this.run, lastHandoff: { ...h, toSession: index } }
1164 }
1165 this.activity.reset()
1166 this.guard = { turnBlocks: 0, sessionBlocks: 0, lastBlockedAnswer: '', last: null }
1167 this.compose = { ...this.compose, isReached: false, deliveredFallback: false }
1168 // A fresh context: a fresh system prompt, so notes about the old one, and what the old one was told, are gone.
1169 this.notesBox.clear()
1170 this.ledger.reset()
1171 this.lastFrontierDelivered = null
1172 this.pressureTold = 'ok'
1173 this.memoText = null
1174 this.router.turnModel = null
1175 this.publisher.forgetPublished()
1176 this.publisher.markAll()
1177 if (!wasOurs) {
1178 this.freshContext = null
1179 this.stepAutopilot({ kind: 'externalClear' })
1180 return
1181 }
1182 const policies = this.policies().map(s => s.name)
1183 const sessionNumber = this.run === null ? 1 : (Chain.currentSession(this.run)?.index ?? 1)
1184 // What the old context left running keeps running here (Claude Code keeps background work across /clear).
1185 const left = this.autopilot.leftRunning
1186 const base = prompts.continuationContext({
1187 runNumber: this.run?.number ?? null,
1188 sessionNumber,
1189 handoffPath: this.handoffPath(),
1190 policies,
1191 milestones: this.plan().tasks.map(t => ({ subject: t.subject, status: t.status, detail: t.detail })).slice(-30),
1192 objective: this.run?.objectiveBy === 'claude' ? this.run.objective : null,
1193 cause: purpose?.cause,
1194 leftRunning: left,
1195 stale: Evidence.staleText(this.staleEvidence()),
1196 })
1197 if (left.length > 0) {
1198 this.trace(`autopilot: the fresh context is told what still runs (${left.map(t => `${t.id} ${t.class ?? 'unknown'}`).join(', ')})`)
1199 this.stepAutopilot({ kind: 'leftRunningTold' })
1200 }hooks/app/views.ts 892 lines1/**
2 * View models: render-ready projections of the Runtime, one per `$.state`
3 * atom. Pure functions of the runtime's state; the publisher decides when
4 * to publish them.
5 *
6 * The plain-language status helpers below are the product's one voice: the
7 * HUD, the Control Centre, the status line and `/cr status` all say the same
8 * thing the same way ("Hands off at 70%", "Watching for early stops").
9 */
10
11import type {
12 ActivityView,
13 ChainRunView,
14 FileChangeView,
15 MissionView,
16 ChainView,
17 EvidenceRowView,
18 FocusModel,
19 HudAlert,
20 HudModel,
21 HudOps,
22 OpsView,
23 PaneModel,
24 PermissionsView,
25 QuestView,
26 ResourcesView,
27 SpinnerModel,
28 StatusView,
29 SystemId,
30 TimelineKind,
31 Tone,
32 TurnTimelineView,
33} from '../../types'
34import { MIN_ENGINE } from '../constants'
35import * as fmt from '../core/format'
36import { findProfile, isModified } from '../core/profiles'
37import { PERMISSION_CATEGORIES } from '../core/settings'
38import { relativeTo, shortPath } from '../core/text'
39import { readingTone } from '../ui/theme'
40import { planWords } from '../ui/hud'
41import { versionAtLeast } from '../core/version'
42import type { ActivityItem, FileChange } from '../features/activity'
43import { backgroundWords, blockingOf, classOf, handoffPoint, isHandoffActive } from '../features/autopilot'
44import { CLASS_WORDS, isBlocking } from '../features/background'
45import { bandOf } from '../features/agents'
46import * as Chain from '../features/chain'
47import { GROUP_LABEL, GROUP_ORDER, attentionOf, groupOf, isOpen, nowOf, turnSummaryOf } from '../features/digest'
48import { companionView, moodOf } from '../features/companion'
49import { gitLine } from '../features/git'
50import { ACHIEVEMENTS, levelOf, runQuestOf } from '../features/quest'
51import { planUsageView, windowRows } from '../features/planUsage'
52import { evidenceLine } from '../features/evidence'
53import { type ValidationSummary, summarize } from '../features/validation'
54import { answerStyleLabel } from '../core/answers'
55import { LONG_CALL_MS, chipsOf, headlineOf, runNowOf, trackOf } from './headline'
56import type { Runtime } from './runtime'
57import { suggestionWords } from '../features/scout'
58
59function contextTone(rt: Runtime): Tone {
60 const tokens = rt.usage.tokens
61 if (tokens === undefined) return 'muted'
62 const threshold = rt.settings.autopilot.enabled ? handoffPoint(rt.autopilot) : null
63 const window = rt.usage.window
64 const ref = threshold ?? (window === undefined ? null : window * 0.9)
65 if (ref === null) return 'normal'
66 if (tokens >= ref) return 'bad'
67 if (tokens >= ref * 0.85) return 'warn'
68 return 'good'
69}
70
71// ---------------------------------------------------------------------------
72// Plain-language status
73
74const EFFORT_NAME: Record<string, string> = { max: 'Maximum effort', xhigh: 'Extra-high effort', high: 'High effort', keep: 'Policy only' }
75const LEVEL_NAME: Record<string, string> = { off: 'Off', low: 'Low', medium: 'Medium', high: 'High', custom: 'Custom' }
76const STRATEGY_NAME: Record<string, string> = { off: 'Off', balanced: 'Balanced', performance: 'Performance', economy: 'Economy', custom: 'Custom' }
77
78/**
79 * A handoff waiting for the person: calm when they asked for it ("Wait for
80 * me"), red only when something went wrong (no notes, clearing refused).
81 */
82const awaitingTone = (rt: Runtime): Tone => (rt.autopilot.lastError === null ? 'warn' : 'bad')
83
84/** "70%" or "700k": the threshold as the person set it. */
85export function thresholdText(rt: Runtime): string {
86 const a = rt.settings.autopilot
87 return a.thresholdMode === 'percent' ? `${a.thresholdPercent}%` : fmt.tokens(rt.autopilot.threshold ?? a.thresholdTokens)
88}
89
90export function autopilotStatus(rt: Runtime): StatusView {
91 if (!rt.settings.autopilot.enabled) return { text: 'Off', tone: 'muted' }
92 switch (rt.autopilot.state) {
93 case 'off':
94 case 'armed':
95 return { text: `Hands off at ${thresholdText(rt)}`, tone: 'normal' }
96 case 'pending':
97 return { text: 'Handoff soon', tone: 'warn' }
98 case 'requested':
99 case 'handoff':
100 return { text: 'Writing the handoff', tone: 'info' }
101 case 'waiting-background':
102 return { text: 'Waiting for background work', tone: 'info' }
103 case 'verifying':
104 return { text: 'Checking the notes', tone: 'info' }
105 case 'clearing':
106 case 'compacting':
107 return { text: 'Starting fresh', tone: 'info' }
108 case 'resuming':
109 return { text: 'Resuming', tone: 'info' }
110 case 'awaiting':
111 return { text: 'Waiting for you', tone: awaitingTone(rt) }
112 }
113}
114
115export function frontierStatus(rt: Runtime): StatusView {
116 const f = rt.settings.frontier
117 if (!f.enabled) return { text: 'Off', tone: 'muted' }
118 if (f.effort !== 'keep' && rt.frontier.isEffortSupported === false) return { text: 'Policy on · this model has no effort setting', tone: 'normal' }
119 return { text: EFFORT_NAME[f.effort] ?? 'On', tone: 'normal' }
120}
121
122export function guardStatus(rt: Runtime): StatusView {
123 if (!rt.settings.guard.enabled) return { text: 'Off', tone: 'muted' }
124 const eff = rt.effective().guard
125 if (!eff.isActive) return { text: eff.reason ?? 'Paused', tone: 'muted' }
126 if (rt.guard.turnBlocks > 0) return { text: `Kept Claude going ${rt.guard.turnBlocks}× this turn`, tone: 'warn' }
127 if (rt.guard.sessionBlocks > 0) return { text: `Kept Claude going ${rt.guard.sessionBlocks}× this session`, tone: 'normal' }
128 return { text: 'Watching for early stops', tone: 'normal' }
129}
130
131export function routerStatus(rt: Runtime): StatusView {
132 const s = rt.settings.router.strategy
133 return { text: STRATEGY_NAME[s] ?? s, tone: s === 'off' ? 'muted' : 'normal' }
134}
135
136export function subagentStatus(rt: Runtime): StatusView {
137 const s = rt.settings.subagents
138 const running = rt.runningSubagents()
139 const live = running > 0 ? ` · ${running} running` : ''
140 switch (s.mode) {
141 case 'unrestricted':
142 return { text: `No limit${live}`, tone: 'normal' }
143 case 'block':
144 return { text: 'Off', tone: 'warn' }
145 case 'ask':
146 return { text: `Ask each time${live}`, tone: 'normal' }
147 case 'limit':
148 return { text: `Up to ${s.limit} at once${live}`, tone: running >= s.limit ? 'warn' : 'normal' }
149 }
150}
151
152/** Machine load. `attention` is set only when it needs a look; the HUD shows nothing otherwise. */
153export function loadStatus(rt: Runtime): StatusView & { isOn: boolean; attention: string | null; level: string } {
154 const r = rt.settings.resources
155 const level = LEVEL_NAME[r.level] ?? r.level
156 if (r.level === 'off') return { isOn: false, attention: null, level, text: 'Off', tone: 'muted' }
157 const m = rt.monitor
158 const p = m.pressure
159 if (m.status === 'unavailable') return { isOn: true, attention: 'Load unavailable', level, text: 'Readings unavailable', tone: 'muted' }
160 if (m.status !== 'live' || p.level === 'unknown') return { isOn: true, attention: null, level, text: 'Starting…', tone: 'muted' }
161 const pct = (n: number | null) => (n === null ? '—' : `${Math.round(n)}%`)
162 const tone: Tone = p.level === 'ok' ? 'good' : p.level === 'elevated' ? 'warn' : 'bad'
163 const driver = p.driver === 'ram' ? `Memory ${pct(p.ram)}` : `CPU ${pct(p.cpu)}`
164 return { isOn: true, attention: p.level === 'ok' ? null : driver, level, text: `CPU ${pct(p.cpu)} · Memory ${pct(p.ram)}`, tone }
165}
166
167export function focusStatus(rt: Runtime): StatusView {
168 const f = rt.settings.focus
169 if (!f.enabled) return { text: 'Off', tone: 'muted' }
170 return { text: f.tools === 'hidden' ? 'Tool calls hidden' : 'Compact transcript', tone: 'normal' }
171}
172
173export function qaStatus(rt: Runtime): StatusView {
174 return rt.settings.qa.enabled ? { text: 'Verify before done', tone: 'normal' } : { text: 'Off', tone: 'muted' }
175}
176
177/** The answer style in force: the person's own Claude Code output style outranks it. */
178export function answersStatus(rt: Runtime): StatusView {
179 if (rt.nativeOutputStyle !== null) return { text: `Claude Code style: ${rt.nativeOutputStyle}`, tone: 'muted' }
180 const style = rt.settings.answers.style
181 return { text: answerStyleLabel(style), tone: style === 'standard' ? 'muted' : 'normal' }
182}
183
184export function statusOf(rt: Runtime): Record<SystemId, StatusView> {
185 const load = loadStatus(rt)
186 return {
187 autopilot: autopilotStatus(rt),
188 frontier: frontierStatus(rt),
189 qa: qaStatus(rt),
190 guard: guardStatus(rt),
191 router: routerStatus(rt),
192 subagents: subagentStatus(rt),
193 load: load.isOn ? { text: `${load.level} · ${load.text}`, tone: load.tone } : { text: 'Off', tone: 'muted' },
194 focus: focusStatus(rt),
195 answers: answersStatus(rt),
196 }
197}
198
199export function profileOf(rt: Runtime): { id: string; name: string; isModified: boolean } {
200 const profile = findProfile(rt.settings, rt.settings.profile)
201 return { id: rt.settings.profile, name: profile?.name ?? 'Custom', isModified: isModified(rt.settings) }
202}
203
204export function runLabelOf(rt: Runtime): string {
205 if (rt.run === null) return 'No run yet'
206 const s = Chain.currentSession(rt.run)
207 return `Run ${rt.run.number} · Session ${s?.index ?? 1}`
208}
209
210
211function liveLoadOf(rt: Runtime): HudModel['load'] {
212 if (rt.monitor.status === 'off') return null
213 const latest = rt.monitor.status === 'live' ? rt.monitor.latest() : null
214 const ceilings = rt.effective().resources.ceilings
215 const cpu = latest?.cpu ?? null
216 const ram = latest?.ram ?? null
217 return {
218 cpu,
219 ram,
220 cpuTone: readingTone(cpu, ceilings?.cpu ?? null, { warn: 75, bad: 90 }),
221 ramTone: readingTone(ram, ceilings?.ram ?? null, { warn: 80, bad: 92 }),
222 cpuSeries: rt.monitor.cpuSeries(8),
223 ramSeries: rt.monitor.ramSeries(8),
224 }
225}
226
227// ---------------------------------------------------------------------------
228// Signal: shared by the status bar and Activity
229
230/** The main conversation's calls in the current (or last) turn, oldest first. */
231const turnItemsOf = (rt: Runtime): ActivityItem[] => rt.activity.items.filter(i => i.turn === rt.activity.turn.index && i.agentId === null)
232
233const validationNow = (rt: Runtime, now: number): ValidationSummary[] => summarize(rt.activity.validationRuns(), now)
234
235const nowLine = (rt: Runtime) => nowOf({ isTurnRunning: rt.turn.isRunning, running: rt.activity.runningItems(), progress: rt.progress() })
236
237// ---------------------------------------------------------------------------
238// Projections
239
240/**
241 * The line above the headline, for what needs the person now: a handoff, the machine, then the
242 * orchestration layer (a watcher due that waits for them, a message the Cold Resume Guard kept, the
243 * budget holding automation, a watcher suggestion). One at a time, the most pressing.
244 */
245function alertOf(rt: Runtime, ops: HudOps | null): HudAlert | null {
246 const s = rt.settings
247 if (rt.autopilot.state === 'awaiting') return { kind: 'awaiting', text: rt.autopilot.note, tone: awaitingTone(rt) }
248 const handoff = handoffAlertOf(rt)
249 if (handoff !== null) return handoff
250 if (rt.monitor.pressure.level === 'critical' && s.resources.level !== 'off') return { kind: 'load', text: 'Your machine is under heavy load. Claude was asked to ease off.', tone: 'bad' }
251 if (rt.autopilot.state === 'pending') return { kind: 'pending', text: 'Finishing this step, then handing off', tone: 'warn' }
252 if (rt.turn.isRunning) return null
253 const w = ops?.watcher ?? null
254 if (w !== null && (w.status === 'due' || w.status === 'stale') && w.needs !== null) {
255 const strategy = rt.ops.current().watchers.find(x => x.id === w.id)?.strategy ?? 'warm'
256 return { kind: 'watcher', text: `Watcher due: ${w.label} · ${w.needs}`, tone: 'warn', ref: w.id, canFresh: strategy !== 'warm' && rt.ops.resumeHealth().isHealthy }
257 }
258 if (rt.ops.held !== null) return { kind: 'held', text: 'Your message was not sent: it is kept in Control Room', tone: 'warn' }
259 if (rt.ops.budgetHeld !== null) return { kind: 'budget', text: `Run budget reached: ${rt.ops.budgetHeld} waits for you`, tone: 'warn' }
260 const sug = rt.ops.suggestion
261 if (sug !== null) return { kind: 'suggest', text: `Claude seems to be waiting for a future result. ${suggestionWords(sug, rt.clock())}`, tone: 'info', ref: sug.id, hasTime: sug.when !== null && sug.when.kind === 'at' }
262 return null
263}
264
265/** From this close to the wait's end, Keep waiting is offered beside the other ways out. */
266const KEEP_OFFERED_MS = 5 * 60_000
267
268/**
269 * A handoff the person may want to move on, with its ways out (the Handoff Supervisor's): one that
270 * waits for background work (Hand off now and leave it running, Stop & hand off, Keep waiting near
271 * the wait's end, Call off), and one whose step has not moved on (stalled). Null otherwise.
272 */
273function handoffAlertOf(rt: Runtime): HudAlert | null {
274 if (!rt.settings.autopilot.enabled) return null
275 const a = rt.autopilot
276 const d = rt.supervisor
277 // The wait's end was set by the engine's clock: measured by it.
278 const now = rt.engineTime()
279 if (a.state === 'waiting-background') {
280 const waiting = blockingOf(a.background)
281 if (waiting.length === 0 || a.skipWait) return null
282 const left = a.waitUntil === null ? '' : a.waitUntil > now ? ` · goes on in ${fmt.countdown(a.waitUntil - now)} at most` : ' · going on'
283 const isStalled = d?.status === 'stalled'
284 return {
285 kind: 'waiting',
286 text: `Handoff ${a.notesWritten === false ? 'waits' : 'written, waits'} for ${backgroundWords(waiting)}${left}`,
287 tone: isStalled ? 'warn' : 'info',
288 canStop: true,
289 canKeep: isStalled || (a.waitUntil !== null && a.waitUntil - now < KEEP_OFFERED_MS),
290 }
291 }
292 if (d === null || (d.status !== 'stalled' && d.status !== 'needs-you') || d.blocker === null || a.state === 'awaiting' || a.state === 'pending') return null
293 return { kind: 'stuck', text: `Handoff: ${d.blocker.text}`, tone: 'warn', offers: [...d.actions] }
294}
295
296export function hudOf(rt: Runtime): HudModel {
297 const tokens = rt.usage.tokens ?? null
298 const window = rt.usage.window ?? null
299 const pct = tokens !== null && window !== null && window > 0 ? Math.round((100 * tokens) / window) : (rt.usage.pct ?? null)
300 const totals = rt.run === null ? null : Chain.totals(rt.run, Date.now())
301 const ap = autopilotStatus(rt)
302 const s = rt.settings
303 const ops = rt.ops.hud()
304 const alert = alertOf(rt, ops)
305 const now = Date.now()
306 const validation = validationNow(rt, now)
307 const activity = hudActivityOf(rt, now)
308 const failing = validation.filter(v => v.isFailing).map(v => v.label)
309 const attention = barAttentionOf(rt, now)
310 const load = liveLoadOf(rt)
311 const agents = { running: rt.runningSubagents(), limit: s.subagents.mode === 'limit' ? s.subagents.limit : null, mode: s.subagents.mode }
312 const guard = { isOn: s.guard.enabled, continued: rt.guard.turnBlocks }
313 const quest = questHudOf(rt)
314 const summary = activity === null || activity.state !== 'done' ? null : { text: activity.text, durationMs: activity.durationMs, isFailing: failing.length > 0 || attention > 0 }
315 return {
316 isVisible: s.ui.hud === 'band' || s.ui.hud === 'both',
317 headline: headlineOf(rt, now, summary),
318 chips: chipsOf({ failing, attention, guard, load, agents, quest, ops, isWorking: rt.turn.isRunning }),
319 isPaneOpen: rt.ui.isPaneOpen,
320 ctx: { tokens, window, pct, threshold: s.autopilot.enabled ? handoffPoint(rt.autopilot) : null, tone: contextTone(rt) },
321 cost: { usd: rt.usage.costUsd ?? null, runUsd: totals?.costUsd ?? null, isRunPartial: totals?.isCostPartial ?? false },
322 profile: profileOf(rt),
323 frontier: { isOn: s.frontier.enabled, effort: s.frontier.enabled ? (EFFORT_NAME[s.frontier.effort] ?? null) : null },
324 autopilot: { isOn: s.autopilot.enabled, state: rt.autopilot.state, text: ap.text, tone: ap.tone },
325 load,
326 agents,
327 guard,
328 session: { run: rt.run?.number ?? null, index: rt.run === null ? 1 : (Chain.currentSession(rt.run)?.index ?? 1), handoffs: totals?.handoffs ?? 0 },
329 alert,
330 ops,
331 work: workOf(rt),
332 now: nowLine(rt),
333 failing,
334 attention,
335 activity,
336 checks: validation.map(v => ({ label: v.label, status: v.status })),
337 quest,
338 cache: sleepingCache(rt.cache.hud(rt.clock(), rt.turn.isRunning), ops),
339 objective: rt.run?.objective ?? null,
340 isAnimated: !s.ui.reducedMotion,
341 companion: companionOf(rt, now, validation),
342 git: rt.git === null ? null : gitLine(rt.git),
343 agentBand: bandOf(rt.ops.agentRowsNow(), rt.clock()),
344 plan: planOf(rt),
345 }
346}
347
348/** The plan's windows now: `resetsAt` is wall-clock time, so they are read against the system clock. */
349function planOf(rt: Runtime): HudModel['plan'] {
350 const now = rt.clock()
351 return { five: planUsageView(rt.planUsage, now), windows: windowRows(rt.planUsage, now) }
352}
353
354/** While a watcher parks the run, the cache says what becomes of it: held warm to the wake, or left to lapse for a fresh one. */
355function sleepingCache(cache: HudModel['cache'], ops: HudOps | null): HudModel['cache'] {
356 const w = ops?.watcher ?? null
357 if (cache === null || ops === null || !ops.isSleeping || w === null || cache.recentMiss !== null) return cache
358 if (w.isHeldWarm && cache.warmth === 'warm') return { ...cache, parked: { text: 'held warm', short: 'held' }, tone: 'normal', isShown: true }
359 if (w.mode === 'fresh') return { ...cache, parked: { text: 'no keep-alive', short: 'lapses' }, tone: 'muted', isShown: true }
360 return cache
361}
362
363/** The orchestration layer's view, for Activity → Operations, Overview and Context. */
364export function opsOf(rt: Runtime): OpsView {
365 return rt.ops.view()
366}
367
368/** Kit's mood from what Claude is doing, and what its surface module needs; null while the companion is off or has failed to draw. */
369function companionOf(rt: Runtime, now: number, validation: readonly ValidationSummary[]): HudModel['companion'] {
370 const s = rt.settings
371 if (!s.ui.companion || rt.companionFault !== null) return null
372 const turn = rt.activity.turn
373 const thisTurn = rt.activity.validationRuns().filter(r => r.turn === turn.index)
374 const p = rt.progress()
375 const threshold = s.autopilot.enabled ? handoffPoint(rt.autopilot) : null
376 const window = rt.usage.window ?? null
377 const ref = threshold ?? (window === null ? null : window * 0.9)
378 const load = liveLoadOf(rt)
379 // Busy for Kit: the processor near its ceiling (fewer frames, no walking). Memory at its limit tires
380 // Kit (its mood) but costs nothing to animate, so it never slows or stills it: a desktop often sits
381 // at its memory ceiling (seen live, 85–90%), and Kit froze there until 1.5.0.
382 const isCpuBusy = load !== null && (load.cpuTone === 'warn' || load.cpuTone === 'bad')
383 const isBusy = isCpuBusy || (load !== null && load.ramTone === 'bad')
384 const cache = rt.cache.hud(rt.clock(), rt.turn.isRunning)
385 const opsHud = rt.ops.hud()
386 const isGreen = (thisTurn.length > 0 && thisTurn.every(r => r.status === 'passed')) || (p.total > 0 && p.done === p.total && p.done > rt.turnStartDone)
387 const mood = moodOf({
388 now,
389 isWorking: rt.turn.isRunning,
390 source: rt.turn.isRunning ? (nowLine(rt)?.source ?? 'thinking') : null,
391 toolKind: rt.activity.runningItems().find(i => i.agentId === null)?.kind ?? null,
392 isCheckRunning: validation.some(v => v.status === 'running'),
393 isFailing: validation.some(v => v.isFailing) || barAttentionOf(rt, now) > 0,
394 autopilotState: s.autopilot.enabled ? rt.autopilot.state : 'off',
395 isGreen,
396 turnEndedAt: turn.endedAt,
397 hasTurned: turn.index > 0,
398 contextStartedAt: rt.contextStartedAt,
399 contextShare: ref === null || rt.usage.tokens === undefined ? null : rt.usage.tokens / ref,
400 isKeepingWarm: (s.cache.keepWarm || rt.ops.hold() !== null) && rt.cache.plan.at !== null,
401 // The orchestration layer: decisions to review look to the person; a parked run sleeps (or tends a held cache).
402 isNeedingYou: (opsHud?.review ?? 0) > 0 || (opsHud?.watcher?.needs ?? null) !== null,
403 sleep: opsHud?.isSleeping === true ? (opsHud.watcher?.isHeldWarm === true ? 'warm' : 'parked') : null,
404 isWaiting: rt.lastStop !== null && (rt.blockingInFlight().length > 0 || rt.lastStop.wakeups.length > 0 || rt.lastStop.isQuestion) || p.blocked.length > 0 || p.waiting.length > 0,
405 isBusy,
406 isCacheNear: cache !== null && cache.tone === 'warn' && cache.recentMiss === null,
407 isRefreshing: rt.cache.state.keepWarm.lastAt !== null && now - rt.cache.state.keepWarm.lastAt < 60_000,
408 })
409 // A processor at its limit: one frame a second (the companion never adds to the load), still alive.
410 const pressure = rt.monitor.pressure
411 const isStrained = pressure.level === 'critical' && pressure.driver === 'cpu'
412 return companionView({
413 mood,
414 now,
415 hour: new Date(now).getHours(),
416 isReduced: s.ui.reducedMotion,
417 isBusy: isCpuBusy,
418 isStrained,
419 isWorking: rt.turn.isRunning,
420 contextStartedAt: rt.contextStartedAt,
421 done: p.done,
422 // One-shot moments, as values Kit compares with what it last saw.
423 // A green finish is a moment only when nothing failed: never a dance beside a failing check.
424 greenAt: mood === 'celebrate' ? turn.endedAt : null,
425 fails: rt.activity.validationRuns().filter(r => r.status === 'failed').length,
426 refreshAt: rt.cache.state.keepWarm.lastAt,
427 })
428}
429
430/**
431 * The status bar's count of calls that need a look. A failing check is left
432 * out: the bar already shows it failing, by name.
433 */
434function barAttentionOf(rt: Runtime, now: number): number {
435 const items = turnItemsOf(rt)
436 const checks = new Set(items.filter(i => i.validation !== null).map(i => i.id))
437 return attentionOf(items, now).filter(a => isOpen(a) && !(a.kind === 'failed' && checks.has(a.id))).length
438}
439
440/**
441 * The status bar's top line. While a turn runs: what Claude is doing, the
442 * milestone it serves, and how long a slow call has been running. Once it
443 * ends: what the turn did, in counted words. Nothing before the first turn.
444 */
445function hudActivityOf(rt: Runtime, now: number): HudModel['activity'] {
446 const turn = rt.activity.turn
447 if (turn.index === 0 && !rt.turn.isRunning) return null
448 const p = rt.progress()
449 const tasks = rt.plan().tasks
450 const milestone = p.current === null ? null : { subject: p.current.subject, index: tasks.indexOf(p.current) + 1, total: tasks.length }
451 if (rt.turn.isRunning) {
452 const line = nowLine(rt) ?? { text: 'Thinking', source: 'thinking' as const }
453 const longest = rt.activity
454 .runningItems()
455 .filter(i => i.agentId === null)
456 .reduce((ms, i) => Math.max(ms, now - i.startedAt), 0)
457 return { state: 'working', text: line.text, source: line.source, milestone, runningMs: longest >= LONG_CALL_MS ? longest : null, durationMs: null }
458 }
459 const items = turnItemsOf(rt)
460 const files = rt.activity.changeList()
461 const ctx = { root: rt.root, handoffFile: rt.settings.autopilot.handoffFile }
462 const lines = turnSummaryOf({
463 items,
464 changed: files.filter(f => turn.files.has(f.path)),
465 groupOf: path => groupOf(path, ctx),
466 validation: validationNow(rt, now),
467 attention: attentionOf(items, now),
468 milestonesDone: Math.max(0, p.done - rt.turnStartDone),
469 })
470 // The changes by count alone ("Changed 4 files"), so the checks after them stay in view.
471 const [first, second] = lines.map((line, i) => (i === 0 && line.startsWith('Changed ') ? (line.split(' · ')[0] ?? line) : line))
472 return {
473 state: 'done',
474 text: first === undefined ? 'Replied, no tools used' : second === undefined ? first : `${first} · ${second}`,
475 source: 'summary',
476 milestone,
477 runningMs: null,
478 durationMs: turn.startedAt === null || turn.endedAt === null ? null : turn.endedAt - turn.startedAt,
479 }
480}
481
482function questHudOf(rt: Runtime): HudModel['quest'] {
483 if (rt.settings.answers.style !== 'quest') return null
484 const l = levelOf(rt.quest.xp)
485 return { level: l.level, xp: rt.quest.xp, intoLevel: rt.quest.xp - l.floor, levelSpan: l.next - l.floor, runXp: runQuestOf(rt.run?.quest).xp }
486}
487
488function workOf(rt: Runtime): HudModel['work'] {
489 const p = rt.progress()
490 if (p.total === 0) return null
491 return { done: p.done, total: p.total, current: p.current === null ? null : (p.current.activeForm ?? p.current.subject), track: trackOf(rt.plan().tasks) }
492}
493
494/** The live status bar for Claude Code's own status line (`/cr hud status`): the run in one line. */
495export function statusLineOf(hud: HudModel): string {
496 const pct = (n: number | null) => (n === null ? '—' : `${Math.round(n)}%`)
497 const isHigh = (t: Tone) => t === 'warn' || t === 'bad'
498 const parts = [`◆ Context ${pct(hud.ctx.pct)}`]
499 if (hud.work !== null) parts.push(`Work ${hud.work.done}/${hud.work.total}`)
500 if (hud.cache !== null) parts.push(`Cache ${hud.cache.parked?.text ?? hud.cache.text}`)
501 if (hud.autopilot.isOn && hud.autopilot.state !== 'off' && hud.autopilot.state !== 'armed') parts.push(hud.autopilot.text)
502 if (hud.now !== null) parts.push(hud.now.text)
503 // The orchestration layer, only while it matters: the run asleep, decisions to review, the budget.
504 const ops = hud.ops
505 const w = ops?.watcher ?? null
506 if (ops !== null && ops.isSleeping && w !== null && w.status === 'armed') parts.push(`Sleeping until ${w.at} · ${w.label} (${w.left})`)
507 else if (w !== null && w.needs !== null) parts.push(`Watcher due: ${w.label}`)
508 else if (w !== null && w.status === 'armed') parts.push(`Watcher ${w.left}`)
509 if (ops !== null && ops.review > 0) parts.push(ops.blocking > 0 ? `Needs you · ${fmt.plural(ops.blocking, 'decision')}` : `Review ${ops.review}`)
510 if (ops?.budget != null && ops.budget.tone !== 'muted') parts.push(ops.budget.text)
511 if (hud.failing.length > 0) parts.push(`${hud.failing.join(', ')} failing`)
512 if (hud.attention > 0) parts.push(`${fmt.plural(hud.attention, 'issue')}`)
513 if (hud.load !== null && isHigh(hud.load.cpuTone)) parts.push(`CPU ${pct(hud.load.cpu)}`)
514 if (hud.load !== null && isHigh(hud.load.ramTone)) parts.push(`RAM ${pct(hud.load.ram)}`)
515 if (hud.agents.running > 0) parts.push(fmt.plural(hud.agents.running, 'agent'))
516 if (hud.guard.isOn && hud.guard.continued > 0) parts.push(`Kept going ×${hud.guard.continued}`)
517 const plan = planWords(hud.plan.five, false)
518 if (plan !== null) parts.push(plan)
519 return parts.join(' · ')
520}
521
522export function paneOf(rt: Runtime): PaneModel {
523 const a = rt.autopilot
524 const isBusy = isHandoffActive(a.state) && a.state !== 'pending'
525 return {
526 tab: rt.ui.tab,
527 showAllChanges: rt.ui.showAllChanges,
528 openPicker: rt.ui.openPicker,
529 status: statusOf(rt),
530 activitySub: rt.ui.activitySub,
531 settings: rt.settings,
532 profileLabel: rt.profileLabel(),
533 runLabel: runLabelOf(rt),
534 engine: { version: rt.engineVersion, isSupported: rt.engineVersion === null || versionAtLeast(rt.engineVersion, MIN_ENGINE) },
535 surfaces: rt.surfaces,
536 autopilot: {
537 state: a.state,
538 note: a.note,
539 threshold: handoffPoint(a),
540 isClamped: a.isClamped,
541 autoCompactAt: rt.autoCompactAt ?? null,
542 tokens: rt.usage.tokens ?? null,
543 window: rt.usage.window ?? null,
544 handoffPath: rt.handoffPath(),
545 lastError: a.lastError,
546 completed: a.completed,
547 canHandoff: rt.settings.autopilot.enabled && !isBusy,
548 canFresh: canFreshOf(rt),
549 canSnooze: a.state === 'pending' || a.state === 'awaiting',
550 background:
551 a.state === 'waiting-background'
552 ? a.background.map(t => {
553 const c = classOf(t)
554 return { id: t.id, description: t.description, class: c, classWords: CLASS_WORDS[c], why: t.why ?? null, by: t.by ?? null, isBlocking: isBlocking(c) }
555 })
556 : [],
557 supervisor:
558 rt.supervisor === null
559 ? null
560 : { status: rt.supervisor.status, phase: rt.supervisor.phase, forMs: rt.supervisor.forMs, blocker: rt.supervisor.blocker, goesOnInMs: rt.supervisor.goesOnAt === null ? null : Math.max(0, rt.supervisor.goesOnAt - rt.engineTime()), actions: [...rt.supervisor.actions] },
561 waitLeftMs: a.state === 'waiting-background' && a.waitUntil !== null ? Math.max(0, a.waitUntil - rt.engineTime()) : null,
562 skipWait: a.skipWait,
563 waitMinutes: rt.settings.autopilot.waitMinutes,
564 },
565 agents: {
566 running: rt.agents.list
567 .filter(x => !['completed', 'failed', 'killed'].includes(x.status))
568 .slice(0, 8)
569 .map(x => ({ id: x.id, type: x.type, description: x.description, status: x.status })),
570 spawned: rt.agents.spawned,
571 denied: rt.agents.denied,
572 asked: rt.agents.asked,
573 },
574 router: {
575 lastDecision: rt.router.lastDecision,
576 resolved: [...rt.router.known.entries()].map(([alias, id]) => ({ alias, id })),
577 unavailable: [...rt.router.unavailable],
578 },
579 guard: {
580 turn: rt.guard.turnBlocks,
581 session: rt.guard.sessionBlocks,
582 last: rt.guard.last === null ? null : { verdict: rt.guard.last.verdict, score: rt.guard.last.score, reasons: rt.guard.last.reasons.slice(0, 4), at: rt.guard.last.at },
583 isActive: rt.effective().guard.isActive,
584 reason: rt.effective().guard.reason,
585 },
586 frontier: {
587 lastEffort: rt.frontier.lastEffort,
588 isEffortSupported: rt.frontier.isEffortSupported,
589 isComposeReached: rt.compose.isReached ? true : rt.composeObserved ? false : null,
590 delivery: rt.ledger.frontier(rt.settings.frontier.enabled),
591 },
592 notes: rt.notes,
593 allowRemoved: rt.allowRemoved,
594 savedAt: rt.savedAt,
595 planSource: rt.planSource,
596 nativeOutputStyle: rt.nativeOutputStyle,
597 cache: rt.cache.view(rt.clock()),
598 handoff: handoffOf(rt),
599 firstRun: firstRunOf(rt),
600 }
601}
602
603/**
604 * Whether Start fresh context is offered: a handoff waiting for the person, or one no event will move
605 * on by itself (its turn ended unseen, or its background work ended with no turn left to end). The
606 * press checks the notes and what is in flight first, and says why when it does not start.
607 */
608function canFreshOf(rt: Runtime): boolean {
609 if (!rt.settings.autopilot.enabled || rt.turn.isRunning) return false
610 const a = rt.autopilot
611 if (a.state === 'awaiting') return true
612 // Waiting for nothing it must wait for (a server beside it never holds it): the press finishes it.
613 if (a.state === 'waiting-background') return blockingOf(a.background).length === 0
614 return a.state === 'handoff' && rt.turn.kind !== 'handoff' && rt.turn.kind !== 'retry'
615}
616
617/** A fresh install's first-run card, in the live settings' words; null once dismissed, and for an existing install. */
618function firstRunOf(rt: Runtime): PaneModel['firstRun'] {
619 const o = rt.onboarding
620 if (o === null || o.dismissedAt !== null) return null
621 const s = rt.settings
622 const a = s.autopilot
623 const at = a.thresholdMode === 'percent' ? `${a.thresholdPercent}%` : fmt.tokens(a.thresholdTokens)
624 const asks = PERMISSION_CATEGORIES.filter(c => s.permissions[c] === 'ask').length
625 const denies = PERMISSION_CATEGORIES.filter(c => s.permissions[c] === 'deny').length
626 return {
627 autopilot: a.enabled ? at : null,
628 frontier: s.frontier.enabled ? (FIRST_RUN_EFFORT[s.frontier.effort] ?? 'its policy only') : null,
629 guardrails: `${asks} kinds of action ask you first, ${denies} are denied`,
630 }
631}
632
633const FIRST_RUN_EFFORT: Record<string, string> = { max: 'Maximum effort', xhigh: 'Extra High effort', high: 'High effort', keep: 'its policy only (effort left as is)' }
634
635/** The run's latest handoff, as Context shows it. */
636function handoffOf(rt: Runtime): PaneModel['handoff'] {
637 const h = rt.run?.lastHandoff
638 if (h === undefined || h === null || rt.run === null) return null
639 const index = Chain.currentSession(rt.run)?.index ?? 1
640 return {
641 at: h.at,
642 fromSession: h.fromSession,
643 toSession: h.toSession,
644 via: h.via,
645 health: h.health,
646 continuity: h.continuity,
647 isChecking: h.toSession !== null && h.continuity === null && h.toSession === index,
648 leftRunning: (h.leftRunning ?? []).map(t => ({ id: t.id, description: t.description, isService: t.class === 'persistent', endedAt: t.endedAt, status: t.status })),
649 evidence: [...evidenceRowsOf(rt).values()].filter(e => e.status !== 'rebaselined'),
650 }
651}
652
653export function resourcesOf(rt: Runtime): ResourcesView {
654 const ceilings = rt.effective().resources.ceilings
655 // The newest reading, as the status bar shows it. Pressure (a smoothed
656 // window) exists only under ceilings; readings alone still have values.
657 const latest = rt.monitor.status === 'live' ? rt.monitor.latest() : null
658 return {
659 status: rt.monitor.status,
660 platform: rt.monitor.platform,
661 cpu: latest?.cpu ?? null,
662 ram: latest?.ram ?? null,
663 level: rt.monitor.pressure.level,
664 ceilings: ceilings === null ? null : { cpu: ceilings.cpu, ram: ceilings.ram, maxHeavy: ceilings.maxHeavy },
665 cpuSeries: rt.monitor.cpuSeries(40),
666 ramSeries: rt.monitor.ramSeries(40),
667 background: [...rt.activity.background.values()].slice(0, 10),
668 heavyRunning: rt.activity.runningHeavy().map(i => i.label).slice(0, 5),
669 refused: rt.resourceStats.refused,
670 noticesSent: rt.resourceStats.noticesSent,
671 error: rt.monitor.error,
672 }
673}
674
675function runView(run: Chain.Run, now: number): ChainRunView {
676 const t = Chain.totals(run, now)
677 return {
678 id: run.id,
679 number: run.number,
680 startedAt: run.startedAt,
681 status: run.status,
682 root: shortPath(run.root, 48),
683 sessions: run.sessions.map(s => ({
684 index: s.index,
685 id: s.id,
686 startedAt: s.startedAt,
687 endedAt: s.endedAt,
688 start: s.start,
689 end: s.end,
690 peakTokens: s.peakTokens,
691 window: s.window,
692 costUsd: s.costUsd,
693 turns: s.turns,
694 endNote: s.endNote,
695 transitions: s.transitions.length,
696 })),
697 costUsd: t.costUsd,
698 isCostPartial: t.isCostPartial,
699 turns: t.turns,
700 handoffs: t.handoffs,
701 durationMs: t.durationMs,
702 }
703}
704
705export function chainOf(rt: Runtime): ChainView {
706 const now = Date.now()
707 return {
708 current: rt.run === null ? null : runView(rt.run, now),
709 history: rt.history.slice(0, 8).map(r => runView(r, now)),
710 }
711}
712
713/** Milestones shown around the work under way: a few done, the current one, a few next. */
714const PLAN_WINDOW = { done: 2, shown: 7 }
715
716/** The run's completed milestones whose evidence is not plainly current, by milestone key (features/evidence.ts). */
717export function evidenceRowsOf(rt: Runtime): Map<string, EvidenceRowView> {
718 const rows = new Map<string, EvidenceRowView>()
719 for (const s of rt.evidence().snapshots) {
720 const line = evidenceLine(s)
721 if (line === null) continue
722 rows.set(s.key, { subject: s.subject, status: s.status === 'current' ? 'rebaselined' : s.status, line })
723 }
724 return rows
725}
726
727export function missionOf(rt: Runtime): MissionView {
728 const p = rt.progress()
729 const tasks = rt.plan().tasks
730 const evidence = evidenceRowsOf(rt)
731 let plan: MissionView['plan'] = null
732 if (tasks.length > 0) {
733 const done = tasks.filter(t => t.status === 'completed')
734 const open = tasks.filter(t => t.status !== 'completed')
735 const shownOpen = open.slice(0, Math.max(1, PLAN_WINDOW.shown - Math.min(PLAN_WINDOW.done, done.length)))
736 const shownDone = done.slice(-Math.max(PLAN_WINDOW.done, PLAN_WINDOW.shown - shownOpen.length))
737 plan = {
738 done: p.done,
739 total: p.total,
740 tasks: [...shownDone, ...shownOpen].map(t => ({ subject: t.subject, status: t.status, isCurrent: t === p.current, detail: t.detail, evidence: t.status === 'completed' ? (evidence.get(t.key) ?? null) : null })),
741 earlier: done.length - shownDone.length,
742 later: open.length - shownOpen.length,
743 staleEarlier: done.filter(t => !shownDone.includes(t)).map(t => evidence.get(t.key)).filter((e): e is EvidenceRowView => e !== undefined && e.status === 'stale'),
744 }
745 }
746 const totals = rt.run === null ? null : Chain.totals(rt.run, Date.now())
747 const current = runNowOf(rt, Date.now())
748 return {
749 objective: rt.run?.objective ?? null,
750 plan,
751 now: current.text,
752 nowState: current.state,
753 next: p.next === null ? null : p.next.subject,
754 isWorking: rt.turn.isRunning,
755 session: rt.run === null ? 1 : (Chain.currentSession(rt.run)?.index ?? 1),
756 handoffs: totals?.handoffs ?? 0,
757 }
758}
759
760function fileView(rt: Runtime, f: FileChange): FileChangeView {
761 return {
762 path: f.path,
763 display: shortPath(relativeTo(f.path, rt.root), 56),
764 added: f.added,
765 removed: f.removed,
766 hasDiff: f.hasDiff,
767 edits: f.edits,
768 isCreated: f.isCreated,
769 isDeleted: f.isDeleted,
770 lastAt: f.lastAt,
771 }
772}
773
774export function activityOf(rt: Runtime): ActivityView {
775 const now = Date.now()
776 const files = rt.activity.changeList()
777 const selected = rt.ui.selectedPath === null ? null : (rt.activity.changes.get(rt.ui.selectedPath) ?? null)
778 const ctx = { root: rt.root, handoffFile: rt.settings.autopilot.handoffFile }
779 const group = (path: string) => groupOf(path, ctx)
780 const turnItems = turnItemsOf(rt)
781 const attention = attentionOf(turnItems, now)
782 const runs = rt.activity.validationRuns()
783 const validation = summarize(runs, now)
784 const turn = rt.activity.turn
785 return {
786 summary: rt.activity.summaryLine({ subagentsRunning: rt.runningSubagents() }),
787 turn: turn.index,
788 sessionTools: rt.activity.sessionTools,
789 mission: missionOf(rt),
790 turnSummary: {
791 lines: turnSummaryOf({
792 items: turnItems,
793 changed: files.filter(f => turn.files.has(f.path)),
794 groupOf: group,
795 validation,
796 attention,
797 milestonesDone: Math.max(0, rt.progress().done - rt.turnStartDone),
798 }),
799 isRunning: rt.turn.isRunning,
800 durationMs: turn.startedAt === null ? null : (turn.endedAt ?? now) - turn.startedAt,
801 tools: turn.tools,
802 },
803 timeline: timelineOf(turnItems, turn.startedAt, turn.endedAt ?? now, now),
804 quest: questOf(rt),
805 attention: [
806 // Verification gone stale first: a finished milestone whose files changed since (never called wrong).
807 ...rt.staleEvidence().map(s => ({ id: `stale-${s.key}`, kind: 'stale' as const, title: s.subject, reason: evidenceLine(s), state: null, attempts: 1, durationMs: null, since: s.verifiedAt })),
808 ...attention.slice(0, 12).map(a => ({ id: a.id, kind: a.kind, title: a.title, reason: a.reason, state: a.state, attempts: a.attempts, durationMs: a.durationMs, since: a.since })),
809 ],
810 validation: validation.map(v => ({
811 kind: v.kind,
812 label: v.label,
813 command: v.command,
814 status: v.status,
815 durationMs: v.durationMs,
816 runs: v.runs,
817 failures: v.failures,
818 isRecovered: v.isRecovered,
819 history: runs.filter(r => r.kind === v.kind).map(r => r.status).slice(-12),
820 })),
821 groups: GROUP_ORDER.map(id => ({ id, label: GROUP_LABEL[id], files: files.filter(f => group(f.path) === id).slice(0, 40).map(f => fileView(rt, f)) })).filter(g => g.files.length > 0),
822 showGenerated: rt.ui.showGenerated,
823 items: rt.activity.items
824 .slice(-80)
825 .reverse()
826 .map(i => ({
827 id: i.id,
828 tool: i.tool,
829 kind: i.kind,
830 label: i.label,
831 status: i.status,
832 reason: i.reason,
833 startedAt: i.startedAt,
834 endedAt: i.endedAt,
835 isSubagent: i.agentId !== null,
836 heavy: i.heavy,
837 })),
838 totals: rt.activity.totals(),
839 selectedPath: selected?.path ?? null,
840 selectedHunks: selected?.hunks ?? '',
841 }
842}
843
844const TIMELINE_KIND: Record<string, TimelineKind> = { read: 'read', search: 'read', edit: 'edit', shell: 'run', web: 'web', agent: 'agent' }
845
846/** Where the turn's time went: one span per call of the main conversation, by kind, a check apart from other commands. */
847export function timelineOf(items: readonly ActivityItem[], from: number | null, to: number, now: number): TurnTimelineView | null {
848 if (from === null || items.length === 0) return null
849 return {
850 from,
851 to: Math.max(to, from + 1),
852 spans: items
853 .filter(i => i.kind !== 'task')
854 .map(i => ({
855 kind: i.validation !== null ? 'check' : (TIMELINE_KIND[i.kind] ?? 'other'),
856 start: Math.max(from, i.startedAt),
857 end: i.endedAt ?? now,
858 isFailed: i.status === 'error' || i.status === 'denied' || i.status === 'held',
859 })),
860 }
861}
862
863function questOf(rt: Runtime): QuestView | null {
864 if (rt.settings.answers.style !== 'quest') return null
865 const q = rt.quest
866 const l = levelOf(q.xp)
867 return {
868 level: l.level,
869 xp: q.xp,
870 intoLevel: q.xp - l.floor,
871 levelSpan: l.next - l.floor,
872 runXp: runQuestOf(rt.run?.quest).xp,
873 recent: q.recent.slice(0, 5),
874 achievements: ACHIEVEMENTS.map(a => ({ id: a.id, name: a.name, hint: a.hint, unlockedAt: q.unlocked[a.id] ?? null })),
875 }
876}
877
878export function permissionsOf(rt: Runtime): PermissionsView {
879 return { recent: rt.permissionLog.slice(0, 20), ...rt.permissionCounts }
880}
881
882export function focusOf(rt: Runtime): FocusModel {
883 const f = rt.settings.focus
884 return { isOn: f.enabled, tools: f.tools, results: f.results, diffs: f.diffs, expanded: [...rt.ui.expanded].slice(-200) }
885}
886
887export function spinnerOf(rt: Runtime): SpinnerModel {
888 const f = rt.settings.focus
889 if (!f.enabled || !f.spinner || !rt.turn.isRunning) return { line: null }
890 return { line: rt.activity.summaryLine({ subagentsRunning: rt.runningSubagents() }) }
891}
892hooks/host.ts 197 lines1/**
2 * The Host: Control Room's one door to the engine.
3 *
4 * `register.tsx` builds it from `$` (the only file that spells `$.noun.method`,
5 * as `claude plugin validate` requires); every other module works against
6 * this interface. Members are thin and named for the engine call they make.
7 */
8
9import type {
10 AgentInfo,
11 CommandInfo,
12 CommandRunResult,
13 CommandSpec,
14 FsStat,
15 ModelForkResult,
16 PaneCloseArgs,
17 PaneOpenArgs,
18 ProcessRunResult,
19 ProcessSpawnChunk,
20 ProcessSpawnResult,
21 PromptSubmitResult,
22 RenderSurface,
23 ToolInfo,
24 ToolSpec,
25 SessionCompactResult,
26 SessionUsage,
27 SessionVersion,
28 Settings as EngineSettings,
29 Timer,
30 ToolCallResult,
31 UiOpenResult,
32 UiPane,
33} from 'claude-code'
34
35import type {
36 ActivityView,
37 AgentMemo,
38 AutopilotRecord,
39 CacheMemo,
40 ChainView,
41 FocusModel,
42 HudModel,
43 OpsView,
44 PaneModel,
45 PermissionsView,
46 ResourcesView,
47 SpinnerModel,
48} from '../types'
49import type { ConfigEnv } from './app/formerStore'
50import type { TtlConfig } from './features/cache'
51
52export type SpawnStream = AsyncGenerator<ProcessSpawnChunk, ProcessSpawnResult> & { readonly result: Promise<ProcessSpawnResult> }
53
54export type Host = {
55 pluginRoot: string
56 /** A synchronous clock for tests (a manual one); absent, the system clock. Not an engine call. */
57 time?: () => number
58
59 now(): Promise<number>
60 after(ms: number, fn: () => void): Timer
61 every(ms: number, fn: () => void): Timer
62
63 sessionId(): Promise<string>
64 sessionRoot(): Promise<string>
65 sessionModel(): Promise<string>
66 usage(): Promise<SessionUsage>
67 /** Usage with the local `summary` breakdown (auto-compact threshold); no network. */
68 usageSummary(): Promise<SessionUsage>
69 version(): Promise<SessionVersion>
70 surfaces(): Promise<readonly RenderSurface[]>
71 // No member appends to the transcript: a row appended while Claude works moves when the next turn
72 // rebuilds the conversation, and the prompt cache is lost from there (app/notes.ts). Notes for
73 // Claude travel with a batch of tool results or with a prompt instead.
74 compact(instructions: string): Promise<SessionCompactResult>
75
76 submit(text: string): Promise<PromptSubmitResult>
77 /** Submits the person's own words (a message the Cold Resume Guard held while it compacted): read bare, as theirs. */
78 submitAsUser(text: string): Promise<PromptSubmitResult>
79 /** Puts text back in the prompt box as the person's draft (a message the Cold Resume Guard did not send). */
80 fillPrompt(text: string): Promise<{ isFilled: boolean; refusal?: string }>
81 /** The prompt box's draft as it stands ('' where the session draws none). */
82 readPrompt(): Promise<{ text: string }>
83 /** Runs /clear (an Autopilot handoff's fresh context). */
84 clearContext(): Promise<CommandRunResult>
85 /**
86 * Runs /compact with the handoff's instructions: where `compact` is refused because the session
87 * compacts only inside a turn (headless and SDK sessions). The engine's compaction follows.
88 */
89 compactCommand(instructions: string): Promise<CommandRunResult>
90 registerCommand(spec: CommandSpec): Promise<unknown>
91 listCommands(): Promise<CommandInfo[]>
92
93 listAgents(): Promise<AgentInfo[]>
94 /** The tools the model can call now (to see whether Claude Code offers a task list). */
95 listTools(): Promise<readonly ToolInfo[]>
96 /** Offers the model one of Control Room's own tools; resolves to its full name (`mcp__project-sentinel__<name>`). */
97 registerTool(spec: ToolSpec): Promise<string>
98 stopTask(taskId: string): Promise<ToolCallResult>
99 /** Claude Code's own SendMessage delivery to one of the session's agents (a background agent or a teammate). */
100 sendToAgent(agentId: string, text: string): Promise<{ isDelivered: boolean; reason?: string }>
101 classify(text: string, labels: readonly string[], model?: string): Promise<string | undefined>
102 /** One tool-less completion over the main thread's last request (Keep warm): never added to the transcript. */
103 fork(prompt: string): Promise<ModelForkResult>
104
105 toast(text: string, timeoutMs?: number): void
106 status(text: string | undefined): void
107 open(pane: PaneOpenArgs): Promise<UiOpenResult>
108 close(pane: PaneCloseArgs): Promise<void>
109 panes(): Promise<readonly UiPane[]>
110 /** Brings the top of the Control Room pane into view (after a section change, or "Back to top"). */
111 scrollPaneToTop(): Promise<void>
112 /**
113 * Brings one of the pane's elements to the top of its view, by the key it is drawn under (an
114 * Operations card's `card-ops-agents`). Resolves to why Claude Code did not move it, or null once it did.
115 */
116 scrollPaneTo(key: string): Promise<string | null>
117 /** Moves the pane's focus ring onto one of its fields (the Mission Queue's, after `/cr queue`): why not, or null once it did. */
118 focusPane(key: string): Promise<string | null>
119 /** Asks the person in Claude Code's own question dialog (AskUserQuestion); rejects when dismissed or with nobody to ask. */
120 ask(question: string, options: readonly string[], header?: string): Promise<string>
121 /** What Claude Code's own permission check would decide for a call (its rules and the mode), running nothing. */
122 checkTool(tool: string, input: Record<string, unknown>): Promise<{ decision: 'allow' | 'ask' | 'deny'; reason?: string; rule?: string }>
123 copy(text: string, surface?: RenderSurface): Promise<boolean>
124
125 readText(path: string): Promise<string>
126 stat(path: string, resolve: boolean): Promise<FsStat>
127 exists(path: string): Promise<boolean>
128
129 storeGet(key: string): Promise<unknown>
130 storeSet(key: string, value: unknown): Promise<void>
131 storeDelete(key: string): Promise<void>
132
133 settings(source?: 'policy' | 'user' | 'project' | 'local'): Promise<EngineSettings>
134 /** What sets the main conversation's cache lifetime in Claude Code: its environment variables and the `promptCacheTtl` setting. */
135 cacheTtlConfig(): Promise<TtlConfig>
136 /** `CLAUDE_CONFIG_DIR`, `USERPROFILE` and `HOME`: only to find the store kept under the former name. */
137 configEnv(): Promise<ConfigEnv>
138 /** The status bar Control Room (the former name) last published in this session's `$.state`, or undefined. */
139 formerHud(): Promise<unknown>
140 /** Whether the standby note was shown in this session (`$.state`: kept across reloads, gone at a restart). */
141 isStandbyNoted(): Promise<boolean>
142 noteStandby(): Promise<void>
143
144 /**
145 * Starts the machine-wide CPU and memory sampler for the platform, one program by name with fixed
146 * arguments (no shell), a reading every SAMPLER_EVERY_SEC: `typeperf` on Windows, `top` on macOS.
147 */
148 spawnSampler(platform: 'windows' | 'macos'): SpawnStream
149 /** Windows: `systeminfo /fo csv /nh`, once, for the machine's total physical memory. */
150 systemInfo(): Promise<ProcessRunResult>
151 /** macOS: `sysctl -n kern.memorystatus_level`, the percentage of memory the kernel counts free. */
152 memoryLevel(): Promise<ProcessRunResult>
153 /** Whether the session's folder is in a Git repository (its root), else null. */
154 repoRoot(): Promise<string | null>
155 /** `git status --porcelain=v1 --branch` in the session's folder (read-only; Git runs with repo hooks off). */
156 gitStatus(): Promise<ProcessRunResult>
157 /**
158 * Milestone evidence: `git hash-object --no-filters -- <paths>`, Git's blob id of each file's content,
159 * one per line in order (read-only: nothing is written to the repository; `--no-filters` runs no
160 * filter a repository's configuration names). Only the ids are kept, never the content.
161 */
162 hashFiles(paths: readonly string[]): Promise<ProcessRunResult>
163 /** Milestone evidence: `git rev-parse --verify -q HEAD`, the commit the work tree is on. */
164 gitHead(): Promise<ProcessRunResult>
165
166 publishHud(value: HudModel): Promise<void>
167 publishPane(value: PaneModel): Promise<void>
168 publishResources(value: ResourcesView): Promise<void>
169 publishChain(value: ChainView): Promise<void>
170 publishActivity(value: ActivityView): Promise<void>
171 publishPermissions(value: PermissionsView): Promise<void>
172 publishFocus(value: FocusModel): Promise<void>
173 publishSpinner(value: SpinnerModel): Promise<void>
174 publishOps(value: OpsView): Promise<void>
175
176 /** The handoff in flight, in `$.state`: survives a reload of the plugin, not a restart or /clear. */
177 saveAutopilotRecord(record: AutopilotRecord | null): Promise<void>
178 loadAutopilotRecord(): Promise<AutopilotRecord | null>
179 /** The policy section this context's system prompt carries, in `$.state`: a reload keeps it while the cache is warm. */
180 savePolicyMemo(memo: { sessionId: string; text: string } | null): Promise<void>
181 loadPolicyMemo(): Promise<{ sessionId: string; text: string } | null>
182 /** The prompt cache's last request, in `$.state`: a reload keeps knowing when the cache lapses. */
183 saveCacheMemo(memo: CacheMemo | null): Promise<void>
184 loadCacheMemo(): Promise<CacheMemo | null>
185 /** What this session saw of its agents, in `$.state`: a reload keeps it (gone at a restart, as the agents are). */
186 saveAgentLedger(list: AgentMemo[]): Promise<void>
187 loadAgentLedger(): Promise<AgentMemo[] | null>
188
189 /** Re-runs agent listings (Subagent Control changed what is offered). */
190 invalidateDescribes(): void
191 /** Has the engine ask for a fresh conversation's first-message context blocks again (after Control Room's own /clear). */
192 invalidatePromptContext(): void
193
194 /** One line in Claude Code's debug log only (`claude --debug`), never on screen. Absent in tests. */
195 trace?(text: string): void
196}
197hooks/ui/kit.ts 165 lines1/**
2 * What every view function receives: the surface's element constructors
3 * (from `$.ui.resolve(e)`), the actions its controls call, and the room it
4 * has. Views never touch `$`; actions mutate the Runtime, which republishes.
5 */
6
7import type {
8 BoxProps,
9 ButtonProps,
10 CodeProps,
11 ElementConstructor,
12 InputProps,
13 MarkdownProps,
14 RenderSurface,
15 SelectProps,
16 SvgProps,
17 TextProps,
18} from 'claude-code'
19
20import type { ActivitySub, ControlRoomSettings, QueueTarget, TabId, WatchStrategy } from '../../types'
21
22export type Ui = {
23 Box: ElementConstructor<BoxProps>
24 Text: ElementConstructor<TextProps>
25 Button: ElementConstructor<ButtonProps>
26 Code: ElementConstructor<CodeProps>
27 Markdown: ElementConstructor<MarkdownProps>
28 /** Desktop and VS Code only: their native popup works with pointer and keys alike. */
29 Select?: ElementConstructor<SelectProps>
30 Input?: ElementConstructor<InputProps>
31 /** Remote surfaces only (Desktop, VS Code, mobile). */
32 Svg?: ElementConstructor<SvgProps>
33}
34
35export type Actions = {
36 openPane: () => void
37 closePane: () => void
38 togglePane: () => void
39 setTab: (tab: TabId) => void
40 /** Opens one in-place picker, or closes it when it is the open one; null closes any. */
41 togglePicker: (key: string | null) => void
42 setActivitySub: (sub: ActivitySub) => void
43 toggleGenerated: () => void
44 /** Setup: every change from the profile, or the most telling few. */
45 toggleChanges: () => void
46 /** Brings the section bar back into view. */
47 scrollToTop: () => void
48 update: (change: (draft: ControlRoomSettings) => void) => void
49 applyProfile: (id: string) => void
50 saveProfile: (name: string) => void
51 deleteProfile: (id: string) => void
52 handoff: () => void
53 fresh: () => void
54 snooze: () => void
55 /** A handoff's ways out of a wait: hand off now and leave background work running, stop it first, wait longer, call it off. */
56 handoffNow: () => void
57 stopAndHandoff: (taskId?: string) => void
58 keepWaiting: () => void
59 cancelHandoff: () => void
60 stopTask: (taskId: string) => void
61 selectFile: (path: string | null) => void
62 clearChanges: () => void
63 toggleRow: (id: string) => void
64 toggleFocus: () => void
65 resetSettings: () => void
66 /** The first-run card's "Got it". */
67 dismissOnboarding: () => void
68 copy: (text: string, surface: RenderSurface) => void
69 /** Activity → Operations: opens the panel there (from the status bar's chips and alerts). */
70 /** Opens Activity → Operations, at one of its cards when given (`ops-review`, `ops-budget`, ...). */
71 openOps: (section?: string) => void
72 /** Opens Activity → Agents (the agent band, the agents chip, Operations' line). */
73 openAgents: () => void
74 /** Milestone evidence: accept stale milestones' files as they are now (one by key, or all). */
75 acceptEvidence: (key: string | null) => void
76 /** Opens or closes a row's details (a watcher, a queued item, an agent, a decision). */
77 toggleOpsRow: (id: string) => void
78 // Mission Queue
79 queueAdd: (text: string) => void
80 setQueueTarget: (target: QueueTarget) => void
81 editQueue: (id: string | null) => void
82 queueEdit: (id: string, text: string) => void
83 queueRetarget: (id: string, target: QueueTarget) => void
84 queueMove: (id: string, delta: -1 | 1) => void
85 queueNow: (id: string) => void
86 queueCancel: (id: string) => void
87 // Decision Inbox
88 answer: (id: string, text: string) => void
89 answerNow: (id: string) => void
90 withdraw: (id: string) => void
91 // Watchers
92 watchLabel: (label: string) => void
93 setWatchStrategy: (strategy: WatchStrategy) => void
94 /** Arms a watcher from the form: its label draft, this time ("in 2h", "at 14:00"), its strategy. */
95 watchAdd: (when: string) => void
96 watchPick: (at: number) => void
97 watchWake: (id: string) => void
98 watchCheck: (id: string) => void
99 watchFresh: (id: string) => void
100 watchSnooze: (id: string, minutes: number) => void
101 watchReschedule: (id: string, when: string) => void
102 editWatch: (id: string | null) => void
103 watchPause: (id: string) => void
104 watchResume: (id: string) => void
105 watchDismiss: (id: string) => void
106 watchStrategy: (id: string, strategy: WatchStrategy) => void
107 watchNotes: (id: string) => void
108 suggestTake: () => void
109 suggestAt: (when: string) => void
110 suggestIgnore: () => void
111 // The Cold Resume Guard's held message
112 heldPutBack: () => void
113 heldSend: () => void
114 heldDiscard: () => void
115 // Run Budget
116 budgetOpen: (isOpen: boolean) => void
117 budgetCost: (text: string) => void
118 budgetTime: (text: string) => void
119 budgetHandoffs: (delta: number) => void
120 budgetAtLimit: (atLimit: 'notify' | 'ask' | 'finish') => void
121 budgetClear: () => void
122 budgetApprove: () => void
123 // An ended run's operations, and agents
124 foreignBring: () => void
125 foreignDismiss: () => void
126 agentStop: (id: string) => void
127 editMessage: (id: string | null) => void
128 agentMessage: (id: string, text: string) => void
129}
130
131export type Kit = {
132 ui: Ui
133 actions: Actions
134 /** Cells across the body the tree draws into. */
135 columns: number
136 surface: RenderSurface
137 now: number
138 /** The in-place picker drawn open, by key. */
139 openPicker: string | null
140 /** Where a pane sits: docked beside the transcript, or a short frame above the prompt. */
141 placement?: 'dock' | 'inline'
142}
143
144/**
145 * Picks the surface's element table into a Ui. The engine completes every
146 * table (an element a surface lacks draws as an empty fragment), so what a
147 * surface can really draw is decided by the surface, not by presence.
148 *
149 * The terminal's Select is left out on purpose: its option list cannot be
150 * picked or closed with the pointer, so the terminal draws choices in place.
151 */
152export function uiOf(table: Partial<Ui> & Pick<Ui, 'Box' | 'Text' | 'Button' | 'Code' | 'Markdown'>, surface: RenderSurface): Ui {
153 const isNative = surface === 'desktop' || surface === 'vscode'
154 return {
155 Box: table.Box,
156 Text: table.Text,
157 Button: table.Button,
158 Code: table.Code,
159 Markdown: table.Markdown,
160 Select: isNative ? table.Select : undefined,
161 Input: surface === 'mobile' ? undefined : table.Input,
162 Svg: surface === 'terminal' ? undefined : table.Svg,
163 }
164}
165hooks/ui/hud.tsx 944 lines1/**
2 * The status bar: a mission HUD above the prompt.
3 *
4 * ▄▀▄ ▄▀▄ (Kit, when on)
5 * ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ the top edge
6 * ▸ Rewriting the cache scheduler · step 3 of 8 ▲ Tests failing ◆ Control Room
7 * WORK ●━●━●━◉─○─○─○─○ 3/8 CONTEXT ▇▇▇▇▇▇▇▇▇▇▌▇▇▇ 48% · hands off 70% CACHE ◕ 42m 5H ▂▂▂▂▂▂ 63% · 2h14m
8 *
9 * The headline says what is happening, in words, with a mark for the state
10 * (working, thinking, running a check, waiting for you or for a result,
11 * blocked, handing off, done). On its right: only what needs a look, as
12 * chips, then the Control Room button, drawn as a filled control.
13 *
14 * Under it, the instruments, each its own shape so they cannot be confused:
15 * WORK is a track of milestones (it carries across handoffs), CONTEXT a
16 * filling meter with the handoff point as a notch (it starts over after one),
17 * CACHE a clock face (shown only while it can matter: when you are away, or
18 * after a costly rebuild), and on the right 5H, the plan's five-hour use as
19 * Claude Code reports it (a thin bar, the percentage, the time to its reset;
20 * nothing on an account with no five-hour window). The run's cost lives in
21 * Overview, Context → Run and the Run Budget. While a watcher parks the run,
22 * WATCHER counts down to its wake (on Desktop it takes the Machine cell:
23 * nothing runs, so the machine is not the story).
24 *
25 * Kit, when on, walks a lane of its own: in the terminal it stands on the
26 * HUD's top edge; on Desktop it is a short animated image above the headline.
27 * A handoff that needs the person takes a line of its own above everything.
28 *
29 * Width decides the detail: the richest tier whose instruments all fit,
30 * from named readings with long graphics down to bare graphics, then the
31 * least important reading drops. Desktop draws the marks, the track, the
32 * meter and the clock as SVG.
33 */
34
35import type { RenderElement } from 'claude-code'
36
37import type { HudChip, HudModel, Tone, TrackStop } from '../../types'
38import * as fmt from '../core/format'
39import { desktopLanePx, kitStillSvg } from '../kit.client'
40import type { Kit } from './kit'
41import { clip, isNative } from './primitives'
42import { G, STATE_MARK, STOP_LOOK, clockGlyph, meterCells, scaleTrack, svgClock, svgContextMeter, svgLevel, svgStateIcon, svgThinMeter, svgWorkTrack, toneProps } from './theme'
43
44/** The watcher that parks the run, while it sleeps: its countdown is an instrument of its own. */
45const sleepingWatcher = (hud: HudModel): NonNullable<NonNullable<HudModel['ops']>['watcher']> | null =>
46 hud.ops !== null && hud.ops.isSleeping && hud.ops.watcher !== null && hud.ops.watcher.status === 'armed' ? hud.ops.watcher : null
47
48/**
49 * A run of text in one style. `isTrack` draws the empty part of a graphic in the theme's quietest
50 * gray (`subtle`): dim text varies from terminal to terminal, and a dim block reads as a slab.
51 */
52type Span = { text: string; tone?: Tone; isDim?: boolean; isBold?: boolean; isTrack?: boolean }
53
54/** A graphic drawn as SVG on the remote surfaces, in place of its glyphs: `cells` wide. */
55type Graphic = { source: string; cells: number; alt: string; height: number }
56
57export type Segment = {
58 key: string
59 priority: number
60 side: 'left' | 'right'
61 /** Text before the graphic (or the whole segment). */
62 spans: Span[]
63 graphic?: Graphic
64 /** Text after the graphic. */
65 after?: Span[]
66}
67
68/** Cells between instruments. */
69const GAP = 4
70
71/** The instruments sit under the headline's words, past its mark. */
72const INDENT = 2
73
74/** The pixels a cell of a status-bar graphic takes as SVG. */
75const CELL_PX = 8
76
77const spanWidth = (spans: readonly Span[] | undefined): number => (spans ?? []).reduce((n, p) => n + p.text.length, 0)
78
79export const widthOf = (s: Segment): number => spanWidth(s.spans) + (s.graphic?.cells ?? 0) + spanWidth(s.after)
80
81/** How much the instruments row shows: names, the meter's cells, the track's stops, the long notes. */
82export type Tier = { labels: boolean; meter: number; stops: number; notes: boolean }
83
84const TIERS: readonly Tier[] = [
85 { labels: true, meter: 24, stops: 16, notes: true },
86 { labels: true, meter: 16, stops: 12, notes: true },
87 { labels: true, meter: 12, stops: 10, notes: false },
88 { labels: true, meter: 10, stops: 8, notes: false },
89 { labels: true, meter: 8, stops: 6, notes: false },
90 { labels: false, meter: 8, stops: 6, notes: false },
91 { labels: false, meter: 6, stops: 5, notes: false },
92]
93
94/**
95 * From this wide (terminal columns; Desktop's are wider) the readings carry their names, docked
96 * beside the panel included, so the names do not come and go as readings appear.
97 */
98const NAMED_FROM: Record<'terminal' | 'other', number> = { terminal: 72, other: 64 }
99
100const namedFrom = (surface: Kit['surface']): number => (surface === 'terminal' ? NAMED_FROM.terminal : NAMED_FROM.other)
101
102/** The tiers the instruments may take at a width, richest first: the first whose segments all fit wins. */
103export function tiersOf(columns: number, surface: Kit['surface'] = 'terminal'): readonly Tier[] {
104 return columns >= namedFrom(surface) ? TIERS : TIERS.filter(t => !t.labels)
105}
106
107/** Adjacent spans of one style become one, so a graphic is a few Texts, not one per cell. */
108function merged(spans: readonly Span[]): Span[] {
109 const out: Span[] = []
110 for (const span of spans) {
111 const last = out[out.length - 1]
112 if (last !== undefined && last.tone === span.tone && last.isDim === span.isDim && last.isBold === span.isBold && last.isTrack === span.isTrack) last.text += span.text
113 else out.push({ ...span })
114 }
115 return out
116}
117
118/** The context meter in the terminal: filled segments in its tone, empty ones dim, the handoff point an orange bar. */
119function meterSpans(fraction: number, marker: number | null, tone: Tone, width: number): Span[] {
120 const c = meterCells(fraction, width, marker)
121 return merged(
122 Array.from({ length: c.width }, (_, i): Span => (i === c.marker ? { text: G.notch, tone: 'accent' } : i < c.filled ? { text: G.segFull, tone } : { text: G.segEmpty, isTrack: true })),
123 )
124}
125
126/** The plan's five-hour meter in the terminal: a thin bar, the used part in its tone, the rest the quietest gray. */
127export function thinMeterSpans(fraction: number, tone: Tone, width: number): Span[] {
128 const w = Math.max(2, Math.floor(width))
129 const f = Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0
130 const filled = f > 0 ? Math.max(1, Math.round(f * w)) : 0
131 return merged(Array.from({ length: w }, (_, i): Span => (i < filled ? { text: G.thin, tone } : { text: G.thin, isTrack: true })))
132}
133
134/** The work track in the terminal: ●━●━◉─○, done in the work color, the stretch to come dim. */
135export function trackSpans(stops: readonly TrackStop[]): Span[] {
136 const out: Span[] = []
137 stops.forEach((s, i) => {
138 if (i > 0) out.push(stops[i - 1] === 'done' && s !== 'open' ? { text: G.lineFull, tone: 'info' } : { text: G.lineEmpty, isDim: true })
139 const look = STOP_LOOK[s]
140 out.push({ text: look.glyph, tone: look.tone, isBold: look.isBold, isDim: look.isDim })
141 })
142 return merged(out)
143}
144
145const valueTone = (tone: Tone): Tone | undefined => (tone === 'good' || tone === 'normal' || tone === 'muted' ? undefined : tone)
146
147const label = (tier: Tier, text: string): Span[] => (tier.labels ? [{ text: `${text} `, isDim: true }] : [])
148
149/** The instruments at a tier, in display order, each with its priority for when room runs short. */
150export function hudSegments(hud: HudModel, tier: Tier, surface: Kit['surface'] = 'terminal'): Segment[] {
151 const isSvg = surface !== 'terminal'
152 const segments: Segment[] = []
153
154 // WORK: a track of milestones, as Claude's own task list counts them.
155 const work = hud.work
156 if (work !== null && work.total > 0) {
157 const isDone = work.done === work.total
158 const count: Span = { text: ` ${work.done}/${work.total}`, tone: isDone ? 'good' : undefined, isBold: true }
159 const stops = scaleTrack(work.track.length === work.total ? work.track : fallbackTrack(work.done, work.total, work.current !== null), tier.stops)
160 const alt = `Work: ${work.done} of ${work.total} milestones done`
161 if (isSvg) {
162 const source = svgWorkTrack({ stops, height: 14, pitch: 16 })
163 const px = Math.round(2 * 6.5 + (stops.length - 1) * 16)
164 segments.push({ key: 'work', priority: 1, side: 'left', spans: label(tier, 'WORK'), graphic: { source, cells: Math.ceil(px / CELL_PX), alt, height: 14 }, after: [count] })
165 } else {
166 segments.push({ key: 'work', priority: 1, side: 'left', spans: [...label(tier, 'WORK'), ...trackSpans(stops), count] })
167 }
168 }
169
170 // CONTEXT: a filling meter with the handoff notch; it starts over in each fresh context.
171 const ctx = hud.ctx
172 if (ctx.pct === null || ctx.tokens === null || ctx.window === null || ctx.window <= 0) {
173 segments.push({ key: 'ctx', priority: 0, side: 'left', spans: [...label(tier, 'CONTEXT'), { text: G.none, isDim: true }] })
174 } else {
175 const fraction = ctx.tokens / ctx.window
176 const marker = ctx.threshold === null ? null : ctx.threshold / ctx.window
177 const tone: Tone = ctx.tone === 'muted' || ctx.tone === 'normal' ? 'good' : ctx.tone
178 const pct: Span = { text: ` ${ctx.pct}%`, tone: valueTone(ctx.tone), isBold: true }
179 const notes: string[] = []
180 const event = hud.autopilot.isOn && hud.autopilot.state !== 'armed' && hud.autopilot.state !== 'off' ? hud.autopilot.text : null
181 if (event !== null) notes.push(event)
182 else if (tier.notes && marker !== null && hud.autopilot.isOn) notes.push(`hands off ${Math.round(marker * 100)}%`)
183 if (tier.notes && hud.session.index > 1) notes.push(`session ${hud.session.index}`)
184 const after: Span[] = [pct, ...(notes.length === 0 ? [] : [{ text: ` · ${notes.join(' · ')}`, isDim: event === null, tone: event === null ? undefined : 'info' } as Span])]
185 if (isSvg) {
186 const px = tier.meter * CELL_PX + 24
187 segments.push({ key: 'ctx', priority: 0, side: 'left', spans: label(tier, 'CONTEXT'), graphic: { source: svgContextMeter({ fraction, marker, tone, width: px, height: 14 }), cells: Math.ceil(px / CELL_PX), alt: `Context ${ctx.pct}% used`, height: 14 }, after })
188 } else {
189 segments.push({ key: 'ctx', priority: 0, side: 'left', spans: [...label(tier, 'CONTEXT'), ...meterSpans(fraction, marker, tone, tier.meter), ...after] })
190 }
191 }
192
193 // CACHE: only while it can matter.
194 const cache = hud.cache
195 if (cache !== null && cache.isShown) {
196 const word = cache.parked !== undefined ? (tier.notes ? cache.parked.text : cache.parked.short) : cache.recentMiss !== null ? cache.text : cache.warmth === 'warm' && cache.leftMs !== null && tier.notes ? `${cache.text} left` : cache.text
197 const text: Span = { text: ` ${word}`, tone: cache.tone === 'warn' ? 'warn' : undefined, isDim: cache.tone === 'muted' ? true : undefined }
198 const glyphTone: Tone = cache.tone === 'normal' ? 'info' : cache.tone
199 const alt = cacheAlt(cache)
200 segments.push(
201 isSvg
202 ? { key: 'cache', priority: 2, side: 'left', spans: label(tier, 'CACHE'), graphic: { source: svgClock({ fraction: cache.fraction, tone: glyphTone, size: 14 }), cells: 2, alt, height: 14 }, after: [text] }
203 : { key: 'cache', priority: 2, side: 'left', spans: [...label(tier, 'CACHE'), { text: clockGlyph(cache.fraction), tone: glyphTone }, text] },
204 )
205 }
206
207 // WATCHER: while a watcher parks the run, the time to its wake (the headline says until when, and why).
208 const watcher = sleepingWatcher(hud)
209 if (watcher !== null) {
210 const left: Span = { text: ` ${watcher.left}`, isBold: true }
211 const alt = `Watcher ${watcher.id} wakes the run in ${watcher.left}, at ${watcher.at}`
212 segments.push(
213 isSvg
214 ? { key: 'watcher', priority: 1, side: 'left', spans: label(tier, 'WATCHER'), graphic: { source: svgStateIcon('sleeping', 14), cells: 2, alt, height: 14 }, after: [left] }
215 : { key: 'watcher', priority: 1, side: 'left', spans: [...label(tier, 'WATCHER'), { text: G.wait, tone: 'info' }, left] },
216 )
217 }
218
219 // 5H: the plan's five-hour window, on the right; nothing where the account has none.
220 const plan = planSegment(hud.plan.five, tier, isSvg)
221 if (plan !== null) segments.push(plan)
222 return segments
223}
224
225/**
226 * Every plan window in a line, for /cr status and Context → Run: "5-hour 63%, resets 14:20 (in 2h 14m)
227 * · 7-day 18%, resets Oct 14 23:00". As Claude Code reports them; nothing is estimated.
228 */
229export function planDetail(plan: HudModel['plan'], now: number): string {
230 const five = plan.five
231 if (five.kind === 'absent') return 'No five-hour window on this account (an API key, or a plan without one)'
232 if (five.kind === 'pending') return 'No reading yet: it comes with the first response'
233 const resets = (at: number | null, leftMs: number | null) => (at === null ? '' : `, resets ${fmt.when(at, now)}${leftMs === null ? '' : ` (in ${fmt.countdown(leftMs)})`}`)
234 const parts = plan.windows.map(w => (w.isReset ? `${w.name} reset at ${fmt.when(w.resetsAt, now)}, no reading of the new window yet` : `${w.name} ${fmt.percent(w.percent)}${resets(w.resetsAt, w.leftMs)}`))
235 if (five.kind === 'limited') {
236 const which = five.window === 'seven_day' ? '7-day limit reached' : five.window === null ? 'A request was refused on the rate limit' : '5-hour limit reached'
237 return [`${which}${resets(five.resetsAt, five.leftMs)}`, ...parts.filter(p => !(five.window === 'five_hour' && p.startsWith('5-hour')))].join(' · ')
238 }
239 return parts.join(' · ')
240}
241
242/**
243 * The five-hour window in a few words, as the status line and the terminal's status bar say it:
244 * "5H 63% · 2h14m", "5H reset · new window", "5H limit · 2h14m", "5H —" before a reading; nothing
245 * where the account has no five-hour window. `isShort` drops the reset's second half.
246 */
247export function planWords(five: HudModel['plan']['five'], isShort: boolean): string | null {
248 switch (five.kind) {
249 case 'absent':
250 return null
251 case 'pending':
252 return '5H —'
253 case 'reset':
254 return isShort ? '5H reset' : '5H reset · new window'
255 case 'reading':
256 return `5H ${fmt.percent(five.percent)}${five.leftMs === null ? '' : ` · ${fmt.countdown(five.leftMs, true)}`}`
257 case 'limited': {
258 const left = five.leftMs === null ? '' : ` · ${fmt.countdown(five.leftMs, true)}`
259 return five.window === 'seven_day' ? `7D limit${left}` : five.window === null ? `Rate limited${left}` : `5H limit${left}`
260 }
261 }
262}
263
264/** The cells of the five-hour meter: a short bar where the readings carry their names, none in the leanest rows. */
265const PLAN_METER = 6
266
267/** What the five-hour reading says to a screen reader (Desktop's image, the plain text). */
268export function planAlt(five: HudModel['plan']['five']): string {
269 switch (five.kind) {
270 case 'pending':
271 return 'Five-hour plan usage: no reading yet'
272 case 'absent':
273 return 'No five-hour plan limit on this account'
274 case 'reset':
275 return 'Five-hour plan window reset; no reading of the new window yet'
276 case 'reading':
277 return `Five-hour plan usage ${fmt.percent(five.percent)}${five.leftMs === null ? '' : `, resets in ${fmt.countdown(five.leftMs)}`}`
278 case 'limited':
279 return `${five.window === 'seven_day' ? 'Seven-day plan limit' : five.window === null ? 'Rate limit' : 'Five-hour plan limit'} reached${five.leftMs === null ? '' : `, resets in ${fmt.countdown(five.leftMs)}`}`
280 }
281}
282
283/**
284 * The status bar's 5H reading: "5H ▂▂▂▂▂▂ 63% · 2h14m" (the bar only where the readings carry their
285 * names), "5H reset · new window" once the window has reset and no response has read the new one,
286 * "5H limit · 2h14m" in red at the limit (the one reading that never drops for room), "5H —" before
287 * the first response. Null on an account with no five-hour window.
288 */
289function planSegment(five: HudModel['plan']['five'], tier: Tier, isSvg: boolean): Segment | null {
290 const name: Span = { text: '5H ', isDim: true }
291 const left = (ms: number | null, tone?: Tone): Span[] => (ms === null ? [] : [{ text: ` · ${fmt.countdown(ms, true)}`, isDim: tone === undefined, tone }])
292 switch (five.kind) {
293 case 'absent':
294 return null
295 case 'pending':
296 return { key: 'plan', priority: 1, side: 'right', spans: [name, { text: G.none, isDim: true }] }
297 case 'reset':
298 return { key: 'plan', priority: 1, side: 'right', spans: [name, { text: tier.notes ? 'reset · new window' : 'reset', isDim: true }] }
299 case 'limited': {
300 const word: Span = { text: five.window === 'seven_day' ? '7D limit' : five.window === null ? 'Rate limited' : 'limit', tone: 'bad', isBold: true }
301 return { key: 'plan', priority: 0, side: 'right', spans: [...(five.window === 'five_hour' ? [name] : []), word, ...left(five.leftMs, 'bad')] }
302 }
303 case 'reading': {
304 const tone: Tone = five.isHigh ? 'warn' : 'info'
305 const value: Span = { text: ` ${fmt.percent(five.percent)}`, tone: five.isHigh ? 'warn' : undefined, isBold: true }
306 if (!tier.labels) return { key: 'plan', priority: 1, side: 'right', spans: [{ text: '5H', isDim: true }, value, ...left(five.leftMs)] }
307 const fraction = five.percent / 100
308 if (isSvg) {
309 const px = PLAN_METER * CELL_PX
310 return { key: 'plan', priority: 1, side: 'right', spans: [name], graphic: { source: svgThinMeter({ fraction, tone, width: px, height: 14 }), cells: PLAN_METER, alt: planAlt(five), height: 14 }, after: [value, ...left(five.leftMs)] }
311 }
312 return { key: 'plan', priority: 1, side: 'right', spans: [name, ...thinMeterSpans(fraction, tone, PLAN_METER), value, ...left(five.leftMs)] }
313 }
314 }
315}
316
317/** A track from counts alone (a stored plan without statuses): done, the one under way, the rest. */
318function fallbackTrack(done: number, total: number, hasCurrent: boolean): TrackStop[] {
319 return Array.from({ length: total }, (_, i) => (i < done ? 'done' : i === done && hasCurrent ? 'now' : 'open'))
320}
321
322/** Keeps the highest-priority segments that fit `width` (gaps included), in display order. */
323export function fitSegments(segments: readonly Segment[], width: number, gap = GAP): Segment[] {
324 const kept = [...segments]
325 const total = (list: readonly Segment[]) => list.reduce((sum, s) => sum + widthOf(s), 0) + Math.max(0, list.length - 1) * gap
326 while (kept.length > 1 && total(kept) > width) {
327 let worst = 0
328 for (let i = 1; i < kept.length; i++) if (kept[i]!.priority >= kept[worst]!.priority) worst = i
329 kept.splice(worst, 1)
330 }
331 return kept
332}
333
334function spanEl(kit: Kit, key: string, span: Span): RenderElement {
335 const { Text } = kit.ui
336 const props = span.isTrack === true ? { color: 'subtle' as const } : span.tone === undefined ? {} : toneProps(span.tone)
337 return (
338 <Text key={key} {...props} dimColor={span.isDim === true ? true : undefined} bold={span.isBold === true ? true : undefined}>
339 {span.text}
340 </Text>
341 )
342}
343
344function segmentEl(kit: Kit, s: Segment): RenderElement {
345 const { Box, Text, Svg } = kit.ui
346 const text = (spans: readonly Span[] | undefined, part: string) =>
347 spans === undefined || spans.length === 0 ? null : <Text key={`seg-${s.key}-${part}`}>{spans.map((p, i) => spanEl(kit, `seg-${s.key}-${part}-${i}`, p))}</Text>
348 if (s.graphic === undefined || Svg === undefined) {
349 return (
350 <Text key={`seg-${s.key}`} wrap="truncate-end">
351 {s.spans.map((p, i) => spanEl(kit, `seg-${s.key}-${i}`, p))}
352 </Text>
353 )
354 }
355 return (
356 <Box key={`seg-${s.key}`} flexDirection="row" alignItems="center" flexShrink={0} columnGap={1}>
357 {text(s.spans, 'before')}
358 <Svg key={`seg-${s.key}-svg`} source={s.graphic.source} alt={s.graphic.alt} height={s.graphic.height} />
359 {text(s.after?.map((p, i) => (i === 0 ? { ...p, text: p.text.replace(/^ /, '') } : p)), 'after')}
360 </Box>
361 )
362}
363
364// ---------------------------------------------------------------------------
365// The headline row
366
367/** The Control Room button's words: its name with the brand mark, or a short verb in a narrow bar. */
368const buttonLabel = (hud: HudModel, columns: number): string => (columns >= 64 ? `${G.brand} Control Room` : `${G.brand} ${hud.isPaneOpen ? 'Close' : 'Open'}`)
369
370/**
371 * The Control Room button. Desktop draws its native primary button, the panel
372 * open or closed: it is the bar's one control, and the secondary button, gray
373 * on the bar's own gray, read as a tag. The terminal draws a filled chip,
374 * brighter under the pointer and in the brand color while the panel is open,
375 * so it reads as a control and not as more text.
376 */
377function controlButton(kit: Kit, hud: HudModel): RenderElement {
378 const { Box, Button } = kit.ui
379 const text = buttonLabel(hud, kit.columns)
380 if (isNative(kit)) return <Button key="open" label={text} variant="primary" onPress={kit.actions.togglePane} />
381 return (
382 <Box key="open-chip" flexShrink={0} paddingX={1} backgroundColor={hud.isPaneOpen ? 'claude' : 'subtle'} hover={{ backgroundColor: hud.isPaneOpen ? 'claude' : 'inactive' }}>
383 <Button key="open" label={text} plain onPress={kit.actions.togglePane} />
384 </Box>
385 )
386}
387
388/** The chips that fit beside the headline: the most pressing first. */
389function chipsThatFit(chips: readonly HudChip[], room: number): HudChip[] {
390 const out: HudChip[] = []
391 let used = 0
392 for (const c of chips) {
393 const w = c.text.length + 2 + (out.length > 0 ? 2 : 0)
394 if (used + w > room) break
395 out.push(c)
396 used += w
397 }
398 return out
399}
400
401/** The Operations card a chip opens at: pressed, Review 2 shows the decisions, not the top of Operations. */
402const CHIP_SECTION: Record<string, string> = { review: 'ops-review', queued: 'ops-queue', watcher: 'ops-watchers', budget: 'ops-budget' }
403
404/**
405 * A chip: its mark in its tone, then its words. One that `opens` is a control: pressed, it opens
406 * Activity → Operations (Review, a watcher's countdown, queued work, agents, the budget). Its words
407 * are the Button's label and its mark sits beside it: Claude Code 2.1.289 takes a Button's label
408 * only, and refuses one with Text children by skipping the whole status bar.
409 */
410function chipEl(kit: Kit, chip: HudChip): RenderElement {
411 const { Box, Text, Button } = kit.ui
412 const mark = chip.key === 'failing' ? G.fail : chip.tone === 'bad' || chip.tone === 'warn' ? G.warn : chip.key === 'quest' ? G.star : chip.key === 'watcher' ? G.wait : G.dot
413 const markEl = <Text key={`chip-${chip.key}-mark`} {...toneProps(chip.tone)}>{`${mark} `}</Text>
414 if (chip.opens !== undefined) {
415 const onPress = chip.opens === 'agents' ? () => kit.actions.openAgents() : () => kit.actions.openOps(CHIP_SECTION[chip.key])
416 return (
417 <Box key={`chip-${chip.key}-box`} flexDirection="row" flexShrink={0}>
418 {markEl}
419 <Button key={`chip-${chip.key}`} label={chip.text} plain onPress={onPress} />
420 </Box>
421 )
422 }
423 return (
424 <Text key={`chip-${chip.key}`} wrap="truncate-end">
425 {markEl}
426 <Text key={`chip-${chip.key}-text`} {...toneProps(chip.tone === 'info' || chip.tone === 'accent' ? 'normal' : chip.tone)} bold={chip.tone === 'bad' ? true : undefined}>
427 {chip.text}
428 </Text>
429 </Text>
430 )
431}
432
433/** The headline: the state's mark, what is happening, its detail; then the chips and the button on the right. */
434function headlineRow(kit: Kit, hud: HudModel): RenderElement {
435 const { Box, Text, Svg } = kit.ui
436 // Not `h`: that name is the JSX factory's in this module.
437 const line = hud.headline
438 const mark = STATE_MARK[line.state]
439 const isStrong = line.state === 'working' || line.state === 'validating' || line.state === 'handoff' || line.state === 'waitingUser' || line.state === 'waitingExternal' || line.state === 'blocked'
440 const isQuiet = line.state === 'ready' || line.state === 'thinking'
441 const buttonWidth = buttonLabel(hud, kit.columns).length + (isNative(kit) ? 6 : 4)
442 // On Desktop the Machine cell carries CPU and memory, in their tones; the headline does not repeat them.
443 const shown = isNative(kit) ? hud.chips.filter(c => c.key !== 'cpu' && c.key !== 'ram') : hud.chips
444 const chips = chipsThatFit(shown, Math.max(0, Math.floor((kit.columns - buttonWidth) * 0.45)))
445 return (
446 <Box key="hud-head" flexDirection="row" alignItems="center" columnGap={3}>
447 <Box flexDirection="row" flexGrow={1} flexShrink={1} alignItems="center" columnGap={Svg !== undefined ? 1 : 0} {...clip(kit)}>
448 {/* The terminal's mark takes the indent the instruments line up under; an icon takes its own size. */}
449 <Box width={Svg !== undefined ? undefined : INDENT} flexShrink={0}>
450 {Svg !== undefined ? <Svg key="mark" source={svgStateIcon(line.state)} alt={line.state} width={16} height={16} /> : <Text {...toneProps(mark.tone)}>{mark.glyph}</Text>}
451 </Box>
452 <Box flexShrink={1} {...clip(kit)}>
453 <Text wrap="truncate-end">
454 <Text bold={isStrong ? true : undefined} dimColor={isQuiet ? true : undefined}>
455 {line.text}
456 </Text>
457 {line.detail === null || line.detail === '' ? null : <Text dimColor>{` · ${line.detail}`}</Text>}
458 </Text>
459 </Box>
460 </Box>
461 {chips.length === 0 ? null : (
462 <Box key="hud-chips" flexDirection="row" columnGap={2} flexShrink={0} alignItems="center">
463 {chips.map(c => chipEl(kit, c))}
464 </Box>
465 )}
466 <Box flexShrink={0}>{controlButton(kit, hud)}</Box>
467 </Box>
468 )
469}
470
471/** The instruments: Work, Context and Cache on the left, the plan's five-hour use on the right. */
472function instrumentsRow(kit: Kit, hud: HudModel): RenderElement {
473 const { Box } = kit.ui
474 const room = Math.max(10, kit.columns - INDENT)
475 // The richest tier that shows every reading; failing that, the leanest one, the least important readings dropped.
476 let kept: Segment[] = []
477 for (const tier of tiersOf(kit.columns, kit.surface)) {
478 const all = hudSegments(hud, tier, kit.surface)
479 kept = fitSegments(all, room)
480 if (kept.length === all.length) break
481 }
482 const left = kept.filter(s => s.side === 'left')
483 const right = kept.filter(s => s.side === 'right')
484 return (
485 <Box flexDirection="row" key="hud-line" alignItems="center" marginLeft={INDENT}>
486 <Box flexDirection="row" columnGap={GAP} flexShrink={0} alignItems="center">
487 {left.map(s => segmentEl(kit, s))}
488 </Box>
489 <Box key="hud-gap" flexGrow={1} flexShrink={1} />
490 {right.length === 0 ? null : (
491 <Box flexDirection="row" columnGap={GAP} flexShrink={0} marginLeft={GAP} alignItems="center">
492 {right.map(s => segmentEl(kit, s))}
493 </Box>
494 )}
495 </Box>
496 )
497}
498
499type AlertAction = { key: string; label: string; onPress: () => void; isPrimary?: boolean }
500
501/** What the alert line offers: the handoff's, a watcher that waits for the person, a held message, the budget, a suggestion. */
502export function alertActions(kit: Kit, alert: NonNullable<HudModel['alert']>): AlertAction[] {
503 const a = kit.actions
504 const ref = alert.ref ?? ''
505 switch (alert.kind) {
506 case 'pending':
507 return [
508 { key: 'handoff-now', label: 'Hand off now', onPress: a.handoff, isPrimary: true },
509 { key: 'snooze', label: 'Later', onPress: a.snooze },
510 ]
511 case 'awaiting':
512 return [
513 { key: 'fresh', label: 'Start fresh', onPress: a.fresh, isPrimary: true },
514 { key: 'snooze', label: 'Later', onPress: a.snooze },
515 ]
516 // A written handoff waiting for background work: the ways out, never only words.
517 case 'waiting':
518 return [
519 { key: 'handoff-go', label: 'Hand off now', onPress: a.handoffNow, isPrimary: true },
520 ...(alert.canStop === true ? [{ key: 'handoff-stop', label: 'Stop & hand off', onPress: () => a.stopAndHandoff() }] : []),
521 ...(alert.canKeep === true ? [{ key: 'handoff-wait', label: 'Keep waiting', onPress: a.keepWaiting }] : []),
522 { key: 'handoff-cancel', label: 'Call off', onPress: a.cancelHandoff },
523 ]
524 case 'stuck': {
525 const offers = alert.offers ?? []
526 const out: AlertAction[] = []
527 if (offers.includes('start-fresh')) out.push({ key: 'stuck-fresh', label: 'Start fresh', onPress: a.fresh, isPrimary: true })
528 if (offers.includes('handoff-now')) out.push({ key: 'stuck-now', label: 'Hand off now', onPress: a.handoffNow, isPrimary: out.length === 0 })
529 if (offers.includes('hand-off')) out.push({ key: 'stuck-again', label: 'Hand off again', onPress: a.handoff, isPrimary: out.length === 0 })
530 if (offers.includes('cancel')) out.push({ key: 'stuck-cancel', label: 'Call off', onPress: a.cancelHandoff })
531 return out
532 }
533 case 'watcher':
534 return [
535 { key: 'watch-check', label: 'Check now', onPress: () => a.watchCheck(ref), isPrimary: true },
536 ...(alert.canFresh === true ? [{ key: 'watch-fresh', label: 'Start fresh', onPress: () => a.watchFresh(ref) }] : []),
537 { key: 'watch-snooze', label: 'In 30m', onPress: () => a.watchSnooze(ref, 30) },
538 { key: 'watch-dismiss', label: 'Dismiss', onPress: () => a.watchDismiss(ref) },
539 ]
540 case 'held':
541 return [
542 { key: 'held-back', label: 'Put back', onPress: a.heldPutBack, isPrimary: true },
543 { key: 'held-send', label: 'Send now', onPress: a.heldSend },
544 { key: 'held-discard', label: 'Discard', onPress: a.heldDiscard },
545 ]
546 case 'budget':
547 return [
548 { key: 'budget-go', label: 'Continue anyway', onPress: a.budgetApprove, isPrimary: true },
549 { key: 'budget-open', label: `Budget ${G.chevron}`, onPress: () => a.openOps('ops-budget') },
550 ]
551 case 'suggest':
552 return [
553 alert.hasTime === true
554 ? { key: 'suggest-take', label: 'Create watcher', onPress: a.suggestTake, isPrimary: true }
555 : { key: 'suggest-1h', label: 'In 1h', onPress: () => a.suggestAt('in 60m'), isPrimary: true },
556 { key: 'suggest-change', label: 'Change time', onPress: () => a.openOps('ops-watchers') },
557 { key: 'suggest-ignore', label: 'Ignore', onPress: a.suggestIgnore },
558 ]
559 case 'load':
560 return []
561 }
562}
563
564const ALERT_MARK: Record<NonNullable<HudModel['alert']>['kind'], string> = { pending: G.warn, awaiting: G.dot, waiting: G.wait, stuck: G.warn, load: G.warn, watcher: G.wait, held: G.warn, budget: G.warn, suggest: G.wait }
565
566/** What needs the person now, on a line of its own above, with its actions. */
567function alertLine(kit: Kit, alert: NonNullable<HudModel['alert']>): RenderElement {
568 const { Box, Text, Button } = kit.ui
569 const actions = alertActions(kit, alert)
570 return (
571 <Box flexDirection="row" key="hud-alert" alignItems="center" columnGap={2}>
572 <Box flexDirection="row" flexGrow={1} flexShrink={1} {...clip(kit)}>
573 <Box width={INDENT} flexShrink={0}>
574 <Text {...toneProps(alert.tone)}>{ALERT_MARK[alert.kind]}</Text>
575 </Box>
576 <Box flexShrink={1} {...clip(kit)}>
577 <Text wrap="truncate-end" bold>
578 {alert.text}
579 </Text>
580 </Box>
581 </Box>
582 <Box flexDirection="row" columnGap={1} flexShrink={0}>
583 {actions.map(a => (
584 <Button key={a.key} label={a.label} variant={a.isPrimary === true ? 'primary' : 'secondary'} onPress={a.onPress} />
585 ))}
586 </Box>
587 </Box>
588 )
589}
590
591/**
592 * The agent band, above everything else and only while subagents run: one line per agent (what it is
593 * doing, for how long, its plan's count when it keeps one), or past three one line counting them by what
594 * they do. Each line is a control: pressed, it opens Activity → Agents. Gone when no agent runs.
595 */
596function agentBand(kit: Kit, hud: HudModel): RenderElement | null {
597 const band = hud.agentBand
598 if (band === null) return null
599 const { Box, Text, Button } = kit.ui
600 const lineOf = (key: string, label: string, isStarting: boolean) => (
601 <Box key={`band-${key}`} flexDirection="row" alignItems="center" {...clip(kit)}>
602 <Box width={INDENT} flexShrink={0}>
603 <Text {...toneProps('info')}>{isStarting ? G.ring : G.run}</Text>
604 </Box>
605 <Box flexShrink={1} {...clip(kit)}>
606 <Button key={`band-${key}-open`} label={label} plain onPress={kit.actions.openAgents} />
607 </Box>
608 </Box>
609 )
610 const rows =
611 band.summary !== null
612 ? [lineOf('all', `${band.summary} ${G.chevron}`, false)]
613 : band.lines.map(l => lineOf(l.id, [l.name, l.doing, l.plan === null ? null : `plan ${l.plan}`, l.elapsedMs === null ? null : fmt.duration(l.elapsedMs)].filter((p): p is string => p !== null && p !== '').join(' · '), l.status === 'starting' || l.status === 'pending'))
614 return (
615 <Box key="hud-agents" flexDirection="column">
616 {rows}
617 </Box>
618 )
619}
620
621/**
622 * Kit's lane, only while the companion is on: its surface module (made by the hooks module,
623 * which alone may name it) in the terminal and on Desktop, where Kit lives on the surface's own
624 * clock; where a surface draws no surface module (VS Code), Kit held still in its mood's pose.
625 */
626function laneRow(kit: Kit, hud: HudModel, stage: RenderElement | null): RenderElement | null {
627 const { Box, Svg } = kit.ui
628 const companion = hud.companion
629 if (companion === null) return null
630 if (stage !== null) {
631 return (
632 <Box key="hud-lane" flexDirection="row" height={kit.surface === 'terminal' ? 5 : undefined}>
633 {stage}
634 </Box>
635 )
636 }
637 if (kit.surface === 'terminal' || Svg === undefined) return null
638 // An image of a fixed size: never boxed in a frame the surface sizes and paints on its own.
639 const width = desktopLanePx(kit.columns)
640 const still = kitStillSvg(companion, width)
641 return (
642 <Box key="hud-lane" flexDirection="row">
643 <Svg key="companion" source={still.source} alt={companion.caption} width={still.width} height={still.height} />
644 </Box>
645 )
646}
647
648/** The HUD's top edge in the terminal: a line right under Kit's feet when it is on, a quiet rule otherwise. */
649function edgeRow(kit: Kit, hud: HudModel, hasLane: boolean): RenderElement | null {
650 const { Text } = kit.ui
651 if (kit.surface !== 'terminal') return null
652 return (
653 <Text key="hud-edge" color="subtle" wrap="truncate-end">
654 {(hasLane ? G.edge : G.lineEmpty).repeat(Math.max(4, kit.columns))}
655 </Text>
656 )
657}
658
659// ---------------------------------------------------------------------------
660// Desktop and mobile: the same readings laid out by the surface, not by counted cells.
661//
662// (▸) Running tests · step 8 of 10 ▲ 1 issue [◆ Control Room]
663// Work Context · hands off at 80% Cache Machine 5-hour usage
664// ●━●━◉─○─○ 2 of 10 ▬▬▬▬▬▬▬▬┃▬▬▬ 24% ◔ warm · 345k CPU ▮ 34% RAM ▮ 85% ▂▂▂ 63% · 2h14m
665//
666// Each reading is a column weighted by what it holds (Work and Context wider, Cache and Machine
667// narrower; the plan's five-hour use takes what it needs at the right edge), on a zero basis, so the columns
668// never drift as values change and no cell is left with a hole. Every reading keeps its column, a
669// quiet `—` standing in for one with nothing yet. Width decides the detail: three tiers, each sized
670// so every cell fits (from about 500 pixels to a full window). Every graphic is an image of a fixed
671// size (never a sandboxed frame, which Desktop sizes on its own and may paint opaque).
672
673/** One part of a cell's value line: an optional dim label, an optional graphic, and its value. */
674export type CellPart = { key: string; label?: Span[]; graphic?: { source: string; alt: string; width: number; height: number }; value: Span[] }
675
676/** One instrument on the remote surfaces: its caption over its parts, its column's weight. */
677export type Cell = { key: string; caption: Span[]; parts: CellPart[]; weight: number; isEnd?: boolean }
678
679/** How much a cell row shows at a width: compact below 80 columns, wide from 110. */
680export type CellTier = 'compact' | 'medium' | 'wide'
681
682export const cellTierOf = (columns: number): CellTier => (columns < 80 ? 'compact' : columns < 110 ? 'medium' : 'wide')
683
684/** The columns' weights: Work and Context hold graphics and words, Cache and Machine a mark and a value. */
685const WEIGHT = { work: 3, ctx: 3, cache: 2, machine: 2 } as const
686
687/** The work track's stop pitch in pixels at each tier. */
688const PITCH: Record<CellTier, number> = { compact: 12, medium: 14, wide: 16 }
689
690/** CSS pixels per column, on the low side (Desktop's text is about 8): graphics sized by it always fit their column. */
691const PX_PER_COLUMN = 7.5
692
693/**
694 * Columns the row spends outside the weighted cells: four gaps of three, and the five-hour reading at
695 * the right edge (its thin bar and "63% · 2h14m"; "63%" alone in the compact tier).
696 */
697const ROW_OVERHEAD: Record<CellTier, number> = { compact: 21, medium: 30, wide: 30 }
698
699/** The five-hour bar's width in pixels at each tier. */
700const PLAN_PX: Record<CellTier, number> = { compact: 24, medium: 32, wide: 40 }
701
702/**
703 * The graphics' sizes for a width: each fills its column (its weight's share of the row) less the
704 * room its value needs, so a wide window has no hole inside a cell and a narrow one never overflows.
705 * They depend on the width alone, never on the values, so nothing drifts as readings change.
706 */
707export function graphicsFor(columns: number, tier: CellTier): { stops: number; pitch: number; meter: number } {
708 const share = Math.max(0, columns - ROW_OVERHEAD[tier]) / (WEIGHT.work + WEIGHT.ctx + WEIGHT.cache + WEIGHT.machine)
709 const pitch = PITCH[tier]
710 // Work: the track, a gap and "12 of 16" (or "2/10"); Context: the meter, a gap and "100%".
711 const workPx = WEIGHT.work * share * PX_PER_COLUMN - (tier === 'wide' ? 10 : 6) * PX_PER_COLUMN
712 const ctxPx = WEIGHT.ctx * share * PX_PER_COLUMN - 5 * PX_PER_COLUMN
713 const stops = Math.max(5, Math.min(16, Math.floor((workPx - 13) / pitch) + 1))
714 return { stops, pitch, meter: Math.round(Math.max(56, Math.min(280, ctxPx))) }
715}
716
717/** What the cache's clock says to a screen reader. */
718function cacheAlt(cache: NonNullable<HudModel['cache']>): string {
719 if (cache.parked !== undefined) return `Prompt cache ${cache.parked.text} while the watcher parks the run`
720 return cache.warmth === 'warm' ? `Prompt cache warm${cache.leftMs === null ? '' : `, about ${cache.text} left`}` : `Prompt cache ${cache.text}`
721}
722
723/** The prompt cache's words in its cell: what it holds while warm, its time left near the expiry or while you are away, what became of it. */
724function cacheWord(cache: NonNullable<HudModel['cache']>, tier: CellTier): string {
725 if (cache.parked !== undefined) {
726 if (tier === 'compact') return cache.parked.short
727 return tier === 'wide' && cache.cachedTokens > 0 ? `${cache.parked.text} · ${fmt.tokens(cache.cachedTokens)}` : cache.parked.text
728 }
729 if (cache.recentMiss !== null || cache.warmth !== 'warm') return cache.text
730 // A turn's requests keep the cache warm; its time left matters only near the expiry (a long call).
731 const left = cache.leftMs === null || (cache.isInUse && cache.tone !== 'warn') ? null : cache.text
732 if (tier === 'compact') return left ?? 'warm'
733 const state = left === null ? 'warm' : `${left} left`
734 return tier === 'wide' && cache.cachedTokens > 0 ? `${state} · ${fmt.tokens(cache.cachedTokens)}` : state
735}
736
737const none = (text: string = G.none): Span[] => [{ text, isDim: true }]
738
739/** The instruments as cells for a width (its tier decides the words, the width the graphics' sizes). */
740export function hudCells(hud: HudModel, columns: number): Cell[] {
741 const tier = cellTierOf(columns)
742 const size = graphicsFor(columns, tier)
743 const cells: Cell[] = []
744 const caption = (text: string): Span[] => [{ text, isDim: true }]
745
746 const work = hud.work
747 if (work === null || work.total === 0) {
748 cells.push({ key: 'work', caption: caption('Work'), parts: [{ key: 'v', value: none(tier === 'compact' ? 'None' : 'No milestones yet') }], weight: WEIGHT.work })
749 } else {
750 const isDone = work.done === work.total
751 const stops = scaleTrack(work.track.length === work.total ? work.track : fallbackTrack(work.done, work.total, work.current !== null), size.stops)
752 const height = 14
753 const source = svgWorkTrack({ stops, height, pitch: size.pitch })
754 const width = Math.round(2 * (Math.max(3, height / 2 - 2) + 1.5) + (stops.length - 1) * size.pitch)
755 cells.push({
756 key: 'work',
757 caption: caption('Work'),
758 parts: [{ key: 'v', graphic: { source, alt: `Work: ${work.done} of ${work.total} milestones done`, width, height }, value: [{ text: tier === 'wide' ? `${work.done} of ${work.total}` : `${work.done}/${work.total}`, tone: isDone ? 'good' : undefined, isBold: true }] }],
759 weight: WEIGHT.work,
760 })
761 }
762
763 const ctx = hud.ctx
764 const event = hud.autopilot.isOn && hud.autopilot.state !== 'armed' && hud.autopilot.state !== 'off' ? hud.autopilot.text : null
765 if (ctx.pct === null || ctx.tokens === null || ctx.window === null || ctx.window <= 0) {
766 cells.push({ key: 'ctx', caption: caption('Context'), parts: [{ key: 'v', value: none() }], weight: WEIGHT.ctx })
767 } else {
768 const fraction = ctx.tokens / ctx.window
769 const marker = ctx.threshold === null ? null : ctx.threshold / ctx.window
770 const tone: Tone = ctx.tone === 'muted' || ctx.tone === 'normal' ? 'good' : ctx.tone
771 // The handoff point is the meter's notch; the wide tier also says it in words.
772 const note: Span[] =
773 event !== null
774 ? [{ text: ` · ${event}`, tone: 'info' }]
775 : marker !== null && hud.autopilot.isOn && tier === 'wide'
776 ? [{ text: ` · hands off at ${Math.round(marker * 100)}%`, isDim: true }]
777 : []
778 const width = size.meter
779 cells.push({
780 key: 'ctx',
781 caption: [...caption('Context'), ...note],
782 parts: [{ key: 'v', graphic: { source: svgContextMeter({ fraction, marker, tone, width, height: 14 }), alt: `Context ${ctx.pct}% used`, width, height: 14 }, value: [{ text: `${ctx.pct}%`, tone: valueTone(ctx.tone), isBold: true }] }],
783 weight: WEIGHT.ctx,
784 })
785 }
786
787 const cache = hud.cache
788 if (cache === null || cache.warmth === 'none') {
789 cells.push({ key: 'cache', caption: caption('Cache'), parts: [{ key: 'v', value: none() }], weight: WEIGHT.cache })
790 } else {
791 const glyphTone: Tone = cache.tone === 'normal' ? 'info' : cache.tone
792 const alt = cacheAlt(cache)
793 cells.push({
794 key: 'cache',
795 caption: caption('Cache'),
796 parts: [{ key: 'v', graphic: { source: svgClock({ fraction: cache.fraction, tone: glyphTone, size: 14 }), alt, width: 14, height: 14 }, value: [{ text: cacheWord(cache, tier), tone: cache.tone === 'warn' ? 'warn' : undefined, isDim: cache.tone === 'muted' ? true : undefined }] }],
797 weight: WEIGHT.cache,
798 })
799 }
800
801 // Machine: CPU and memory, each a slim level bar and its percentage, from the sampler's live readings.
802 // While a watcher parks the run nothing runs, so the cell counts down to its wake instead.
803 const load = hud.load
804 const watcher = sleepingWatcher(hud)
805 if (watcher !== null) {
806 cells.push({
807 key: 'machine',
808 caption: caption(tier === 'compact' ? 'Wakes' : 'Watcher'),
809 parts: [
810 {
811 key: 'v',
812 graphic: { source: svgStateIcon('sleeping', 14), alt: `Watcher ${watcher.id} wakes the run at ${watcher.at}`, width: 16, height: 14 },
813 value: [{ text: watcher.left, isBold: true }, ...(tier === 'compact' ? [] : [{ text: ` · ${watcher.at}`, isDim: true }])],
814 },
815 ],
816 weight: WEIGHT.machine,
817 })
818 } else if (load === null || (load.cpu === null && load.ram === null)) {
819 cells.push({ key: 'machine', caption: caption('Machine'), parts: [{ key: 'v', value: none() }], weight: WEIGHT.machine })
820 } else {
821 const isWide = tier === 'wide'
822 const reading = (key: 'cpu' | 'ram', label: string, value: number | null, tone: Tone): CellPart => ({
823 key,
824 label: isWide ? [{ text: label, isDim: true }] : undefined,
825 graphic: tier === 'compact' || value === null ? undefined : { source: svgLevel({ fraction: value / 100, tone, height: 14 }), alt: `${label} ${Math.round(value)}%`, width: 6, height: 14 },
826 value: value === null ? none() : [{ text: `${Math.round(value)}%`, tone: valueTone(tone), isBold: tone === 'bad' ? true : undefined }],
827 })
828 cells.push({
829 key: 'machine',
830 // Narrower tiers name the two readings once, in their order, over their values.
831 caption: caption(isWide ? 'Machine' : 'CPU · RAM'),
832 parts: [reading('cpu', 'CPU', load.cpu, load.cpuTone), reading('ram', 'RAM', load.ram, load.ramTone)],
833 weight: WEIGHT.machine,
834 })
835 }
836
837 cells.push({ ...planCell(hud.plan.five, tier), weight: 0, isEnd: true })
838 return cells
839}
840
841/**
842 * The plan's five-hour use at the right edge: its thin bar, the percentage and the time to its reset
843 * (the percentage alone when compact); the reset and the limit in words; "—" before a reading, under
844 * "Plan usage" on an account with no five-hour window.
845 */
846function planCell(five: HudModel['plan']['five'], tier: CellTier): Omit<Cell, 'weight' | 'isEnd'> {
847 const caption: Span[] = [{ text: tier === 'compact' ? '5h' : '5-hour usage', isDim: true }]
848 const left = (ms: number | null, tone?: Tone): Span[] => (ms === null || tier === 'compact' ? [] : [{ text: ` · ${fmt.countdown(ms, true)}`, isDim: tone === undefined, tone }])
849 switch (five.kind) {
850 case 'absent':
851 return { key: 'plan', caption: [{ text: 'Plan usage', isDim: true }], parts: [{ key: 'v', value: none() }] }
852 case 'pending':
853 return { key: 'plan', caption, parts: [{ key: 'v', value: none() }] }
854 case 'reset':
855 return { key: 'plan', caption, parts: [{ key: 'v', value: none(tier === 'compact' ? 'reset' : 'reset · new window') }] }
856 case 'limited': {
857 const word = five.window === 'seven_day' ? '7-day limit' : five.window === null ? 'rate limited' : 'limit reached'
858 return { key: 'plan', caption, parts: [{ key: 'v', value: [{ text: tier === 'compact' && five.window === 'five_hour' ? 'limit' : word, tone: 'bad', isBold: true }, ...left(five.leftMs, 'bad')] }] }
859 }
860 case 'reading': {
861 const width = PLAN_PX[tier]
862 return {
863 key: 'plan',
864 caption,
865 parts: [
866 {
867 key: 'v',
868 graphic: { source: svgThinMeter({ fraction: five.percent / 100, tone: five.isHigh ? 'warn' : 'info', width, height: 14 }), alt: planAlt(five), width, height: 14 },
869 value: [{ text: fmt.percent(five.percent), tone: five.isHigh ? 'warn' : undefined, isBold: true }, ...left(five.leftMs)],
870 },
871 ],
872 }
873 }
874 }
875}
876
877function cellsRow(kit: Kit, hud: HudModel): RenderElement {
878 const { Box, Text, Svg } = kit.ui
879 const cells = hudCells(hud, kit.columns)
880 const line = (key: string, spans: readonly Span[]) => <Text key={key} wrap="truncate-end">{spans.map((p, i) => spanEl(kit, `${key}-${i}`, p))}</Text>
881 return (
882 <Box key="hud-cells" flexDirection="row" columnGap={3} alignItems="flex-start">
883 {cells.map(c => (
884 <Box
885 key={`cell-${c.key}`}
886 flexDirection="column"
887 // Weighted columns on a zero basis: fixed shares that never drift; the five-hour reading takes what it needs at the
888 // right edge. Every caption starts where its reading starts, the last cell's too (flush right, the caption sat
889 // apart from its wider reading).
890 flexGrow={c.isEnd === true ? 0 : c.weight}
891 flexShrink={c.isEnd === true ? 0 : 1}
892 width={c.isEnd === true ? undefined : 0}
893 alignItems="flex-start"
894 {...clip(kit)}
895 >
896 {line(`cell-${c.key}-caption`, c.caption)}
897 <Box flexDirection="row" alignItems="center" columnGap={1.5} {...clip(kit)}>
898 {c.parts.map(part => (
899 <Box key={`cell-${c.key}-${part.key}`} flexDirection="row" alignItems="center" columnGap={part.label === undefined ? 1 : 0.5} flexShrink={0}>
900 {part.label === undefined ? null : line(`cell-${c.key}-${part.key}-label`, part.label)}
901 {part.graphic === undefined || Svg === undefined ? null : (
902 <Box flexShrink={0}>
903 <Svg key={`cell-${c.key}-${part.key}-svg`} source={part.graphic.source} alt={part.graphic.alt} width={part.graphic.width} height={part.graphic.height} />
904 </Box>
905 )}
906 {line(`cell-${c.key}-${part.key}-value`, part.value)}
907 </Box>
908 ))}
909 </Box>
910 </Box>
911 ))}
912 </Box>
913 )
914}
915
916function nativeHud(kit: Kit, hud: HudModel, stage: RenderElement | null): RenderElement {
917 const { Box } = kit.ui
918 return (
919 <Box flexDirection="column" rowGap={1}>
920 {agentBand(kit, hud)}
921 {hud.alert === null ? null : alertLine(kit, hud.alert)}
922 {laneRow(kit, hud, stage)}
923 {headlineRow(kit, hud)}
924 {cellsRow(kit, hud)}
925 </Box>
926 )
927}
928
929export function hudView(kit: Kit, hud: HudModel, stage: RenderElement | null = null): RenderElement {
930 const { Box } = kit.ui
931 if (kit.surface !== 'terminal') return nativeHud(kit, hud, stage)
932 const lane = laneRow(kit, hud, stage)
933 return (
934 <Box flexDirection="column">
935 {agentBand(kit, hud)}
936 {hud.alert === null ? null : alertLine(kit, hud.alert)}
937 {lane}
938 {edgeRow(kit, hud, lane !== null)}
939 {headlineRow(kit, hud)}
940 {instrumentsRow(kit, hud)}
941 </Box>
942 )
943}
944hooks/ui/pane/frame.tsx 144 lines1/**
2 * The Control Centre's frame: the name and the active profile, the section
3 * bar, the page, and (in the terminal) one quiet line on how to move.
4 *
5 * Six sections, each answering one question:
6 * Overview How is this session doing, and what is on?
7 * Context When does Claude hand off, and what happened across the run?
8 * Behavior How does Claude work (effort, finishing the job, models)?
9 * Guardrails What may Claude do, and how hard may it push the machine?
10 * Activity What did Claude just do, and what did it change?
11 * Setup Profiles, display, about.
12 */
13
14import type { RenderElement } from 'claude-code'
15
16import type { ActivityView, ChainView, HudModel, OpsView, PaneModel, PermissionsView, ResourcesView, TabId } from '../../../types'
17import type { Kit } from '../kit'
18import { clip, navBar } from '../primitives'
19import { ACCENT, G } from '../theme'
20import { activityPage } from './activity'
21import { behaviorPage } from './behavior'
22import { contextPage } from './context'
23import { guardrailsPage } from './guardrails'
24import { overviewPage } from './overview'
25import { setupPage } from './setup'
26
27export const TABS: readonly { id: TabId; label: string; accent: string }[] = [
28 { id: 'overview', label: 'Overview', accent: ACCENT.overview },
29 { id: 'context', label: 'Context', accent: ACCENT.context },
30 { id: 'behavior', label: 'Behavior', accent: ACCENT.behavior },
31 { id: 'guardrails', label: 'Guardrails', accent: ACCENT.guardrails },
32 { id: 'activity', label: 'Activity', accent: ACCENT.activity },
33 { id: 'setup', label: 'Setup', accent: ACCENT.setup },
34]
35
36/** Which extra atoms a page draws from (the render hook reads only these). */
37export const TAB_NEEDS: Record<TabId, readonly ('resources' | 'chain' | 'activity' | 'permissions' | 'ops')[]> = {
38 overview: ['resources', 'ops'],
39 context: ['chain', 'ops'],
40 behavior: [],
41 guardrails: ['permissions', 'resources'],
42 activity: ['activity', 'ops'],
43 setup: [],
44}
45
46export type PaneData = {
47 pane: PaneModel
48 hud: HudModel
49 resources?: ResourcesView
50 chain?: ChainView
51 activity?: ActivityView
52 permissions?: PermissionsView
53 /** The orchestration layer (Activity → Operations; Overview's card; Context's Cold Resume Guard and Ready to resume). */
54 ops?: OpsView
55}
56
57function header(kit: Kit, data: PaneData): RenderElement {
58 const { Box, Text, Button } = kit.ui
59 const p = data.hud.profile
60 const chip = `${p.name}${p.isModified ? ' · edited' : ''}`
61 return (
62 <Box key="header" flexDirection="row" justifyContent="space-between" alignItems="center">
63 <Text>
64 <Text color="claude" bold>
65 {G.brand}
66 </Text>
67 <Text bold>{' Control Room'}</Text>
68 </Text>
69 <Button key="header-profile" label={chip} plain dimColor={p.id === 'normal'} onPress={() => kit.actions.setTab('setup')} />
70 </Box>
71 )
72}
73
74function page(kit: Kit, data: PaneData): RenderElement {
75 switch (data.pane.tab) {
76 case 'overview':
77 return overviewPage(kit, data)
78 case 'context':
79 return contextPage(kit, data.pane, data.hud, data.chain, data.ops)
80 case 'behavior':
81 return behaviorPage(kit, data.pane)
82 case 'guardrails':
83 return guardrailsPage(kit, data.pane, data.permissions, data.resources)
84 case 'activity':
85 return activityPage(kit, data.pane, data.activity, data.ops)
86 case 'setup':
87 return setupPage(kit, data.pane)
88 }
89}
90
91/**
92 * The widest a page draws. Beyond it a row's label and its control drift too
93 * far apart to read as one line (an inline frame in a wide terminal spans
94 * the screen, as does a Desktop pane at full size), so a wider frame centres
95 * the page at this width.
96 */
97export const MAX_COLUMNS = 80
98
99export function paneView(base: Kit, data: PaneData): RenderElement {
100 const inner = Math.max(24, base.columns - 2)
101 const columns = Math.min(MAX_COLUMNS, inner)
102 const kit: Kit = { ...base, openPicker: data.pane.openPicker, columns }
103 const { Box, Text, Button } = kit.ui
104 const notes = data.pane.notes
105 // A short frame above the prompt: the tabs sit right under the title, so the page starts sooner.
106 const isShort = kit.placement === 'inline' && kit.surface === 'terminal'
107 const body = (
108 <Box key="page-body" flexDirection="column" paddingX={1} width={columns === inner ? undefined : columns + 2}>
109 {header(kit, data)}
110 <Box key="nav-wrap" marginTop={isShort ? 0 : 1} flexDirection="column">
111 {navBar(kit, { tabs: TABS, current: data.pane.tab, onSelect: id => kit.actions.setTab(id) })}
112 </Box>
113 {notes.length === 0 ? null : (
114 <Box flexDirection="column" marginTop={1} key="notes">
115 {notes.map((n, i) => (
116 <Text key={`note-${i}`} color="warning" wrap="wrap">{`${G.warn} ${n}`}</Text>
117 ))}
118 </Box>
119 )}
120 {page(kit, data)}
121 <Box marginTop={1} key="footer" flexDirection="row" justifyContent="space-between" columnGap={2}>
122 <Box flexShrink={1} {...clip(kit)}>
123 {kit.surface === 'terminal' ? (
124 <Text dimColor wrap="truncate-end">
125 {'Tab to move · Enter to choose · Esc to return'}
126 </Text>
127 ) : null}
128 </Box>
129 {/* The section bar does not scroll with the page; this brings it back from the end of any page. */}
130 <Box flexShrink={0}>
131 <Button key="to-top" label={`${G.top} Sections`} plain dimColor onPress={kit.actions.scrollToTop} />
132 </Box>
133 </Box>
134 </Box>
135 )
136 // Capped in a wider frame: centred, so the margins match.
137 if (columns === inner) return body
138 return (
139 <Box flexDirection="column" alignItems="center">
140 {body}
141 </Box>
142 )
143}
144