SLOPSHOPPER

Project Sentinel

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…

newpanebandspinnerrowsguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · project-sentinel
│ ┃ control-room ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Control Room Normal │ ┃ ● project-sentinel: settings: a fresh install: the 1.7 defaults (Auto │ ┃ Overview Context Behavior ● project-sentinel: autopilot: configure · off → armed │ ┃ Guardrails Activity Setup ⏺ Read(src/auth.ts) │ ┃ ────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────── ✓ Edit …/src/auth.ts ▸ │ ┃ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ ◆ No run yet — ⎿ Denied by project-sentinel: Control Room Permission Polic │ ┃ │ ┃ ○ Ready ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ WORK Open › ✻ Worked for 42s · done 4:20 PM │ ┃ Claude lists milestones when the work │ ┃ has several steps. › /control-room │ ┃ Carries across handoffs, counted from ⎿ project-sentinel: Control Room opened. │ ┃ Claude’s own milestones. ● project-sentinel: keep warm: no refresh planned (Off) │ ┃ ● project-sentinel: plan usage: five-hour 31% │ ┃ CONTEXT Open › │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ ─────────────────────────────────────… │ │ ┃ │ Waiting for the first response │ │ ┃ │ │ │ ┃ │ Autopilot ● On │ │ ┃ │ Hands off at 70% │ │ ┃ ╰────────────────────────────────────────╯ ──────────────────────────────────────────────────────────────────────────────────────────────────── ○ Ready ◆ Control Room CONTEXT — 5H — ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
──────────────────────────────────────────────────────────────────────────────────────────────────── ○ Ready ◆ Control Room CONTEXT — 5H —
Pane · control-room
◆ Control Room Normal Overview Context Behavior Guardrails Activity Setup ────────────────────────────────────────────────────── ◆ No run yet — ○ Ready WORK Open › Claude lists milestones when the work has several steps. Carries across handoffs, counted from Claude’s own milestones. CONTEXT Open › ╭────────────────────────────────────────────────────────╮ │ ────────────────────────────────────────────────── │ │ Waiting for the first response │ │ │ │ Autopilot ● On │ │ Hands off at 70% │ ╰────────────────────────────────────────────────────────╯ Starts over at each handoff. CACHE Open › ╭────────────────────────────────────────────────────────╮ │ Keep warm ○ Off │ │ Nothing cached yet │ ╰────────────────────────────────────────────────────────╯ BEHAVIOR Open › ╭────────────────────────────────────────────────────────╮ │ Frontier Max ● On │ │ Extra-high effort │ │ Lazy-exit guard ○ Off │ │ Release check ○ Off │ │ Model router Off › │ │ Answer style Standard › │ ╰────────────────────────────────────────────────────────╯ GUARDRAILS Open › ╭────────────────────────────────────────────────────────╮ │ Permissions 6 ask · 2 deny › │ │ Subagents No limit › │ │ Machine load limit ○ Off │ ╰────────────────────────────────────────────────────────╯ ACTIVITY Open › ╭────────────────────────────────────────────────────────╮ │ Focus view ● On │ │ Compact transcript │ ╰────────────────────────────────────────────────────────╯ SETUP Open › ╭────────────────────────────────────────────────────────╮ │ Profile Normal ▾ │ │ Autopilot at 70%, Frontier Max at Extra High │ ╰────────────────────────────────────────────────────────╯ Tab to move · Enter to choose · Esc to return ↑ Sections
Tool row
✓ Edit …/src/auth.ts ▸
README

Project Sentinel

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.

Examples

  1. See a long run at a glance. Install it and start a session: the status bar shows the run's milestones as a track (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.
  2. Let a run continue past a full context. On a new install Autopilot is on (/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.
  3. Be asked before risky calls. Out of the box, installs, downloads, deletions, edits outside the project, pushes and deploys are set to Ask, and force pushes, resets and dangerous commands to Deny. So before Claude runs 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).
  4. Keep the cache warm while you step away (optional, spends usage): /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.
  5. Queue work, and let a run sleep until a result is due. /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.
  6. Meet Kit: /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.

What it does on your machine

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 it submits, and what is in them

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:

  • The handoff prompt. How full the context is (tokens used of the window), the run and session numbers, and the request: verify the work; update the run's milestones (with its 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.
  • The retry prompt, only when the notes file was not written: asks once more for it.
  • The continuation prompt, in the fresh context after /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):

  • Queued work (Mission Queue ·): the words you queued, with when you queued them, at the boundary you chose.
  • Answers (Decision Inbox ·): Claude's question and your answer, when the run waits on it or you press Send now (otherwise they ride your next message as context).
  • A watcher's wake (Project Sentinel watcher wake): the watcher's id, what it waits for (its label), when it was armed, the milestone under way then, asking Claude to check and continue.
  • A fresh context's first prompt (Project Sentinel fresh resume), after a /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.
  • Write notes first, only when you press it on a watcher: asks Claude to record its milestones and update the notes before a fresh park.
  • Your own message, unchanged, when the Cold Resume Guard compacted first or you pressed Send now on a message it kept.

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.

Commands it runs

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.

Tools it calls, and messages to agents

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.

Dialogs and the prompt box

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.

What its tool-call hook does with the calls it sees

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:

  • passes the call on unchanged, which is what happens to nearly every call;
  • asks you first, in Claude Code's question dialog, when its category is set to Ask and Claude Code would not ask anyway (Run it / Don't run it), then passes it on unchanged;
  • refuses it with a reason Claude reads: its category is set to Deny; it is a new heavy job while machine-load limits are on, the machine is at its ceiling and other heavy jobs are running; or, should its classifier fail, it is an obviously destructive command.

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.

The tools it answers itself

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

Subagents

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.

Programs it runs

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:

  • Windows: 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.
  • macOS: top -l 0 -s 2 -n 0 (one process while live load is on) and sysctl -n kern.memorystatus_level at each reading.
  • Linux: no program; it reads /proc/stat and /proc/meminfo.
  • In the terminal only: git status --porcelain=v1 --branch --untracked-files=normal in the project, for the branch and the count of uncommitted files.
  • For milestone evidence, on every surface: 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.

What it reads

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.

Credentials

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.

What it sends, and where

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:

  • the prompts and notes above;
  • Keep warm (off by default; it spends usage): shortly before the prompt cache would lapse while you are away, one request of the conversation so far plus a keep-alive line asking for a one-word reply, so Anthropic's prompt cache stays warm; it stops after two idle hours (by default);
  • the lazy-exit guard (off by default): when Claude stops with work plainly unfinished it asks Claude to continue, at most twice a turn; with its model check on, it first asks Claude Code's small model whether the stop was premature, sending your request and the end of Claude's answer;
  • the Handoff Supervisor (with Autopilot): when a written handoff has waited a minute for a background task that fixed patterns do not recognise, it asks Claude Code's small model once whether that task is a server or a job that ends, sending only the task's command and Claude Code's description of it (never the conversation).

What it adds to Claude's instructions

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.

What it stores

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.

Files it ships

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.

More

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.

Source 74 files
hooks/register.tsx 582 lines
1/**
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}
582
hooks/constants.ts 82 lines
1/**
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'
82
hooks/core/policy.ts 118 lines
1/**
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}
118
hooks/features/permissions/categories.ts 404 lines
1/**
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}
404
hooks/features/permissions/decide.ts 115 lines
1/**
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}
115
hooks/app/actions.ts 233 lines
1/**
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}
233
hooks/app/runtime.ts 3467 lines
1/**
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 lines
1/**
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}
892
hooks/host.ts 197 lines
1/**
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}
197
hooks/ui/kit.ts 165 lines
1/**
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}
165
hooks/ui/hud.tsx 944 lines
1/**
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}
944
hooks/ui/pane/frame.tsx 144 lines
1/**
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