One band above the prompt: quick replies, branch/PR/CI standing, decision cards, waiting-on-you items, standing-rule capture and a Hand off chip at natural…

One band above the Claude Code prompt that replaces the things you keep typing: quick replies, where the branch and PR stand, one-click answers to option questions, the manual steps Claude is waiting on, a "save this as a standing rule?" offer, and a Hand off chip at a natural break. It never calls a model itself (the chip runs the handoff mod's /handoff, which asks first).
It owns the AbovePrompt band because that band is a single instance shared with surveys: several band mods would hide each other.
Each section folds to its toggle (the ▾/▸ label on its left); the fold is kept for the session.
$.prompt.submit({ text, asUser: true })). After you click anything in the band it keeps the keyboard focus, and a letter you then type presses the band button with that hotkey. So only the two harmless replies, Try again and Status, have a hotkey (a letter, never a digit, because a bare digit typed into an empty prompt presses a band button with that digit). Go, Resume and Session completion set work in motion, so they have no hotkey and send only when you press the button itself: typing "make sure" or "wait" into the band never sends them.| Key | Button | Sends (default) |
|---|---|---|
| - | Go | Go ahead. |
| - | Resume | Resume from where you left off; do not restart from scratch. |
| t | Try again | Try again. |
| s | Status | Status update: what ran and passed, what is merged, what is pending, and the link where it can be seen. |
| - | Session completion | Is everything done, completed and merged on green CI in this session, and communicated back to the lead session if there is one? If so, archive this session to wrap up; or is that already done? |
A press is claimed before it is sent, and only one quick reply is in flight at a time: a double press, a press from a second surface, or a second press before the first reply's turn starts sends nothing more ("already queued"). While Claude is working a press sends nothing and says so; press again when the turn ends.
branch · PR #N open/mergeable · CI green|failing|pending · not merged · idle Nm. When the session is fully done, an Archive session button follows the line, read fail-closed: the standing is fresh (under 3 minutes old, and started after the latest turn's Stop, matched by a Stop sequence number and a per-load token rather than a clock, so a standing saved by an earlier load never matches); the PR is merged at this exact HEAD (headRefOid equals git rev-parse HEAD), or there is no PR (gh's "no pull requests found" or "no git remotes found"), nothing is ahead of the upstream (git rev-list --count @{u}..HEAD is 0; no upstream counts as unknown) and HEAD is already in the remote default branch (git merge-base --is-ancestor HEAD origin/<default>, the default from refs/remotes/origin/HEAD; unknown blocks); a repository with no remote at all (git remote lists none) has nowhere to push or merge, so a clean tree counts as done and the reason reads no remote, all committed; git status --porcelain is empty; nothing is working or queued, no item waits on you, no decision card is open; the Stop after the latest turn listed no background task or wakeup (a new turn resets the count until its Stop); where the host raises no Stop to mods (the desktop app), the turn end reads the running subagents instead and background shell tasks and wakeups stay the app's to guard, which refuses to archive live work (the blocker then reads N running agents); and the lead: the app's get_session record is read once a minute (unknown until it answers, and a lead once known is never forgotten by a failed read), and when it names a parent session, this session's main loop sent it a delivered message (run by the engine, not answered by a hook, not refused, not errored) after the merge: GitHub's mergedAt, or the refresh that first saw it merged, kept only while the same PR on the same branch stays merged; with no PR, after the refresh that first saw HEAD in the default branch (the app's send_message to that id, or any SendMessage, which names sessions by title the band cannot match). The standing is stamped before its first git reading, and a refresh asked for while one runs (a turn's end, the press) waits for it and reads again, so readings from before an edit never pass. Pressing it asks Archive this session (PR #N merged, tree clean, nothing waiting)? Finished side sessions sharing its worktree are archived with it., re-reads git and judges everything again after the answer (an edit, a wakeup or a task that arrived meanwhile stops it, with a toast; a re-read that does not land stops it too), and then calls the app's archive_session tool for this session, which ends it; the button exists only where that tool is listed, elsewhere the row reads done: ready to archive (use the app). While the session is idle and not done, a dim line says what stands: archive after: PR #12 open, 3 uncommitted changes, 1 waiting on you, lead session not told (untracked files count as changes). Unknown never passes (an unreadable tree, no Stop seen yet). Child sessions this one started are the app's to guard: its archive refuses while a side session sharing the worktree is still working, and archives finished ones with it. Work parked in a git stash is invisible to the check, and GitHub's merge time is compared with this Mac's clock. It comes from git rev-parse --abbrev-ref HEAD and gh pr view --json number,state,mergeable,mergeStateStatus,statusCheckRollup,mergedAt,url, both run in the session's own directory ($.session.cwd(), so a worktree shows its own branch) with a 10-second timeout. It refreshes every minute and after every main-loop turn, but only while a surface that draws the band (terminal or desktop) is attached: a -p run never polls. "No pull requests found" reads as no PR; an empty or unknown CI rollup reads as pending, never green; a gh that hangs until the timeout reads as PR unknown for that round. (stale Nm) appears once the last refresh is over three minutes old. Idle counts from the last tool call.Q1, Question 2, Decision 3:, 1., or a line with a ?) followed by at least two lettered options (a), (b), C., Option D), or options written on the question line. A block counts only when its header or the lines under it ask you something (a ? that ends the line, or is followed only by "pick one" and the like) or a "recommend" sits in it. Fenced code is skipped, and a question the text answers itself ("Did it regress? No.", "Why did CI fail? Two reasons:") is prose, so step lists, status lists, explanations and code samples do not raise cards.Recommended: (or Recommendation:) and holds nothing else but the options: Recommended: b, Recommended: (b), Recommended: option B, Recommended: Q1 a, Q2 c, with at most a closing full stop. Any other word on the line voids all of it, so Recommended: not Q1 a, Recommendation: avoid Q1 a; go with Q1 b, Recommended: (a) is NOT recommended and Recommended: a - avoid at all costs name nothing. A line that names the same question twice with different letters names nothing for it.(recommended), [strongly recommended], (my recommendation)) and not led by a negating word (not (recommended)), or the label opening with Recommended: or closing with — recommended. The opening and closing forms count only when nothing else in the label is a negation cue (not, no, never, avoid, against, but, rather, least, risky, wrong, any n't, and the like), so Recommended: no, too risky, Recommended — against: Postgres and avoid, recommended mark nothing.I recommend option B does not), and neither does a passing mention (Use the recommended defaults) or watered-down wording ((least recommended), Recommended against: Q1 a). When two marks disagree there is no recommendation, so the picker is never preset to a guess. These are patterns, not understanding: the preview line under the buttons shows exactly what will be sent before you press.Q1 a, Q2 c into the prompt for you to send with Enter (or submits it directly where there is no prompt box). Accept all recommended shows only when every question has a recommendation. A dim line under the buttons shows the exact text each of them will send. While Claude is working these buttons are dimmed and a press only says so.WAITING ON JOSEPH: or WAITING ON YOU: (any case) become items (up to 200 characters each) with a Done button that sends Done: <item> as your words and removes it. Each item is cleaned to printable ASCII when it is read: invisible characters (controls, zero-width, bidi overrides, the Unicode tag block used to smuggle hidden text) are dropped, accents come off letters, typographic quotes, dashes and ellipses are spelled plainly, and any other character is dropped. The band draws exactly the cleaned text, and that is what Done sends. The item is drawn whole, wrapped over as many rows as it needs and never cut, because Done sends all of it: read it before pressing, since Claude wrote it. An answer without the marker leaves the list as it was; your next prompt clears it. Done does nothing while Claude is working. Add the marker convention to CLAUDE.md so Claude uses it.$HOME/.claude/captured-rules.md (created with a short header). That file loads only once ~/.claude/CLAUDE.md has the line @captured-rules.md. The mod never edits ~/.claude/CLAUDE.md itself; when the line is missing, the "Saved" message says so and names the file to add it to.<session root>/CLAUDE.md.## Captured rules heading, created if missing, at the end of that section: never a blind append, so a generated block at the end of a file stays whole. A heading inside a code block is not used, and a file with Windows line endings keeps them. The write re-checks the file's modification time just before writing and tries once more if another session changed it; if it keeps changing nothing is written and the offer stays.CLAUDE.md. A rule already there as a line of its own (in any section, outside code), or saved there from an earlier prompt, is not offered again. Saving to one project does not stop the offer in another.PR #N merged: the PR for this branch is merged at this exact HEAD (the same head test the archive check uses), and the merge was seen no earlier than this session began (GitHub's merge time, or the refresh that first saw it, against $.session.usage().startedAt, which /clear starts over), so a fresh session on a branch merged before it is not offered a hand-off at once;waiting on you, no agents running: the last answer left WAITING ON JOSEPH: items and the observation after it listed no running agent (unknown counts as running);context 162k: the context window holds at least handoffTokens tokens (150k by default). The figure is absolute tokens, never a share of the window: past about 150k each turn costs, waits and thins out more whatever the window's size.One reason shows at a time, in that order. Never while Claude works or a quick reply is queued. "No PR, HEAD already in the default branch" is deliberately not a break: a fresh session on main reads so from its first moment and would be offered a hand-off at once.
On /clear the band forgets the old conversation: its waiting items, its decision cards, its observation of running agents and its context reading go at once, before any turn, so no break carried over from it is offered. The context reading starts again with no fill and no session start; the start is read from $.session.usage() just after the clear, and until a read succeeds there is no merged break (unknown never passes). A usage read that was still on its way when the clear came, or one that still reports the forgotten conversation's start, is dropped, so the old fill or start never returns. A resume that swaps the conversation in the same process (session.end with reason: 'resume') is forgotten the same way. A plain /clear drops the waiting reminders with it (your next prompt would have retired them anyway); /handoff carries them into the note's "Waiting on / open questions" section first.
A press runs /handoff ($.command.run({ command: 'handoff' }), origin plugin cockpit, not awaited): the handoff mod asks first, writes the note under ~/.claude/handoffs/, clears the conversation only once the note is written, and pre-fills the next prompt; nothing is submitted. A press that lands after a turn started runs nothing and says Cockpit: wait for the turn to end, then hand off. The chip has no hotkey, since a hand-off ends in a /clear. It shows only while a plugin lists the handoff command ($.command.list(), re-read with every standing refresh and after session.start), so nothing advertises a mod that is not installed; if the run is refused anyway, a toast names the install line: claude plugin install handoff@joseph-mods --scope user (or /plugin install handoff --marketplace Joseph-Ayoub/claude-mods). See mods/handoff/README.md for the flow itself.
| Hook | Kind | What it does |
|---|---|---|
session.start | observer | Arms the one-minute standing refresh when a surface that draws the band (terminal or desktop) is attached, read from $.session.surfaces(), not from isInteractive (false for the desktop app's SDK-hosted session). With none attached (-p) it does not poll. After next(e) (so a plugin beneath has registered its commands) it reads the host, the command list among it, and the context fill from $.session.usage() (for a reload; a /clear fires no session.start, see session.end). |
session.end | observer | After next(e) (even when a hook beneath throws), on reason: 'clear' or 'resume' (the one word a mod gets of a /clear, or of a resume that swaps the conversation in this process): empties the waiting items, the decision cards and the agent observation, resets the context reading (no tokens, no start, the window kept), drops any usage read begun before it, and reads $.session.usage() again from a zero-delay timer once the engine has moved on. Any other reason changes nothing. |
session.attach | observer | Arms the refresh when the desktop app attaches to a session that started without a band surface. |
tool.call | observer, gating site with .catch that replays next | Stamps the last tool time (not awaited) and lazily re-arms the refresh after a hot reload (still only with a band surface attached). Never refuses a tool. |
turn.start | observer | Clears a pending quick reply once its turn starts, and marks a main-loop turn in flight (a Hand off press re-checks it). |
turn.complete | observer | Main loop only (agentId undefined): ends the turn in flight (in a finally, so a hook beneath that throws cannot leave Hand off refused); refreshes the standing line, and through it the host and the command list, without holding the answer; reads the context fill from $.session.usage(); on reason: 'answer' parses decision cards and waiting items. Returns the answer's result unchanged. |
session.measure | observer | Keeps the context fill (e.context.tokens and window) after next(e), written only when it moved, for the Hand off chip. |
prompt.submit | guard, fails open (.catch replays next) | Drops an exact duplicate (normalized) of one of your last five prompts from the last ten minutes, only when it was typed over a running turn (turnId set), not queued on purpose (wait not true), and from you (composer, bridge or sdk origin: the desktop app hosts its sessions over the SDK); puts the text back in the prompt if it is empty. After the hooks beneath let a prompt through (never for one they dropped): a prompt from you (composer, bridge or sdk) retires the cards and the waiting items and is checked for a standing-rule sentence. |
ui.render on AbovePrompt | drawing | Draws the band on terminal and desktop, sized to bodyColumns; yields (next(e)) to a survey and while a subagent's transcript is open. |
claude plugin validate lists tool.call and prompt.submit as gating hooks with .catch; both fail open by design, since neither should ever block you.
Each quick-reply phrase is a userConfig field, editable in /config or under pluginConfigs.cockpit in settings: replyGo, replyResume, replyTryAgain, replyStatus, replyCompletion. An empty value falls back to the default above. A custom phrase is cleaned to printable ASCII like a Done item, and the band shows it under the buttons (Go sends: "..."), since it goes out as your words.
handoffTokens (number, default 150000, held to 20000 to 1000000) is where the Hand off chip offers itself on context alone: an absolute token count, since past about 150k each turn costs, waits and thins out more whatever the window. 0 falls back to 80 % of the window (no chip on context when the window is unknown).
Install it from the marketplace (terminal session): /plugin install cockpit --marketplace Joseph-Ayoub/claude-mods, or from this folder's marketplace at user scope: claude plugin install cockpit@joseph-mods --scope user.
From a terminal, for one session:
claude --plugin-dir /absolute/path/to/claude-mods/cockpit
Or set CLAUDE_CODE_PLUGIN_DIRS to that absolute path (in the process environment or the env block of ~/.claude/settings.json) so sessions the desktop app starts load it too. Either way the folder is watched in an interactive session, so edits hot-reload.
Check and test it with:
claude plugin validate mods/cockpit
claude plugin test mods/cockpit
$.command.list(), names and sources only) and the context fill ($.session.usage(), the plain call, which costs nothing), and runs /handoff only when you press the chip. It keeps three figures in session state: the token count, the window and the session's start time (startedAt, to tell a merge from before the session); the note itself is the handoff mod's.$.store (shared by every session) holds only hashes of saved rules, one key per rule and file it went to (capturedRule:<sha256>), so two sessions saving at once never overwrite each other's record.git and the GitHub CLI reachable from the Claude process's own PATH (it also tries /opt/homebrew/bin/gh and /usr/local/bin/gh). $.process is documented as CLI-only, so where a host does not offer it the line reads git unavailable.(stale Nm) once its data is over three minutes old.Recommended: b, because it is faster, or b) Postgres, no migration needed — recommended, whose "no" is a negation cue) leaves the picker unset rather than risk presetting an option argued against.fillOutcome) but not end to end: the test kit strips the refusal's cause.t or s presses Try again or Status (the only two with hotkeys), at most one phrase at a time; there is no way for a plugin to hand the focus back to the prompt.Claude is working, so <reply> was not sent rather than the spec's "already queued", because nothing was queued (verifier V19, awaiting the lead's acceptance).composer, bridge and sdk origins as you; every other origin (a notification, a peer, a schedule) is not you. That the desktop app stamps your prompts sdk was not checked on a live desktop session.hooks/register.tsx 1398 lines1// The cockpit band: one plugin that owns the band above the prompt, because
2// AbovePrompt is a single instance shared with surveys and every other band
3// mod. It draws quick replies, the branch/PR/CI standing line, decision cards
4// parsed from the last answer, "waiting on you" items and a standing-rule
5// capture prompt, each section folding to its toggle. At a natural break it
6// offers Hand off, which runs the handoff mod's /handoff (when that is listed).
7//
8// Everything a drawing reads lives in $.state (it survives a hot reload); the
9// only module variables are the timer's armed flag and the in-flight promises,
10// all of which are meant to reset when the module reloads.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, PluginOptions, Register, RenderSurface, SessionContextUsage } from 'claude-code'
14
15import type { CockpitBackground, CockpitCards, CockpitCollapsed, CockpitContext, CockpitHost, CockpitPr, CockpitRule, CockpitStanding, CockpitWaiting } from '../types'
16import {
17 answersText,
18 archiveReason,
19 completion,
20 fillOutcome,
21 findRuleSentence,
22 handoffBreak,
23 hasRule,
24 insertCapturedRule,
25 labelMarksRecommended,
26 normalizePrompt,
27 normalizeRule,
28 parseDecisions,
29 parsePr,
30 parseWaiting,
31 promptPreview,
32 sha256Hex,
33 standingLine,
34 stripFences,
35 personText,
36} from './parse'
37
38type Engine = EngineInterface
39
40const PENDING = { plugin: 'cockpit', key: 'pending' } as const
41const RECENT = { plugin: 'cockpit', key: 'recent' } as const
42const STANDING = { plugin: 'cockpit', key: 'standing' } as const
43const LAST_TOOL_AT = { plugin: 'cockpit', key: 'lastToolAt' } as const
44const CARDS = { plugin: 'cockpit', key: 'cards' } as const
45const WAITING = { plugin: 'cockpit', key: 'waiting' } as const
46const RULE = { plugin: 'cockpit', key: 'rule' } as const
47const COLLAPSED = { plugin: 'cockpit', key: 'collapsed' } as const
48const LAST_REPORT_AT = { plugin: 'cockpit', key: 'lastReportAt' } as const
49const BACKGROUND = { plugin: 'cockpit', key: 'background' } as const
50const HOST = { plugin: 'cockpit', key: 'host' } as const
51const CONTEXT = { plugin: 'cockpit', key: 'context' } as const
52
53const pending = atom(PENDING, null)
54const standing = atom(STANDING, null)
55const lastToolAt = atom(LAST_TOOL_AT, 0)
56const cards = atom(CARDS, null)
57const waiting = atom(WAITING, [])
58const rule = atom(RULE, null)
59const collapsed = atom(COLLAPSED, { replies: false, standing: false, cards: false, waiting: false, rule: false })
60const lastReportAt = atom(LAST_REPORT_AT, 0)
61const background = atom(BACKGROUND, null)
62const host = atom(HOST, { archiveTool: false, lead: null, leadState: 'unknown', handoffCommand: false })
63const context = atom(CONTEXT, null)
64const recent = atom(RECENT, [])
65
66const REFRESH_MS = 60_000
67const PROCESS_TIMEOUT_MS = 10_000
68/** The desktop app's session tools, reached through $.mcp.call; the archive ends this session. */
69const HOST_SERVER = 'ccd_session_mgmt'
70const ARCHIVE_TOOL = 'archive_session'
71const ARCHIVE_TOOL_FULL = 'mcp__ccd_session_mgmt__archive_session'
72const GET_SESSION_TOOL = 'get_session'
73/** Tool calls that tell another session something: a report to the lead counts from the last one. */
74const REPORT_TOOLS = new Set(['SendMessage', 'mcp__ccd_session_mgmt__send_message'])
75const DUPLICATE_WINDOW_MS = 10 * 60_000
76const RECENT_LIMIT = 5
77// A pending reply whose submit continuation was lost (a reload mid-flight) must not block the button forever.
78const PENDING_EXPIRY_MS = 10 * 60_000
79// Idle is shown in minutes, so stamping more often than this only churns redraws.
80const ACTIVITY_STAMP_MS = 15_000
81const REFILL_DELAY_MS = 250
82const PR_FIELDS = 'number,state,mergeable,mergeStateStatus,statusCheckRollup,mergedAt,headRefOid,url'
83// $.process.run inherits the Claude process's PATH, not the login shell's, so a desktop-launched session may need these.
84const GH_CANDIDATES = ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh'] as const
85/** The handoff mod's command, run by the Hand off chip; the chip shows only while it is listed. */
86const HANDOFF_COMMAND = 'handoff'
87const HANDOFF_MISSING = 'Cockpit: the Hand off button needs the handoff mod (claude plugin install handoff@joseph-mods --scope user).'
88// Absolute tokens, never a share of the window: past about 150k every turn costs, waits and
89// thins out more whatever the window's size, so that is where a fresh session pays.
90const HANDOFF_TOKENS_DEFAULT = 150_000
91const HANDOFF_TOKENS_MIN = 20_000
92const HANDOFF_TOKENS_MAX = 1_000_000
93// One $.store key per saved rule and file, so two sessions saving at once never overwrite each other's
94// record (the store has no compare-and-swap); a set of a fixed key is idempotent.
95const capturedKey = (digest: string) => `capturedRule:${digest}`
96const GLOBAL_PREAMBLE =
97 '# Captured rules\n\nStanding rules captured from your prompts by the cockpit mod. Load them in every session with a line `@captured-rules.md` in ~/.claude/CLAUDE.md.\n\n'
98// The import line ~/.claude/CLAUDE.md needs for the global file to load: relative, home-relative or absolute.
99const GLOBAL_IMPORT = /(?:^|\s)@(?:~\/\.claude\/|\.\/|\/\S*\/\.claude\/)?captured-rules\.md(?=\s|$)/m
100// The people a prompt can come from: what retires the cards and the waiting list. A notification,
101// a peer or a schedule arriving between Claude's question and the answer is not the answer. `sdk` is
102// the person too: the desktop app hosts its sessions over the SDK, so its typed prompts arrive so.
103const PERSON_KINDS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
104// A $.process.run that rejects after this long timed out (it started); sooner, it could not start.
105const TIMEOUT_GUESS_MS = PROCESS_TIMEOUT_MS / 2
106
107// `id` keys the button; `hotkey` is set only on the harmless replies (see the note on REPLIES).
108type Reply = { id: string; hotkey?: string; label: string; option: string; fallback: string }
109
110// After any click on the band it holds the keys (claude-code.d.ts ButtonProps.hotkey), so the
111// person's next typed letter presses a band Button with that hotkey, and a press does not say whether
112// it was a click or a key. So only the harmless replies, Try again and Status, carry a hotkey, and
113// letters only, since a bare digit typed into an empty composer presses a band Button with that digit.
114// Go, Resume and Session completion set work in motion (Session completion can end in a merge and an
115// archive, outward and hard to undo), so they send only on an explicit press of the button:
116// "make sure..." typed into the band sends nothing.
117const REPLIES: readonly Reply[] = [
118 { id: 'g', label: 'Go', option: 'replyGo', fallback: 'Go ahead.' },
119 { id: 'r', label: 'Resume', option: 'replyResume', fallback: 'Resume from where you left off; do not restart from scratch.' },
120 { id: 't', hotkey: 't', label: 'Try again', option: 'replyTryAgain', fallback: 'Try again.' },
121 {
122 id: 's',
123 hotkey: 's',
124 label: 'Status',
125 option: 'replyStatus',
126 fallback: 'Status update: what ran and passed, what is merged, what is pending, and the link where it can be seen.',
127 },
128 {
129 id: 'c',
130 label: 'Session completion',
131 option: 'replyCompletion',
132 fallback:
133 'Is everything done, completed and merged on green CI in this session, and communicated back to the lead session if there is one? If so, archive this session to wrap up; or is that already done?',
134 },
135]
136
137// A phrase set in the options goes out as the person's words, so it is cleaned like any such text
138// (personText) and the band shows it whenever it differs from the default (see the band hook).
139const replyText = (options: PluginOptions, reply: Reply): string => {
140 const value = options[reply.option]
141 const text = typeof value === 'string' ? personText(value) : ''
142
143 return text !== '' ? text : reply.fallback
144}
145
146/**
147 * The option `handoffTokens` as the judge takes it: 0 stays 0 (80 % of the window), any other
148 * number is held to 20k..1M, and anything else is the 150k default.
149 */
150const handoffLine = (options: PluginOptions): number => {
151 const value = options['handoffTokens']
152 if (value === 0) return 0
153 return typeof value === 'number' && Number.isFinite(value) ? Math.min(HANDOFF_TOKENS_MAX, Math.max(HANDOFF_TOKENS_MIN, value)) : HANDOFF_TOKENS_DEFAULT
154}
155
156// Timers die with the module on a hot reload, and this flag dies with them: the
157// first event after a reload finds it false and arms a fresh timer.
158let isArmed = false
159// Set by any caller that saw a band surface itself, read by an arm already waiting on the roster:
160// the roster it asked for may predate the attach that brought the surface.
161let isBandSeen = false
162let arming: Promise<void> | null = null
163let refreshing: Promise<void> | null = null
164// A refresh asked for while one runs: the running one's readings may predate what asked, so one more follows it.
165let refreshQueued = false
166// Main-loop Stops this load, so a refresh can say which Stop it followed; a reload restarts it and any old standing mismatches.
167let stopSeq = 0
168// This load's own mark on every Stop: a standing saved by an earlier load never matches a Stop of this one.
169const loadToken = `${Math.floor(Math.random() * 0x7fffffff).toString(36)}-${Math.floor(Math.random() * 0x7fffffff).toString(36)}`
170let hostLooking: Promise<void> | null = null
171// A main-loop turn between turn.start (main loop only) and its turn.complete: the band's
172// isWorking is as of its last drawing, so a press re-checks this. A reload mid-turn resets
173// it to false, and the drawing's isWorking covers that turn.
174let isTurnLive = false
175// Bumped by each /clear: a context reading begun before it is dropped rather than landing on the
176// conversation that follows (it would bring back the old fill or the old start).
177let contextGen = 0
178// The start of the conversation the last /clear (or resume) forgot: a usage read that still
179// reports it landed before the engine moved on, and is dropped rather than revived.
180let forgottenStart: number | null = null
181let ghBinary: string | null = null
182
183const isBandSurface = (surface: RenderSurface | null | undefined): boolean => surface === 'terminal' || surface === 'desktop'
184
185/**
186 * Starts the minute timer once a surface that draws the band is attached.
187 * Whether one is comes from the session's roster, not from `isInteractive`
188 * (false for the desktop app's SDK-hosted session, which does draw the band),
189 * and is asked again on each event until it is: a -p run never polls, even
190 * after a hot reload, and a desktop that attaches later starts it.
191 * `isKnown` skips the roster when the caller already saw a band surface.
192 */
193function arm($: Engine, isKnown = false): Promise<void> {
194 if (isArmed) {
195 return Promise.resolve()
196 }
197
198 isBandSeen ||= isKnown
199
200 arming ??= (async () => {
201 try {
202 const surfaces = isBandSeen ? [] : await $.session.surfaces().catch(() => [] as readonly RenderSurface[])
203
204 if (isArmed || !(isBandSeen || surfaces.some(isBandSurface))) {
205 return
206 }
207
208 isArmed = true
209 $.clock.every(REFRESH_MS, () => {
210 void refresh($)
211 })
212 void refresh($)
213 } finally {
214 arming = null
215 }
216 })()
217
218 return arming
219}
220
221/**
222 * One coalesced standing refresh. A second caller while one runs shares it;
223 * with `fresh`, it waits for the running one and reads again, since the
224 * running one's git readings may predate what asked (a turn's end, an archive).
225 * Never throws.
226 */
227function refresh($: Engine, fresh = false): Promise<void> {
228 if (refreshing !== null) {
229 if (!fresh) return refreshing
230 refreshQueued = true
231 const running = refreshing
232 return running.then(() => (refreshing !== null ? refreshing : refresh($)))
233 }
234
235 refreshing = (async () => {
236 try {
237 const looked = await lookStanding($)
238 const previous = (await $.state.get(STANDING)).value
239 // The merge is dated from the refresh that first saw it, kept only while the same PR on the
240 // same branch stays merged: another PR, or another branch, is a new merge to tell the lead about.
241 const samePr =
242 previous?.pr?.isMerged === true &&
243 looked.pr !== null &&
244 previous.pr.number === looked.pr.number &&
245 previous.branch === looked.branch &&
246 typeof previous.mergedSeenAt === 'number'
247 looked.mergedSeenAt = looked.pr?.isMerged === true ? (looked.pr.mergedAt ?? (samePr ? previous.mergedSeenAt : looked.refreshedAt)) : null
248 // With no PR, "merged" is HEAD in the default branch: dated from the refresh that first saw it, on this branch.
249 const sameInDefault = previous?.inDefault === true && previous.branch === looked.branch && typeof previous.inDefaultSeenAt === 'number'
250 looked.inDefaultSeenAt = looked.inDefault === true ? (sameInDefault ? previous.inDefaultSeenAt : looked.refreshedAt) : null
251 await $.state.set(STANDING, looked)
252 void lookHost($)
253 } catch {
254 // The line keeps its last value and turns "(stale Nm)" on its own.
255 } finally {
256 refreshing = null
257 if (refreshQueued) {
258 refreshQueued = false
259 void refresh($)
260 }
261 }
262 })()
263
264 return refreshing
265}
266
267async function lookStanding($: Engine): Promise<CockpitStanding> {
268 // Stamped before any reading: the standing is as old as its oldest reading, never younger, and
269 // belongs to the Stop that stood when it started.
270 const startedAt = await $.clock.now()
271 const stopped = await read($, background)
272 const stoppedAt = stopped?.seq ?? -1
273 const stoppedToken = stopped?.token ?? ''
274 // The session's own directory: in a worktree, repo().root is the main checkout and would always say "main".
275 const cwd = await $.session.cwd()
276 const git = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: PROCESS_TIMEOUT_MS }).catch(() => null)
277
278 // git that cannot start (no $.process on this host, or a hang) is not the same as "not a repository".
279 if (git === null) {
280 return { branch: null, pr: null, prStatus: 'unavailable', refreshedAt: startedAt, isClean: null, dirtyFiles: null, headSha: null, aheadOfUpstream: null, hasRemote: null, defaultBranch: null, inDefault: null, mergedSeenAt: null, stopSeq: stoppedAt, stopToken: stoppedToken, inDefaultSeenAt: null }
281 }
282
283 const branch = git.exitCode === 0 ? git.stdout.trim() || null : null
284
285 if (branch === null) {
286 return { branch: null, pr: null, prStatus: 'skipped', refreshedAt: startedAt, isClean: null, dirtyFiles: null, headSha: null, aheadOfUpstream: null, hasRemote: null, defaultBranch: null, inDefault: null, mergedSeenAt: null, stopSeq: stoppedAt, stopToken: stoppedToken, inDefaultSeenAt: null }
287 }
288
289 // Clean means nothing to commit; a git that cannot say leaves it unknown (never "clean").
290 const status = await $.process.run(['git', 'status', '--porcelain'], { cwd, timeoutMs: PROCESS_TIMEOUT_MS }).catch(() => null)
291 const dirtyFiles = status !== null && status.exitCode === 0 ? status.stdout.split('\n').filter(line => line.trim() !== '').length : null
292 const isClean = dirtyFiles === null ? null : dirtyFiles === 0
293
294 // HEAD and what is ahead of its upstream: a merged PR counts only at this exact head, and with
295 // no PR nothing may be unpushed. No upstream, or a git that cannot say, stays unknown.
296 const head = await $.process.run(['git', 'rev-parse', 'HEAD'], { cwd, timeoutMs: PROCESS_TIMEOUT_MS }).catch(() => null)
297 const headSha = head !== null && head.exitCode === 0 && /^[0-9a-f]{7,64}$/i.test(head.stdout.trim()) ? head.stdout.trim().toLowerCase() : null
298 const ahead = await $.process.run(['git', 'rev-list', '--count', '@{u}..HEAD'], { cwd, timeoutMs: PROCESS_TIMEOUT_MS }).catch(() => null)
299 const aheadOfUpstream = ahead !== null && ahead.exitCode === 0 && /^\d+$/.test(ahead.stdout.trim()) ? Number(ahead.stdout.trim()) : null
300
301 // Any remote at all? None means nowhere to push or merge: a clean tree is as done as it gets.
302 const remotes = await $.process.run(['git', 'remote'], { cwd, timeoutMs: PROCESS_TIMEOUT_MS }).catch(() => null)
303 const hasRemote = remotes !== null && remotes.exitCode === 0 ? remotes.stdout.trim() !== '' : null
304
305 // The remote default branch and whether HEAD is already in it: with no PR, that is what "merged" means.
306 const symbolic = await $.process.run(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd, timeoutMs: PROCESS_TIMEOUT_MS }).catch(() => null)
307 const defaultBranch = symbolic !== null && symbolic.exitCode === 0 && /^[\w./-]+$/.test(symbolic.stdout.trim()) ? symbolic.stdout.trim() : null
308 let inDefault: boolean | null = null
309 if (defaultBranch !== null) {
310 const contained = await $.process.run(['git', 'merge-base', '--is-ancestor', 'HEAD', defaultBranch], { cwd, timeoutMs: PROCESS_TIMEOUT_MS }).catch(() => null)
311 inDefault = contained === null ? null : contained.exitCode === 0 ? true : contained.exitCode === 1 ? false : null
312 }
313
314 const found = await lookPr($, cwd)
315
316 return { branch, ...found, refreshedAt: startedAt, isClean, dirtyFiles, headSha, aheadOfUpstream, hasRemote, defaultBranch, inDefault, mergedSeenAt: null, stopSeq: stoppedAt, stopToken: stoppedToken, inDefaultSeenAt: null }
317}
318
319/**
320 * What the host can tell the band, re-read with every refresh (a server may
321 * connect later, a mod may be installed or reloaded): whether the app's
322 * archive tool is listed, this session's parent (lead) session, from the
323 * app's own record, and whether a plugin lists `/handoff`. A host that answers
324 * nothing leaves "no lead", "no archive tool" and "no handoff command": no
325 * button ever shows on a guess. Never throws.
326 */
327function lookHost($: Engine): Promise<void> {
328 if (hostLooking !== null) return hostLooking
329 hostLooking = (async () => {
330 try {
331 const held = await read($, host)
332 const tools = await $.tool.list().catch(() => [])
333 const archiveTool = tools.some(t => t.name === ARCHIVE_TOOL_FULL)
334 // Only a plugin's command: a user command of the same name is not the flow the chip promises.
335 const commands = await $.command.list().catch(() => [])
336 const handoffCommand = commands.some(c => c.name === HANDOFF_COMMAND && c.source === 'plugin')
337 let lead = held.lead
338 let leadState = held.leadState
339 if (tools.some(t => t.name === `mcp__${HOST_SERVER}__${GET_SESSION_TOOL}`)) {
340 const timeout = new Promise<null>(resolve => {
341 $.clock.after(PROCESS_TIMEOUT_MS, () => resolve(null))
342 })
343 const res = await Promise.race([$.mcp.call(HOST_SERVER, GET_SESSION_TOOL, { session_id: 'self' }).catch(() => null), timeout])
344 const text = res && !res.isError ? res.content.map(block => ('text' in block && typeof block.text === 'string' ? block.text : '')).join('') : ''
345 const read = parentOf(text)
346 // A failed or unreadable answer leaves what was known: a lead once seen is never forgotten.
347 if (read !== null) {
348 lead = read.lead
349 leadState = read.lead === null ? 'none' : 'known'
350 }
351 }
352 await $.state.set(HOST, { archiveTool, lead, leadState, handoffCommand })
353 } catch {
354 // the previous answer stands
355 } finally {
356 hostLooking = null
357 }
358 })()
359 return hostLooking
360}
361
362/**
363 * The app's session record, read for its parent: `{ lead }` with the parent session id, or
364 * null for none (no parent, or detached); null when the text is not such a record.
365 */
366function parentOf(text: string): { lead: string | null } | null {
367 try {
368 const data = JSON.parse(text) as unknown
369 if (!data || typeof data !== 'object' || Array.isArray(data)) return null
370 const record = data as Record<string, unknown>
371 if (typeof record['sessionId'] !== 'string') return null
372 const parent = record['parentSessionId']
373 if (typeof parent !== 'string' || parent === '' || record['detached'] === true) return { lead: null }
374 return { lead: parent.slice(0, 128) }
375 } catch {
376 return null
377 }
378}
379
380/** Subagent statuses that count as running. */
381const ACTIVE_AGENT = new Set(['pending', 'running', 'waiting'])
382
383/**
384 * Background work as the band can see it after a main-loop turn on a host that
385 * raises no classic.Stop (the desktop, verified 2026-10-12): the running
386 * subagents from $.agent.list(). Background shell tasks and scheduled wakeups
387 * are listed by no engine noun, so they stay the app's to guard (its archive
388 * refuses a session with live background work). A classic.Stop that does arrive
389 * overwrites this with its fuller count. Never throws; a failed list leaves
390 * the count unknown.
391 */
392async function observeBackground($: Engine): Promise<void> {
393 try {
394 const agents = await $.agent.list()
395 const tasks = agents.filter(a => ACTIVE_AGENT.has(String(a.status))).length
396 const at = await $.clock.now()
397 stopSeq += 1
398 await $.state.set(BACKGROUND, { tasks, crons: 0, at, seq: stopSeq, token: loadToken, source: 'agents' })
399 if (isArmed) void refresh($, true)
400 } catch {
401 // unknown stays unknown
402 }
403}
404
405/** Reads every value that bears on "done" and judges it now; isWorking is the caller's. */
406async function judgeNow($: Engine, isWorking: boolean) {
407 const [standingNow, queued, waitingNow, cardsNow, backgroundNow, hostNow, reportAt, now] = await Promise.all([
408 read($, standing),
409 read($, pending),
410 read($, waiting),
411 read($, cards),
412 read($, background),
413 read($, host),
414 read($, lastReportAt),
415 $.clock.now(),
416 ])
417 return completion({
418 standing: standingNow,
419 now,
420 isWorking,
421 hasPending: queued !== null && now - queued.at < PENDING_EXPIRY_MS,
422 waiting: waitingNow.length,
423 openDecisions: cardsNow === null ? 0 : cardsNow.questions.filter(q => q.picked === null).length,
424 background: backgroundNow,
425 lead: { state: hostNow.leadState, id: hostNow.lead },
426 lastReportAt: reportAt,
427 })
428}
429
430/** The person pressed Archive session: confirm, then ask the app to archive this one. Never throws. */
431async function archiveSession($: Engine, reason: string): Promise<void> {
432 try {
433 const choice = await $.ui.ask(`Archive this session (${reason})? Finished side sessions sharing its worktree are archived with it.`, { options: ['Archive', 'Not yet'], header: 'Cockpit' })
434 if (choice !== 'Archive') return
435 // Judged again after the dialog, on fresh git readings: a wakeup, a task or an edit that
436 // arrived meanwhile stops the archive. A re-read that did not land is no reading at all.
437 const pressedAt = await $.clock.now()
438 await refresh($, true)
439 const fresh = await read($, standing)
440 if (fresh === null || fresh.refreshedAt < pressedAt) {
441 $.ui.toast('Cockpit: not archived, the standing could not be re-read.')
442 return
443 }
444 const again = await judgeNow($, false)
445 if (!again.isReady) {
446 $.ui.toast(`Cockpit: not archived, something came up: ${again.blockers.join(', ')}.`)
447 return
448 }
449 const res = await $.mcp.call(HOST_SERVER, ARCHIVE_TOOL, { session_id: 'self', reason: `cockpit: ${reason}` })
450 if (res.isError) $.ui.toast('Cockpit: the app did not archive this session (tool error).')
451 } catch (err) {
452 $.ui.toast(`Cockpit: archive not done (${err instanceof Error && err.name ? err.name : 'error'}).`)
453 }
454}
455
456/**
457 * Keeps the context fill the band judges a hand-off by, from a measurement or
458 * `$.session.usage()`. Writes only when the figures moved, so a cost-only
459 * measurement does not redraw the band. Never throws.
460 */
461async function keepContext($: Engine, usage: SessionContextUsage | undefined, startedAt: number | undefined, gen: number): Promise<void> {
462 try {
463 const tokens = typeof usage?.tokens === 'number' && Number.isFinite(usage.tokens) ? usage.tokens : null
464 const window = typeof usage?.window === 'number' && Number.isFinite(usage.window) && usage.window > 0 ? usage.window : null
465 const at = await $.clock.now()
466 await update($, context, held => {
467 // Taken before a /clear: the fresh conversation's reading stands.
468 if (gen !== contextGen) return held
469 // Taken after it but answered with the forgotten conversation's own start: too early, dropped.
470 if (typeof startedAt === 'number' && forgottenStart !== null && startedAt === forgottenStart) return held
471 // A measurement carries no start: the one last read from $.session.usage() stands (null after a /clear until read).
472 const began = typeof startedAt === 'number' && Number.isFinite(startedAt) ? startedAt : (held?.startedAt ?? null)
473 return held !== null && held.tokens === tokens && held.window === window && held.startedAt === began ? held : ({ tokens, window, at, startedAt: began } satisfies CockpitContext)
474 })
475 } catch {
476 // the last reading stands
477 }
478}
479
480/**
481 * Reads the context fill and the session's start now (the plain call costs nothing). A failed
482 * read changes nothing, so a start a /clear set to null stays null (no merged break) until a
483 * read succeeds. Never throws.
484 */
485async function readContext($: Engine): Promise<void> {
486 const gen = contextGen
487 const usage = await $.session.usage().catch(() => null)
488 if (usage !== null) await keepContext($, usage.context, usage.startedAt, gen)
489}
490
491/**
492 * A /clear ended the conversation: the process goes on under a new session id and no
493 * session.start follows, so this is the band's only word of it. The old conversation's
494 * waiting items, decision cards and agent observation go with it, and the context reading
495 * starts over with no fill and no start (the window stays: same model), so no hand-off break
496 * reads from the old conversation; the merged break fails closed until the fresh start is
497 * read. That read waits for a zero-delay timer, once the engine has moved on to the new
498 * session; a refused timer leaves it to the next main-loop turn end. Never throws.
499 */
500async function forgetConversation($: Engine): Promise<void> {
501 contextGen += 1
502 try {
503 const at = await $.clock.now()
504 await update($, context, held => {
505 forgottenStart = held?.startedAt ?? forgottenStart
506 return { tokens: null, window: held?.window ?? null, at, startedAt: null } satisfies CockpitContext
507 })
508 } catch {
509 // a reading begun before the clear is dropped by the generation either way
510 }
511 await update($, waiting, () => []).catch(() => undefined)
512 await update($, cards, () => null).catch(() => undefined)
513 await update($, background, () => null).catch(() => undefined)
514 try {
515 $.clock.after(0, () => void readContext($))
516 } catch {
517 // the next main-loop turn end reads it
518 }
519}
520
521/**
522 * The person pressed Hand off: runs the handoff mod's `/handoff`, which asks
523 * before it writes the note and clears. Not mid-turn (the note would miss the
524 * turn, and the command would queue behind it). The run is never awaited: the
525 * engine queues it until the session is idle and the dialog may wait on the
526 * person. A rejection (the mod is not installed, or was just removed) says how
527 * to install it. Never throws.
528 */
529function offerHandoff($: Engine, isWorking: boolean): void {
530 if (isWorking || isTurnLive) {
531 $.ui.toast('Cockpit: wait for the turn to end, then hand off.')
532 return
533 }
534 void $.command.run({ command: HANDOFF_COMMAND }).catch(() => {
535 $.ui.toast(HANDOFF_MISSING)
536 })
537}
538
539async function lookPr($: Engine, cwd: string): Promise<{ pr: CockpitPr | null; prStatus: CockpitStanding['prStatus'] }> {
540 const binaries = ghBinary !== null ? [ghBinary] : GH_CANDIDATES
541
542 for (const binary of binaries) {
543 const startedAt = await $.clock.now()
544 const ran = await $.process
545 .run([binary, 'pr', 'view', '--json', PR_FIELDS], { cwd, timeoutMs: PROCESS_TIMEOUT_MS })
546 .catch(() => null)
547
548 if (ran === null) {
549 // A run that hung until the timeout did start: the CLI exists but is stuck (network, an auth
550 // prompt). Trying the other paths would only wait as long again each, so the PR is unknown this round.
551 if ((await $.clock.now()) - startedAt >= TIMEOUT_GUESS_MS) {
552 ghBinary = binary
553
554 return { pr: null, prStatus: 'error' }
555 }
556
557 continue
558 }
559
560 ghBinary = binary
561
562 if (ran.exitCode === 0) {
563 const pr = parsePr(ran.stdout)
564
565 return pr === null ? { pr: null, prStatus: 'error' } : { pr, prStatus: 'ok' }
566 }
567
568 // gh exits 1 with these lines when the branch has no PR, or the repo no remote: facts, not failures.
569 return /no pull requests found|no git remotes found/i.test(ran.stderr) ? { pr: null, prStatus: 'none' } : { pr: null, prStatus: 'error' }
570 }
571
572 ghBinary = null
573
574 return { pr: null, prStatus: 'unavailable' }
575}
576
577async function stampActivity($: Engine): Promise<void> {
578 try {
579 const now = await $.clock.now()
580 const held = await $.state.get(LAST_TOOL_AT)
581
582 if (now - (held.value ?? 0) >= ACTIVITY_STAMP_MS) {
583 await $.state.set(LAST_TOOL_AT, now, { ifVersion: held.version })
584 }
585 } catch {
586 // Idle is advisory; a missed stamp only makes it read a little high.
587 }
588}
589
590async function clearPendingFor($: Engine, text: string): Promise<void> {
591 const held = await $.state.get(PENDING)
592
593 if (held.value !== null && held.value !== undefined && normalizePrompt(held.value.text) === normalizePrompt(text)) {
594 await $.state.set(PENDING, null, { ifVersion: held.version })
595 }
596}
597
598/** Takes the last main-loop answer apart into decision cards and waiting items. */
599async function absorbAnswer($: Engine, answer: string, turnId: string): Promise<void> {
600 const questions = answer.trim() === '' ? [] : parseDecisions(answer)
601 const held = await $.state.get(CARDS)
602
603 if (questions.length > 0) {
604 await $.state.set(CARDS, { turnId, questions })
605 } else if (held.value !== null && held.value !== undefined) {
606 await $.state.set(CARDS, null, { ifVersion: held.version })
607 }
608
609 // An answer without the marker leaves the list alone: items asked for earlier stay until the person types.
610 const items = parseWaiting(answer)
611
612 if (items.length > 0) {
613 await $.state.set(WAITING, items)
614 }
615}
616
617/** The two files a rule can go to; global is null when HOME is unset. */
618async function rulePaths($: Engine): Promise<{ global: string | null; project: string }> {
619 // $.fs does not expand "~": the home directory comes from the environment.
620 const home = await $.env.get('HOME')
621
622 return {
623 global: home === undefined || home === '' ? null : `${home.replace(/\/+$/, '')}/.claude/captured-rules.md`,
624 project: `${(await $.session.root()).replace(/\/+$/, '')}/CLAUDE.md`,
625 }
626}
627
628/** The store's dedupe key for a rule in one file, so saving to one project never hides it from another. */
629const ruleDigest = (path: string, sentence: string) => sha256Hex(`${path}\n${normalizeRule(sentence)}`)
630
631/** Whether the rule already holds where this session reads rules: the global file or this project's CLAUDE.md. */
632async function isRuleInForce($: Engine, sentence: string): Promise<boolean> {
633 const paths = await rulePaths($)
634
635 for (const path of [paths.global, paths.project]) {
636 if (path === null) {
637 continue
638 }
639
640 if ((await $.store.get(capturedKey(await ruleDigest(path, sentence))).catch(() => undefined)) === true) {
641 return true
642 }
643
644 const text = await $.fs.read(path).catch(() => null)
645
646 if (text !== null && hasRule(String(text), sentence)) {
647 return true
648 }
649 }
650
651 return false
652}
653
654/**
655 * What a prompt that went through retires or raises in the band: a person's
656 * prompt (PERSON_KINDS) retires the cards and the waiting list and may raise
657 * a rule capture. Run only after the chain beneath let the prompt through.
658 */
659async function observePrompt($: Engine, text: string, kind: string, now: number): Promise<void> {
660 if (!PERSON_KINDS.has(kind)) {
661 return
662 }
663
664 // Cards answer the question they came with; the person's next prompt retires them (this plugin's own never reach here).
665 const heldCards = await $.state.get(CARDS)
666
667 if (heldCards.value !== null && heldCards.value !== undefined) {
668 await $.state.set(CARDS, null, { ifVersion: heldCards.version })
669 }
670
671 const heldWaiting = await $.state.get(WAITING)
672
673 if ((heldWaiting.value ?? []).length > 0) {
674 await $.state.set(WAITING, [], { ifVersion: heldWaiting.version })
675 }
676
677 const sentence = findRuleSentence(text)
678
679 if (sentence === null || (await isRuleInForce($, sentence))) {
680 return
681 }
682
683 await $.state.set(RULE, { sentence, detectedAt: now })
684}
685
686/** Puts a dropped duplicate back as a draft, unless the person has already started typing something else. */
687function scheduleRefill($: Engine, text: string): void {
688 $.clock.after(REFILL_DELAY_MS, () => {
689 void (async () => {
690 try {
691 const box = await $.prompt.read()
692
693 if (box.text.trim() === '') {
694 await $.prompt.fill({ text, mode: 'replace' })
695 }
696 } catch {
697 // The drop reason already told the person; the draft is a courtesy.
698 }
699 })()
700 })
701}
702
703// Band actions ----------------------------------------------------------------
704
705async function sendReply($: Engine, label: string, text: string, isWorking: boolean): Promise<void> {
706 const now = await $.clock.now()
707 const held = await $.state.get(PENDING)
708 const live = held.value !== null && held.value !== undefined && now - held.value.at < PENDING_EXPIRY_MS ? held.value : null
709
710 // One quick reply in flight at a time, whichever it is: letters typed while the band holds the
711 // focus (after a click on it) press these buttons, and "make sure…" must not send m, s, r and t.
712 if (live !== null) {
713 $.ui.toast('already queued')
714
715 return
716 }
717
718 if (isWorking) {
719 $.ui.toast(`Claude is working, so ${label} was not sent. Press it again once the turn ends.`)
720
721 return
722 }
723
724 // Claim the send first, so a double press or a press from a second surface cannot submit twice.
725 const claim = await $.state.set(PENDING, { text, at: now }, { ifVersion: held.version })
726
727 if (!claim.isSet) {
728 $.ui.toast('already queued')
729
730 return
731 }
732
733 const result = await $.prompt.submit({ text, asUser: true }).catch(() => ({ drop: 'failed' }) as const)
734 const after = await $.state.get(PENDING)
735
736 if (after.value?.text === text && after.value.at === now) {
737 await $.state.set(PENDING, null, { ifVersion: after.version })
738 }
739
740 if (result.drop !== undefined) {
741 $.ui.toast(`${label} was not sent: a hook dropped it.`)
742 }
743}
744
745async function restoreCards($: Engine, held: CockpitCards): Promise<void> {
746 const now = await $.state.get(CARDS)
747
748 if (now.value === null || now.value === undefined) {
749 await $.state.set(CARDS, held, { ifVersion: now.version })
750 }
751}
752
753const WORKING_TOAST = 'Claude is working. Press it again once the turn ends.'
754
755async function sendAnswers($: Engine, mode: 'picked' | 'recommended', isWorking: boolean): Promise<void> {
756 // Mid-turn, a fill would land in the box while Claude works and a no-box submit would queue behind it.
757 if (isWorking) {
758 $.ui.toast(WORKING_TOAST)
759
760 return
761 }
762
763 const held = await $.state.get(CARDS)
764 const current = held.value
765
766 if (current === null || current === undefined) {
767 return
768 }
769
770 const answers = current.questions.map(question => ({
771 number: question.number,
772 value: mode === 'recommended' ? question.recommended : (question.picked ?? question.recommended),
773 }))
774 const missing = answers.filter(answer => answer.value === null).map(answer => `Q${answer.number}`)
775
776 if (missing.length > 0) {
777 $.ui.toast(`Pick an answer for ${missing.join(', ')} first.`)
778
779 return
780 }
781
782 // Clear the cards before anything is sent: a second press finds nothing to send.
783 const claim = await $.state.set(CARDS, null, { ifVersion: held.version })
784
785 if (!claim.isSet) {
786 return
787 }
788
789 const text = answersText(answers.map(answer => ({ number: answer.number, value: answer.value ?? '' })))
790 const filled = await $.prompt.fill({ text, mode: 'replace' }).catch(() => ({ isFilled: false, refusal: undefined }))
791 const outcome = fillOutcome(filled)
792
793 if (outcome === 'filled') {
794 return
795 }
796
797 if (outcome === 'submit') {
798 const result = await $.prompt.submit({ text, asUser: true }).catch(() => ({ drop: 'failed' }) as const)
799
800 if (result.drop !== undefined) {
801 await restoreCards($, current)
802 $.ui.toast('Your answers were not sent: a hook dropped them. The cards are back.')
803 }
804
805 return
806 }
807
808 await restoreCards($, current)
809 $.ui.toast(
810 filled.refusal === 'dialog'
811 ? 'A dialog holds the keys, so the answers were not put in the prompt. The cards are back.'
812 : 'The prompt did not take the answers. The cards are back.',
813 )
814}
815
816async function pickAnswer($: Engine, turnId: string, number: number, value: string): Promise<void> {
817 await update($, cards, held =>
818 held !== null && held.turnId === turnId
819 ? {
820 ...held,
821 questions: held.questions.map(question =>
822 question.number === number && question.options.some(option => option.value === value) ? { ...question, picked: value } : question,
823 ),
824 }
825 : held,
826 )
827}
828
829async function dismissCards($: Engine): Promise<void> {
830 const held = await $.state.get(CARDS)
831
832 if (held.value !== null && held.value !== undefined) {
833 await $.state.set(CARDS, null, { ifVersion: held.version })
834 }
835}
836
837async function markDone($: Engine, item: CockpitWaiting, isWorking: boolean): Promise<void> {
838 if (isWorking) {
839 $.ui.toast(WORKING_TOAST)
840
841 return
842 }
843
844 let isRemoved = false
845
846 await update($, waiting, list => {
847 isRemoved = list.some(held => held.id === item.id && held.text === item.text)
848
849 return isRemoved ? list.filter(held => held.id !== item.id) : list
850 })
851
852 if (!isRemoved) {
853 return
854 }
855
856 // Cleaned again here: an item held from before a reload may predate the parser's cleaning.
857 const result = await $.prompt.submit({ text: `Done: ${personText(item.text)}`, asUser: true }).catch(() => ({ drop: 'failed' }) as const)
858
859 if (result.drop !== undefined) {
860 await update($, waiting, list => (list.some(held => held.id === item.id) ? list : [...list, item]))
861 $.ui.toast('"Done" was not sent: a hook dropped it. The item is back.')
862 }
863}
864
865async function statOrNull($: Engine, path: string) {
866 return $.fs.stat(path).catch(() => null)
867}
868
869/**
870 * Read, insert under "## Captured rules", write: guarded by comparing the
871 * file's mtime and size just before the write with what was read, and tried
872 * once more on a change, since several sessions may capture at once and
873 * $.fs has neither append nor a lock.
874 */
875async function writeRule($: Engine, path: string, sentence: string, preamble: string): Promise<'saved' | 'present' | 'conflict'> {
876 for (let attempt = 0; attempt < 2; attempt += 1) {
877 const before = await statOrNull($, path)
878
879 if (before !== null && before.kind !== 'file') {
880 throw new Error('not a file')
881 }
882
883 const text = before === null ? '' : String(await $.fs.read(path))
884
885 if (hasRule(text, sentence)) {
886 return 'present'
887 }
888
889 const updated = insertCapturedRule(text, sentence, preamble)
890 const check = await statOrNull($, path)
891
892 if ((before?.mtimeMs ?? -1) !== (check?.mtimeMs ?? -1) || (before?.size ?? -1) !== (check?.size ?? -1)) {
893 continue
894 }
895
896 await $.fs.write(path, updated)
897
898 return 'saved'
899 }
900
901 return 'conflict'
902}
903
904async function rememberRule($: Engine, path: string, sentence: string): Promise<void> {
905 try {
906 await $.store.set(capturedKey(await ruleDigest(path, sentence)), true)
907 } catch {
908 // The file itself is the dedupe of record; the store only saves a repeat prompt.
909 }
910}
911
912async function restoreRule($: Engine, held: CockpitRule): Promise<void> {
913 const now = await $.state.get(RULE)
914
915 if (now.value === null || now.value === undefined) {
916 await $.state.set(RULE, held, { ifVersion: now.version })
917 }
918}
919
920async function saveRule($: Engine, target: 'global' | 'project'): Promise<void> {
921 const held = await $.state.get(RULE)
922 const candidate = held.value
923
924 if (candidate === null || candidate === undefined) {
925 return
926 }
927
928 const claim = await $.state.set(RULE, null, { ifVersion: held.version })
929
930 if (!claim.isSet) {
931 return
932 }
933
934 try {
935 const paths = await rulePaths($)
936 const path = target === 'global' ? paths.global : paths.project
937
938 if (path === null) {
939 await restoreRule($, candidate)
940 $.ui.toast('HOME is not set, so there is nowhere to save global rules.')
941
942 return
943 }
944
945 const outcome = await writeRule($, path, candidate.sentence, target === 'global' ? GLOBAL_PREAMBLE : '')
946
947 if (outcome === 'conflict') {
948 await restoreRule($, candidate)
949 $.ui.toast(`${path} kept changing while saving. Nothing was written; press again.`)
950
951 return
952 }
953
954 await rememberRule($, path, candidate.sentence)
955
956 const done = outcome === 'present' ? `Already in ${path}.` : `Saved to ${path}.`
957 // The global file loads only through an import line the person adds; the mod never edits ~/.claude/CLAUDE.md itself.
958 const userClaude = path.replace(/captured-rules\.md$/, 'CLAUDE.md')
959 const isLoaded = target === 'project' || (await isGlobalImported($, userClaude))
960
961 $.ui.toast(isLoaded ? done : `${done} It is not loaded yet: add the line @captured-rules.md to ${userClaude}.`)
962 } catch {
963 await restoreRule($, candidate)
964 $.ui.toast(`Could not save to ${target} rules. Nothing was written.`)
965 }
966}
967
968/** Whether ~/.claude/CLAUDE.md imports the captured-rules file (outside code); unreadable reads as no. */
969async function isGlobalImported($: Engine, userClaude: string): Promise<boolean> {
970 const text = await $.fs.read(userClaude).catch(() => null)
971
972 return text !== null && GLOBAL_IMPORT.test(stripFences(String(text)))
973}
974
975async function dismissRule($: Engine): Promise<void> {
976 const held = await $.state.get(RULE)
977
978 if (held.value !== null && held.value !== undefined) {
979 await $.state.set(RULE, null, { ifVersion: held.version })
980 }
981}
982
983// Hooks -------------------------------------------------------------------------
984
985export const register: Register = (on, options) => {
986 const replies = REPLIES.map(reply => ({ ...reply, text: replyText(options, reply) }))
987 const tokensLine = handoffLine(options)
988
989 // No band surface at start (-p, or the SDK before the desktop attaches): the
990 // next events and session.attach ask again, so nothing polls until one draws.
991 on('session.start', async ($, e, next) => {
992 void arm($, isBandSurface(e.surface)).catch(() => undefined)
993
994 const result = await next(e)
995
996 // After the chain beneath: a plugin there registers its commands during its own session.start.
997 // The context is read too, for a reload (a /clear fires no session.start: session.end below).
998 void lookHost($)
999 void readContext($)
1000
1001 return result
1002 })
1003
1004 // Observer: a /clear arrives only as this, with reason 'clear'; a resume that swaps the
1005 // conversation in this process arrives as 'resume' (the process goes on under another id
1006 // either way, with no session.start). Forgotten even when a hook beneath throws: the
1007 // conversation is gone either way.
1008 on('session.end', async ($, e, next) => {
1009 try {
1010 return await next(e)
1011 } finally {
1012 if (e.reason === 'clear' || e.reason === 'resume') await forgetConversation($)
1013 }
1014 })
1015
1016 on('session.attach', ($, e, next) => {
1017 if (isBandSurface(e.surface)) {
1018 void arm($, true).catch(() => undefined)
1019 }
1020
1021 return next(e)
1022 })
1023
1024 // Observer: records when a tool last ran. Fails open, never refusing a tool.
1025 on('tool.call', async ($, e, next) => {
1026 void arm($).catch(() => undefined)
1027 void stampActivity($)
1028 if (!REPORT_TOOLS.has(e.tool) || e.agentId !== undefined) {
1029 return next(e)
1030 }
1031 // A report to the lead: the main loop's own message, delivered (no error), and for the app's
1032 // send_message addressed to the lead's id. SendMessage names a session by name, which the band
1033 // cannot match to the lead's id, so any delivered SendMessage from the main loop counts.
1034 const result = await next(e)
1035 try {
1036 // Delivered means the tool ran: the engine stamps `ref` on a result it produced, never on one a hook answered in its place.
1037 if (result.deny === undefined && result.isError !== true && typeof result.ref === 'number') {
1038 const hostNow = await read($, host)
1039 // The arguments ride flat on the event (`e.session_id`); an `input` object is read too, should a host nest them.
1040 const flat = e as unknown as Record<string, unknown>
1041 const nested = flat['input'] && typeof flat['input'] === 'object' ? (flat['input'] as Record<string, unknown>) : null
1042 const target = nested?.['session_id'] ?? flat['session_id']
1043 const isToLead = e.tool === 'SendMessage' || (hostNow.lead !== null && target === hostNow.lead)
1044 if (isToLead) await $.state.set(LAST_REPORT_AT, await $.clock.now())
1045 }
1046 } catch {
1047 // the stamp is best effort
1048 }
1049 return result
1050 }).catch(($, e, next) => next(e))
1051
1052 // What the last main-loop Stop listed: background tasks and wakeups an archive would drop.
1053 // Absent arrays say nothing, so the count goes back to unknown rather than reading as none.
1054 on('classic.Stop', async ($, e, next) => {
1055 try {
1056 if (e.agent_id === undefined) {
1057 const tasks = Array.isArray(e.background_tasks) ? e.background_tasks.length : null
1058 const crons = Array.isArray(e.session_crons) ? e.session_crons.length : null
1059 const at = await $.clock.now()
1060 stopSeq += 1
1061 await $.state.set(BACKGROUND, tasks === null || crons === null ? null : { tasks, crons, at, seq: stopSeq, token: loadToken, source: 'stop' })
1062 // The standing must be read after this Stop before the session can read as done.
1063 if (isArmed) void refresh($, true)
1064 }
1065 } catch {
1066 // the previous count stands
1067 }
1068 return next(e)
1069 }).catch(($, e, next) => next(e))
1070
1071 on('turn.start', ($, e, next) => {
1072 isTurnLive = true
1073 void clearPendingFor($, e.text).catch(() => undefined)
1074 // A new turn may start background work: only a Stop after it says what is running.
1075 void $.state.set(BACKGROUND, null).catch(() => undefined)
1076
1077 // A chain beneath that rejects may never see its turn.complete: the flag is undone then.
1078 return next(e).catch(err => {
1079 isTurnLive = false
1080 throw err
1081 })
1082 })
1083
1084 // Subagent turns fire this too: only the main loop's answer feeds the band.
1085 on('turn.complete', async ($, e, next) => {
1086 let result: Awaited<ReturnType<typeof next>>
1087 try {
1088 result = await next(e)
1089 } finally {
1090 // Even when a hook beneath throws: a flag left set would refuse every Hand off press.
1091 if (e.agentId === undefined) isTurnLive = false
1092 }
1093
1094 if (e.agentId === undefined) {
1095 void arm($).catch(() => undefined)
1096
1097 // What is running now (an un-armed session skips the refresh, not the count), then the standing
1098 // and, through it, the host (the command list among it). The context fill is read here too.
1099 void observeBackground($)
1100 void readContext($)
1101
1102 if (e.reason === 'answer') {
1103 await absorbAnswer($, e.answer, e.turnId).catch(() => undefined)
1104 }
1105 }
1106
1107 return result
1108 })
1109
1110 // Observer: the context fill as the engine measures it, for the Hand off chip.
1111 on('session.measure', async ($, e, next) => {
1112 const gen = contextGen
1113 const result = await next(e)
1114 await keepContext($, e.context, undefined, gen)
1115 return result
1116 })
1117
1118 // The duplicate guard fails open: a throw lets the prompt through (the .catch replays next).
1119 on('prompt.submit', async ($, e, next) => {
1120 void arm($).catch(() => undefined)
1121
1122 const kind = e.origin.kind
1123 const isPerson = PERSON_KINDS.has(kind)
1124 const normalized = isPerson ? normalizePrompt(e.text) : ''
1125 const digest = normalized === '' ? null : await sha256Hex(normalized)
1126 const now = await $.clock.now()
1127
1128 if (digest !== null && e.turnId !== undefined && e.wait !== true) {
1129 const held = await $.state.get(RECENT)
1130 const twin = (held.value ?? []).find(entry => entry.hash === digest && now - entry.at <= DUPLICATE_WINDOW_MS)
1131
1132 if (twin !== undefined) {
1133 scheduleRefill($, e.text)
1134
1135 return {
1136 drop: `cockpit: dropped a duplicate. You sent ${promptPreview(e.text)} ${Math.max(1, Math.round((now - twin.at) / 1000))}s ago and a turn is still running; the text goes back in the prompt, and ctrl+x enter queues it anyway.`,
1137 }
1138 }
1139 }
1140
1141 if (digest !== null) {
1142 await update($, recent, list => [...list.filter(entry => entry.hash !== digest), { hash: digest, at: now }].slice(-RECENT_LIMIT))
1143 }
1144
1145 const result = await next(e)
1146
1147 if (result.drop !== undefined) {
1148 // A prompt something beneath dropped was never sent: it cannot be duplicated, and it answers nothing.
1149 if (digest !== null) {
1150 await update($, recent, list => list.filter(entry => !(entry.hash === digest && entry.at === now))).catch(() => undefined)
1151 }
1152
1153 return result
1154 }
1155
1156 await observePrompt($, e.text, kind, now).catch(() => undefined)
1157
1158 return result
1159 }).catch(($, e, next) => next(e))
1160
1161 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1162 // The band is shared with surveys, and a subagent's transcript is not the session's cockpit.
1163 if (e.props.hasSurvey || e.props.view.agentId !== undefined || (e.surface !== 'terminal' && e.surface !== 'desktop')) {
1164 return next(e)
1165 }
1166
1167 const { Box, Button, Select, Text } = $.ui.resolve(e)
1168 const fold = await read($, collapsed)
1169 const queued = await read($, pending)
1170 const standingNow = await read($, standing)
1171 const toolAt = await read($, lastToolAt)
1172 const cardsNow = await read($, cards)
1173 const waitingNow = await read($, waiting)
1174 const ruleNow = await read($, rule)
1175 const reportAt = await read($, lastReportAt)
1176 const backgroundNow = await read($, background)
1177 const hostNow = await read($, host)
1178 const now = await $.clock.now()
1179 const isWorking = e.props.isWorking
1180 const done = completion({
1181 standing: standingNow,
1182 now,
1183 isWorking,
1184 hasPending: queued !== null && now - queued.at < PENDING_EXPIRY_MS,
1185 waiting: waitingNow.length,
1186 openDecisions: cardsNow === null ? 0 : cardsNow.questions.filter(q => q.picked === null).length,
1187 background: backgroundNow,
1188 lead: { state: hostNow.leadState, id: hostNow.lead },
1189 lastReportAt: reportAt,
1190 })
1191 const reason = archiveReason(standingNow)
1192 const contextNow = await read($, context)
1193 // Offered only while a plugin lists the command: nothing advertises a mod that is not there.
1194 const handoffAt =
1195 hostNow.handoffCommand === true
1196 ? handoffBreak({
1197 standing: standingNow,
1198 waiting: waitingNow.length,
1199 background: backgroundNow,
1200 context: contextNow,hooks/parse.ts 1048 lines1// Pure, deterministic text work for the cockpit band: no `$`, no model calls.
2// Kept apart from register.tsx so the tests can hold every parser to its
3// positive and false-positive cases without mounting anything.
4
5import type { CockpitBackground, CockpitCi, CockpitContext, CockpitOption, CockpitPr, CockpitQuestion, CockpitStanding, CockpitWaiting } from '../types'
6
7export const MAX_QUESTIONS = 6
8export const MAX_OPTIONS = 8
9export const MAX_WAITING = 8
10export const MAX_RULE_CHARS = 300
11export const MAX_RULE_PROMPT_CHARS = 2000
12export const STALE_MS = 3 * 60_000
13
14/**
15 * Blanks every fenced code block (``` or ~~~, closed by the same fence or
16 * longer) so option-looking lines and markers inside code never parse, while
17 * the line count stays the same.
18 */
19export function stripFences(text: string): string {
20 const out: string[] = []
21 let fence: { char: string; length: number } | null = null
22
23 for (const line of text.split(/\r?\n/)) {
24 const mark = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1]
25
26 if (fence === null) {
27 if (mark !== undefined) {
28 fence = { char: mark[0] ?? '`', length: mark.length }
29 out.push('')
30 continue
31 }
32
33 out.push(line)
34 continue
35 }
36
37 if (mark !== undefined && mark[0] === fence.char && mark.length >= fence.length) {
38 fence = null
39 }
40
41 out.push('')
42 }
43
44 return out.join('\n')
45}
46
47/**
48 * Collapses a prompt to the form two sends of "the same thing" share: trimmed,
49 * lowercased, whitespace collapsed, trailing punctuation dropped. Exact match
50 * on this, never fuzzy: "go" and "go ahead but skip tests" stay different.
51 */
52export function normalizePrompt(text: string): string {
53 return dropTrailing(text.trim().toLowerCase().replace(/\s+/g, ' '), ' .!?,;:')
54}
55
56/**
57 * The text without its trailing run of `chars`, in one pass from the end. A
58 * `[...]+$` regex rescans a long inner run of those characters once per start
59 * position, which is quadratic, and these run inside prompt and turn hooks.
60 */
61export function dropTrailing(text: string, chars: string): string {
62 let end = text.length
63
64 while (end > 0 && chars.includes(text[end - 1] ?? '')) {
65 end -= 1
66 }
67
68 return text.slice(0, end)
69}
70
71/** SHA-256 of a string as lowercase hex, so recent prompts are compared without keeping their text. */
72export async function sha256Hex(text: string): Promise<string> {
73 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
74
75 return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')
76}
77
78const SECRET_PREFIX =
79 /(?:\bsk-[A-Za-z0-9_-]{8,}|\bgh[pousr]_[A-Za-z0-9]{16,}|\bgithub_pat_|\bshpat_|\bAKIA[0-9A-Z]{16}|\bAIza[0-9A-Za-z_-]{20,}|\bxox[abprs]-|-----BEGIN [A-Z ]*PRIVATE KEY)/
80
81/**
82 * True when a text carries anything shaped like a credential: a known token
83 * prefix, a private-key header, or any unbroken 32+ character token-ish run
84 * (which also catches commit SHAs; erring that way only costs a preview).
85 * Used to keep such text out of drop reasons and out of captured rules.
86 */
87export function looksSecret(text: string): boolean {
88 return SECRET_PREFIX.test(text) || /[A-Za-z0-9_\-+/=]{32,}/.test(text)
89}
90
91/** A short quote of a prompt for a drop reason, or a neutral phrase when quoting it could leak something. */
92export function promptPreview(text: string): string {
93 const flat = text.trim().replace(/\s+/g, ' ')
94
95 return flat.length <= 60 && !looksSecret(flat) ? `"${flat}"` : 'your earlier prompt'
96}
97
98// Decision cards ------------------------------------------------------------
99
100/** Drops quote marks, a heading's hashes, a bullet and bold/underline markers: what is left is matched. */
101const cleanLine = (line: string): string =>
102 line
103 .replace(/\*\*|__/g, '')
104 .replace(/^\s*(?:>\s*)*(?:#{1,6}\s+)?(?:[-*+•]\s+)?/, '')
105 .trim()
106
107const SEP = String.raw`(?:\s*[:.)\-–—]\s*|\s+|$)`
108const Q_HEADER = new RegExp(String.raw`^(?:Q|Question\s*)(\d{1,2})${SEP}(.*)$`, 'i')
109const DECISION_HEADER = new RegExp(String.raw`^Decision\s+(\d{1,2})${SEP}(.*)$`, 'i')
110const NUMBERED_HEADER = /^(\d{1,2})[.)]\s+(.*)$/
111const OPTION_LINE = /^(?:\(([a-h])\)|([a-h])\)|([a-h])\.(?=\s)|Option\s+([a-h])\b(?:\s*[:.)\-–—])?)\s*(.*)$/i
112const INLINE_OPTION = /(?:^|[\s,;])\(?([a-h])\)\s+/gi
113const RECOMMEND_WORD = /\brecommend(?:s|ed|ation)?\b/i
114const RECOMMEND_EVERY = /\brecommend(?:s|ed|ation)?\b/gi
115// The whole of a bracket that marks its option: an allowlist, so "(recommended against)", "(least
116// recommended)" or "(risky; the recommended one is b)" never mark the option they sit on.
117const MARK_ALONE = /^(?:(?:strongly|highly)\s+)?recommended$|^(?:my\s+|the\s+)?recommendation$/i
118// The only prose that names a recommended option: a line that opens with "Recommended:" (or
119// "Recommendation:") and holds nothing else but a bare option list. "I recommend option B",
120// "Recommended against: a", "Recommended: not Q1 a" and "Recommended: (a) is NOT recommended"
121// name none: any word beside the options, negation or not, voids the whole line.
122const RECOMMEND_LINE = /^(?:my\s+)?recommend(?:ed|ation)\s*[:\-–—]\s*(.*)$/i
123// The whole rest of a "Recommended:" line for one question: "b", "(b)", "b)", "option B".
124const RECOMMEND_LETTER = /^(?:option\s+([a-h])|\(([a-h])\)|([a-h])\)?)$/i
125const QUESTION_PAIR = String.raw`Q(\d{1,2})\s*[:=\-]?\s*\(?([a-h])\)?`
126// The whole rest of a "Recommended:" line for several questions: "Q1 a, Q2 c", "Q1: (a); Q2: c".
127const RECOMMEND_PAIRS = new RegExp(String.raw`^${QUESTION_PAIR}(?:\s*(?:[,;&]|and)\s*${QUESTION_PAIR})*$`, 'i')
128const RECOMMEND_PER_QUESTION = new RegExp(QUESTION_PAIR, 'gi')
129// Words that turn a label's leading or closing mark around ("Recommended: no, too risky", "avoid,
130// recommended"); a label carrying any of them is not marked by those forms. A wrong miss only leaves
131// the picker unset; a wrong hit presets the option argued against.
132const NEGATION_CUE =
133 /\b(?:not|no|never|none|nor|neither|nothing|avoid\w*|against|without|except|but|instead|rather|least|less|hardly|rarely|barely|seldom|reject\w*|discourag\w*|risky|unsafe|dangerous|wrong|bad|worse|worst|nope|nah|cannot|non|dont|wont|cant|isnt|arent|wasnt|wouldnt|shouldnt|couldnt|doesnt|didnt)\b|n['’]t\b/i
134
135// A word that, straight before a whole bracket mark, turns it around: "Big bang, not (recommended)".
136// "No" is left out, since "b) No (recommended)" is an option named No.
137const NEGATED_TAIL = /(?:\b(?:not|never|hardly|rarely|barely|seldom|least|less)|n['’]t)[\s,:\-–—]*$/i
138
139/**
140 * The rest of a line that opens with "Recommended:", whole, with its closing
141 * punctuation dropped; null for any other line. Callers accept it only when
142 * all of it is a bare option list, so nothing said beside an option (a
143 * negation, a second option, a reason) can ever be read past.
144 */
145function recommendLine(line: string): string | null {
146 const match = RECOMMEND_LINE.exec(cleanLine(line))
147
148 return match === null ? null : (match[1] ?? '').replace(/[\s.!]+$/, '').trim()
149}
150
151/**
152 * Whether an option's own label marks it as the recommended one, by an
153 * allowlist of whole marks: a bracket holding nothing but the mark
154 * ("(recommended)", "[strongly recommended]", "(my recommendation)") and not
155 * led by a negation ("not (recommended)"), the label opening with
156 * "Recommended:" or closing with "— recommended". The opening and closing
157 * forms count only when nothing else in the label is a negation cue
158 * (NEGATION_CUE), so "Recommended: no, too risky" or "avoid, recommended"
159 * never mark it. Anything else that merely contains the word ("Use the
160 * recommended defaults", "(recommended against)", "(least recommended)",
161 * "NOT RECOMMENDED") never marks it.
162 */
163export function labelMarksRecommended(label: string): boolean {
164 for (const match of label.matchAll(RECOMMEND_EVERY)) {
165 const at = match.index ?? 0
166 const before = label.slice(0, at)
167 const after = label.slice(at + match[0].length)
168 const open = Math.max(before.lastIndexOf('('), before.lastIndexOf('['))
169 const closeAt = after.search(/[)\]]/)
170 const isInBracket = open !== -1 && !/[)\]]/.test(before.slice(open)) && closeAt !== -1 && !/[([]/.test(after.slice(0, closeAt))
171 const bracketText = isInBracket ? `${before.slice(open + 1)}${match[0]}${after.slice(0, closeAt)}`.trim().replace(/[.!]+$/, '') : ''
172 // The bracket is whole; only the words straight before it could turn it around ("Big bang, not (recommended)").
173 const isBracketed = isInBracket && MARK_ALONE.test(bracketText) && !NEGATED_TAIL.test(before.slice(0, open))
174 const isMarkWord = /^recommended$/i.test(match[0])
175 const isLeading = isMarkWord && before.trim() === '' && /^\s*(?:$|[:\-–—]\s)/.test(after) && !NEGATION_CUE.test(after)
176 // A hyphen glued to a word ("not-recommended") is part of that word, never a separator.
177 const isTrailing =
178 isMarkWord &&
179 /(?:\s[-–—]|[–—:,←*|])\s*(?:(?:strongly|highly)\s+)?$/.test(before) &&
180 /^[\s.!)\]*]*$/.test(after) &&
181 !NEGATION_CUE.test(before)
182
183 if (isBracketed || isLeading || isTrailing) {
184 return true
185 }
186 }
187
188 return false
189}
190
191type HeaderKind = 'labelled' | 'numbered' | 'bare'
192type Header = { number: number | null; text: string; kind: HeaderKind }
193
194function headerOf(line: string): Header | null {
195 const labelled = Q_HEADER.exec(line) ?? DECISION_HEADER.exec(line)
196 const match = labelled ?? NUMBERED_HEADER.exec(line)
197
198 return match === null ? null : { number: Number(match[1]), text: (match[2] ?? '').trim(), kind: labelled !== null ? 'labelled' : 'numbered' }
199}
200
201type ParsedOption = { option: CockpitOption; isMarked: boolean }
202
203function optionOf(line: string): ParsedOption | null {
204 const match = OPTION_LINE.exec(line)
205
206 if (match === null) {
207 return null
208 }
209
210 const letter = (match[1] ?? match[2] ?? match[3] ?? match[4] ?? '').toLowerCase()
211 const full = (match[5] ?? '').trim()
212 const label = full || `Option ${letter.toUpperCase()}`
213
214 // The mark is read from the whole label: a long one is cut to 80 characters for the drawing.
215 return letter === '' ? null : { option: { value: letter, label: label.slice(0, 80) }, isMarked: labelMarksRecommended(full) }
216}
217
218/**
219 * Every option letter an explicit "Recommended: b" line in the text names, so
220 * a caller can tell one clear recommendation from a conflict. Prose ("I would
221 * recommend option B", "I would not recommend option A") never names one.
222 */
223function recommendedLettersIn(text: string): Set<string> {
224 const letters = new Set<string>()
225
226 for (const line of text.split('\n')) {
227 const rest = recommendLine(line)
228 const match = rest === null ? null : RECOMMEND_LETTER.exec(rest)
229 const letter = match?.slice(1).find(group => group !== undefined)
230
231 if (letter !== undefined) {
232 letters.add(letter.toLowerCase())
233 }
234 }
235
236 return letters
237}
238
239// What may follow a question's "?" and still leave it asking: nothing, or a cue to choose.
240const CHOICE_CUE = /^(?:\(?(?:pick|choose|select)\b[^)]*\)?|options?|choices?|either|one of)[\s:.)]*$/i
241// An unlabelled question that explains rather than asks: "Why ...?", "How did CI fail?", "What caused it?".
242const EXPLAINS = /^(?:why|how come|how (?:did|does|do|was|were|is|are|has|had)|what (?:caused|happened|went wrong|broke|failed|changed))\b/i
243// A yes/no question about a fact ("Did the parser regress?"): it asks only when it offers a choice.
244const FACT_QUESTION = /^(?:did|does|was|were|has|had|have|is|are)\b/i
245const CHOICE_WORD = /\b(?:which|or|should|shall|prefer|rather|want|would you|ok(?:ay)? to)\b/i
246const YES_NO = /^(?:yes|no|y|n)\b/i
247
248/**
249 * Whether a header or prose line puts a question to the reader: a "?" that
250 * ends it or is followed only by a cue to choose. A question the line answers
251 * itself ("Did it regress? No.", "Why did CI fail? Two reasons:") is prose. An
252 * unlabelled one is prose too when it explains ("Why ...?", "How did CI
253 * fail?") or asks about a fact its own option lines answer ("Did the parser
254 * regress?" over "a) unit tests pass"): a fact question asks only with a
255 * choice word in it or a yes/no among its options.
256 */
257function asksReader(line: string, kind: HeaderKind | 'prose', options: readonly CockpitOption[]): boolean {
258 const at = line.lastIndexOf('?')
259
260 if (at === -1 || !(line.slice(at + 1).trim() === '' || CHOICE_CUE.test(line.slice(at + 1).trim()))) {
261 return false
262 }
263
264 if (kind === 'labelled') {
265 return true
266 }
267
268 if (EXPLAINS.test(line)) {
269 return false
270 }
271
272 return !FACT_QUESTION.test(line) || CHOICE_WORD.test(line) || options.some(option => YES_NO.test(option.label))
273}
274
275/** Options written on the question's own line: "Which store? (a) Postgres (b) SQLite". */
276function inlineOptions(text: string): { question: string; options: CockpitOption[] } | null {
277 const marks = [...text.matchAll(INLINE_OPTION)]
278
279 if (marks.length < 2 || marks[0]?.[1]?.toLowerCase() !== 'a') {
280 return null
281 }
282
283 const options: CockpitOption[] = []
284
285 marks.forEach((mark, index) => {
286 const start = (mark.index ?? 0) + mark[0].length
287 const end = marks[index + 1]?.index ?? text.length
288 const label = text.slice(start, end).trim().replace(/[,;]$/, '').trim()
289
290 options.push({ value: (mark[1] ?? '').toLowerCase(), label: label.slice(0, 80) || `Option ${(mark[1] ?? '').toUpperCase()}` })
291 })
292
293 return { question: text.slice(0, marks[0]?.index ?? 0).trim(), options }
294}
295
296const isUniqueLetters = (options: readonly CockpitOption[]) => new Set(options.map(option => option.value)).size === options.length
297
298/**
299 * The one recommended letter of a question, or null. An option label that
300 * marks itself and a "Recommended: b" line both count; two marks that
301 * disagree are no recommendation at all, so the card never defaults to a
302 * guess (a negated or passing mention never counts, see labelMarksRecommended).
303 */
304function pickRecommended(marked: ReadonlySet<string>, named: ReadonlySet<string>, letters: ReadonlySet<string>): string | null {
305 const said = new Set([...marked, ...[...named].filter(letter => letters.has(letter))])
306
307 return said.size === 1 ? ([...said][0] ?? null) : null
308}
309
310/**
311 * Finds the option questions in an answer, deterministically: a header
312 * (`Q1`, `Question 2`, `Decision 3:`, `1.`, or a bare line with a `?`) followed
313 * by at least two lettered options (`a)`, `(b)`, `C.`, `Option D`), or options
314 * written inline on the header. A block counts only when its header or prose
315 * asks the reader (see asksReader) or a "recommend" sits in it, so numbered
316 * steps with lettered sub-points, self-answered status lists, explanations and
317 * code are skipped. Each question carries the recommended letter when the
318 * block (or a later "Recommended: Q1 a, Q2 c" line) names exactly one.
319 */
320export function parseDecisions(answer: string): CockpitQuestion[] {
321 const lines = stripFences(answer).split('\n')
322 const cleaned = lines.map(cleanLine)
323 const questions: CockpitQuestion[] = []
324 let index = 0
325
326 const nextNonBlank = (from: number): number => {
327 let at = from
328
329 while (at < cleaned.length && cleaned[at] === '') {
330 at += 1
331 }
332
333 return at
334 }
335
336 while (index < cleaned.length && questions.length < MAX_QUESTIONS) {
337 const line = cleaned[index] ?? ''
338 let header = headerOf(line)
339
340 if (header === null && line.includes('?') && optionOf(line) === null) {
341 const following = nextNonBlank(index + 1)
342 const hasOptionsBelow = following < cleaned.length && following <= index + 2 && optionOf(cleaned[following] ?? '') !== null
343
344 header = hasOptionsBelow || inlineOptions(line) !== null ? { number: null, text: line, kind: 'bare' } : null
345 }
346
347 if (header === null) {
348 index += 1
349 continue
350 }
351
352 let questionText = header.text
353 let options: CockpitOption[] = []
354 const marked = new Set<string>()
355 const blockText: string[] = [line]
356 // The header, its prose and a trailing recommendation: what a question or a "Recommended: b" is read from.
357 const notes: string[] = []
358 // The lines that may ask the reader, judged once the options are known.
359 const asking: { text: string; kind: HeaderKind | 'prose' }[] = []
360 let cursor = index + 1
361 const inline = inlineOptions(questionText)
362
363 if (inline !== null) {
364 questionText = inline.question
365 options = inline.options
366 notes.push(inline.question)
367 asking.push({ text: inline.question, kind: header.kind })
368 inline.options.forEach(option => {
369 if (labelMarksRecommended(option.label)) {
370 marked.add(option.value)
371 }
372 })
373 } else {
374 notes.push(header.text)
375 asking.push({ text: header.text, kind: header.kind })
376 // Up to two lines of question text may sit between the header and its options.
377 let prose = 0
378
379 for (;;) {
380 const at = nextNonBlank(cursor)
381 const text = cleaned[at] ?? ''
382
383 if (at >= cleaned.length || at > cursor + 1 || headerOf(text) !== null) {
384 break
385 }
386
387 const parsed = optionOf(text)
388
389 if (parsed !== null) {
390 const option = parsed.option
391
392 if (options.some(held => held.value === option.value)) {
393 break
394 }
395
396 options.push(option)
397
398 if (parsed.isMarked) {
399 marked.add(option.value)
400 }
401
402 blockText.push(text)
403 cursor = at + 1
404 continue
405 }
406
407 if (options.length === 0 && prose < 2) {
408 prose += 1
409 questionText = `${questionText} ${text}`.trim()
410 blockText.push(text)
411 notes.push(text)
412 asking.push({ text, kind: 'prose' })
413 cursor = at + 1
414 continue
415 }
416
417 // An indented continuation of the last option is part of it, not the end of the list.
418 if (options.length > 0 && /^\s{2,}\S/.test(lines[at] ?? '') && !RECOMMEND_WORD.test(text)) {
419 blockText.push(text)
420 cursor = at + 1
421 continue
422 }
423
424 break
425 }
426 }
427
428 // A recommendation line may follow the options directly.
429 const trailing = nextNonBlank(cursor)
430 const trailingText = trailing <= cursor + 1 ? (cleaned[trailing] ?? '') : ''
431
432 if (trailingText !== '' && RECOMMEND_WORD.test(trailingText) && headerOf(trailingText) === null) {
433 blockText.push(trailingText)
434 notes.push(trailingText)
435 cursor = trailing + 1
436 }
437
438 const block = blockText.join('\n')
439 // Any "recommend", even "not recommended", says a choice is on the table; only a "?" that asks counts.
440 const isAsking = asking.some(line => asksReader(line.text, line.kind, options))
441 const isQuestion = options.length >= 2 && isUniqueLetters(options) && (isAsking || RECOMMEND_WORD.test(block))
442
443 if (!isQuestion) {
444 index += 1
445 continue
446 }
447
448 const letters = new Set(options.map(option => option.value))
449 const recommended = pickRecommended(marked, recommendedLettersIn(notes.join('\n')), letters)
450 const number = header.number ?? (questions.at(-1)?.number ?? 0) + 1
451
452 if (!questions.some(held => held.number === number)) {
453 questions.push({
454 number,
455 text: (questionText || `Question ${number}`).slice(0, 200),
456 options: options.slice(0, MAX_OPTIONS),
457 recommended,
458 picked: null,
459 })
460 }
461
462 index = Math.max(cursor, index + 1)
463 }
464
465 // A "Recommended: Q1 a, Q2 c" line anywhere fills in the questions still without one, but only a
466 // line that is nothing but such pairs ("Recommendation: avoid Q1 a; go with Q1 b" names none), and
467 // a question the line names twice with different letters gets none from it.
468 for (const line of cleaned) {
469 const rest = recommendLine(line)
470 const pairs = rest !== null && RECOMMEND_PAIRS.test(rest) ? [...rest.matchAll(RECOMMEND_PER_QUESTION)] : []
471
472 for (const match of pairs) {
473 const question = questions.find(held => held.number === Number(match[1]))
474 const letter = (match[2] ?? '').toLowerCase()
475 const isConflicted = pairs.some(other => Number(other[1]) === Number(match[1]) && (other[2] ?? '').toLowerCase() !== letter)
476
477 if (question !== undefined && !isConflicted && question.recommended === null && question.options.some(option => option.value === letter)) {
478 question.recommended = letter
479 }
480 }
481 }
482
483 return questions
484}
485
486/**
487 * What to do after `$.prompt.fill` with the answers: done when the box took
488 * them; submit as the person only when the engine itself said there is no
489 * box (`no_composer`); otherwise keep the cards. A refusal with no cause (a
490 * hook's, or a failed call) is unknown, never read as "no box".
491 */
492export function fillOutcome(filled: { isFilled: boolean; refusal?: 'no_composer' | 'dialog' | undefined }): 'filled' | 'submit' | 'keep' {
493 return filled.isFilled ? 'filled' : filled.refusal === 'no_composer' ? 'submit' : 'keep'
494}
495
496/** The reply a set of answers sends: "Q1 a, Q2 c" (ASCII by construction, cleaned anyway: it may go out as the person). */
497export const answersText = (answers: readonly { number: number; value: string }[]): string =>
498 personText(answers.map(answer => `Q${answer.number} ${answer.value}`).join(', '))
499
500// Waiting on you --------------------------------------------------------------
501
502// Characters that draw as nothing or move other text: controls, format characters (zero-width,
503// bidi overrides and isolates, the U+E0000 tag block used to smuggle ASCII), private use, lone
504// surrogates, line and paragraph separators, variation selectors and blank-looking letters.
505const INVISIBLE =
506 /[\p{Cc}\p{Cf}\p{Co}\p{Cs}\p{Zl}\p{Zp}\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}\u115F\u1160\u3164\uFFA0\u2800]/gu
507
508/**
509 * The text as it will be seen: every invisible or reordering character
510 * removed and whitespace runs folded to one space.
511 */
512export const visibleText = (text: string): string =>
513 text
514 // A control or line break between two words still parts them once it is gone.
515 .replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ')
516 .replace(INVISIBLE, '')
517 .replace(/\s+/g, ' ')
518 .trim()
519
520// Typographic punctuation, spelled the plain way so what is sent is ASCII the person can read.
521const PLAIN_PUNCTUATION: readonly (readonly [RegExp, string])[] = [
522 [/[\u2018\u2019\u201A\u201B\u2032]/g, "'"],
523 [/[\u201C\u201D\u201E\u201F\u2033\u00AB\u00BB]/g, '"'],
524 [/[\u2010-\u2015\u2212]/g, '-'],
525 [/\u2026/g, '...'],
526 [/[\u2022\u00B7]/g, '*'],
527 [/\u00A0/g, ' '],
528]
529
530/**
531 * Text that goes out as the person's own words (a Done item, an answer, a
532 * quick-reply phrase): printable ASCII only. Invisible characters (controls,
533 * Cf format characters such as zero-width, bidi overrides and the U+E0000 tag
534 * block) are dropped first, accents come off their letters, typographic
535 * punctuation is spelled plainly, and anything else that is not printable
536 * ASCII is dropped. Claude can copy injected text into a marker line, and
537 * the band draws exactly this string, so nothing sent with the person's
538 * authority can hide from them or read differently on screen.
539 */
540export function personText(text: string): string {
541 let plain = visibleText(text).normalize('NFKD').replace(/\p{M}/gu, '')
542
543 for (const [pattern, replacement] of PLAIN_PUNCTUATION) {
544 plain = plain.replace(pattern, replacement)
545 }
546
547 // The normalizing above can bring back invisibles (NFKD of a compatibility form), so clean again last.
548 return visibleText(plain.replace(/[^\x20-\x7E]/g, ''))
549}
550
551// Only the marker is matched by a regex; the item is the rest of the line, cleaned in code. A lazy
552// capture followed by optional trailing markers backtracks cubically over a long inner run of spaces.
553const WAITING_MARK = /^\s*(?:>\s*)*(?:[-*+•]\s+)?(?:\*\*|__)?WAITING ON (?:JOSEPH|YOU)\s*:/i
554export const MAX_WAITING_CHARS = 200
555
556/** Every "WAITING ON JOSEPH:" / "WAITING ON YOU:" line of an answer (outside code), as items. */
557export function parseWaiting(answer: string): CockpitWaiting[] {
558 const items: CockpitWaiting[] = []
559
560 for (const line of stripFences(answer).split('\n')) {
561 const mark = WAITING_MARK.exec(line)
562 const text = mark === null ? undefined : personText(line.slice(mark[0].length).replace(/\*\*|__/g, '')).slice(0, MAX_WAITING_CHARS).trim()
563
564 if (text === undefined || text === '' || items.some(item => item.text === text)) {
565 continue
566 }
567
568 items.push({ id: `w${items.length + 1}`, text })
569
570 if (items.length >= MAX_WAITING) {
571 break
572 }
573 }
574
575 return items
576}
577
578// Rule capture ----------------------------------------------------------------
579
580export const RULE_PHRASE = /\b(going forward|from now on|standing rule|add (this|that) to memory|put (this|that) in memory|remember (that|to))\b/i
581const RULE_PHRASE_FIRM = /\b(?:going forward|from now on|standing rule|add (?:this|that) to memory|put (?:this|that) in memory)\b/i
582const REMEMBER_PHRASE = /\bremember (?:that|to)\b/i
583// "remember that/to" is a rule only as a request: at the sentence's start or after a word that makes it one.
584// "Do you remember that bug?" and "I can't remember to be honest" are not.
585const REMEMBER_LEAD = /^(?:(?:please|pls|also|and|but|so|just|now|then|ok|okay|always|kindly|you should|you must|you need to|make sure to)[\s,]+)*$/i
586const MAX_LEAD_IN_LINES = 5
587
588const stripBullet = (line: string): string => line.replace(/^\s*(?:[-*+•]\s+|\d{1,2}[.)]\s+)?/, '').trim()
589
590// A question that is still a request: "Can you remember that going forward?".
591const REQUEST_QUESTION = /^(?:(?:please|pls|also|and|so|ok|okay)[\s,]+)*(?:can|could|would|will) you\b/i
592
593/**
594 * Whether a sentence states a standing rule: a firm phrase anywhere, or
595 * "remember that/to" asked of Claude. A question ("Is this going forward or
596 * not?") states nothing unless it is a request ("Could you, from now on, ...?").
597 */
598function statesRule(sentence: string): boolean {
599 if (sentence.endsWith('?') && !REQUEST_QUESTION.test(sentence)) {
600 return false
601 }
602
603 if (RULE_PHRASE_FIRM.test(sentence)) {
604 return true
605 }
606
607 const remember = REMEMBER_PHRASE.exec(sentence)
608
609 if (remember === null) {
610 return false
611 }
612
613 const lead = sentence.slice(0, remember.index)
614
615 return lead.length <= 60 && REMEMBER_LEAD.test(lead)
616}
617
618/**
619 * The one sentence of a prompt that states a standing rule, or null. Only
620 * short prompts that are not slash commands are read, code is skipped, a
621 * sentence over 300 characters or one that looks like it holds a credential
622 * is never offered, and the sentence comes back as visibleText (nothing
623 * invisible or reordering in it). A lead-in that ends with a colon ("Going forward:") takes
624 * the lines under it as its rule, joined with "; ".
625 */
626export function findRuleSentence(text: string): string | null {
627 if (text.length >= MAX_RULE_PROMPT_CHARS || text.trimStart().startsWith('/')) {
628 return null
629 }
630
631 const lines = stripFences(text).split('\n')
632
633 for (const [at, line] of lines.entries()) {
634 const sentences = stripBullet(line).split(/(?<=[.!?])\s+/)
635
636 for (const [place, raw] of sentences.entries()) {
637 let sentence = raw.trim()
638
639 if (!statesRule(sentence)) {
640 continue
641 }
642
643 if (sentence.endsWith(':') && place === sentences.length - 1) {
644 const body: string[] = []
645
646 for (const next of lines.slice(at + 1, at + 1 + MAX_LEAD_IN_LINES)) {
647 const item = stripBullet(next)
648
649 if (item === '') {
650 if (body.length > 0) {
651 break
652 }
653
654 continue
655 }
656
657 body.push(item)
658 }
659
660 // A lead-in with nothing under it is no rule.
661 if (body.length === 0) {
662 continue
663 }
664
665 sentence = `${sentence} ${body.join('; ')}`
666 }
667
668 // Drawn, offered and written as it is seen: a tag, bidi or zero-width character pasted along
669 // would otherwise reach a CLAUDE.md that loads as standing instructions, unseen.
670 const seen = visibleText(sentence)
671
672 return seen === '' || seen.length > MAX_RULE_CHARS || looksSecret(seen) ? null : seen
673 }
674 }
675
676 return null
677}
678
679/** A rule's dedupe form: no bullet, lowercased, whitespace collapsed, no trailing punctuation. */
680export const normalizeRule = (sentence: string): string => normalizePrompt(sentence.replace(/^\s*[-*+]\s+/, ''))
681
682export const CAPTURED_HEADING = '## Captured rules'
683
684/**
685 * True when a file already holds the rule as a line of its own (in any
686 * section: a rule anywhere in a loaded CLAUDE.md is already in force),
687 * compared in dedupe form. Lines inside fenced code do not count.
688 */
689export function hasRule(text: string, sentence: string): boolean {
690 const wanted = normalizeRule(sentence)
691
692 return stripFences(text)
693 .split('\n')
694 .some(line => normalizeRule(line) === wanted)
695}
696
697/**
698 * The file's text with the rule added as a bullet at the end of its
699 * "## Captured rules" section (before the next level-1 or level-2 heading),
700 * the section created at the end when the file has none. Never a blind
701 * append: a file whose tail is a generated block keeps that block whole.
702 * Headings inside fenced code are not headings, and a CRLF file gets CRLF lines.
703 */
704export function insertCapturedRule(text: string, sentence: string, preamble = ''): string {
705 const eol = text.includes('\r\n') ? '\r\n' : '\n'
706 const cr = eol === '\r\n' ? '\r' : ''
707 const entry = `- ${sentence.trim()}`
708 // Split on "\n" alone so each line keeps its own "\r" and the file's endings survive as they were.
709 const lines = text.split('\n')
710 // Same line count as `lines` (a fence's lines blanked): where headings are looked for.
711 const visible = stripFences(text).split('\n')
712 const isHeading = (at: number) => /^#{1,2}\s/.test(visible[at] ?? '')
713 const headingAt = visible.findIndex(line => line.trim().toLowerCase() === CAPTURED_HEADING.toLowerCase())
714
715 if (headingAt === -1) {
716 const base = text.trimEnd()
717 const lead = base === '' ? preamble.replace(/\n/g, eol) : `${base}${eol}${eol}`
718
719 return `${lead}${CAPTURED_HEADING}${eol}${eol}${entry}${eol}`
720 }
721
722 let end = headingAt + 1
723
724 while (end < lines.length && !isHeading(end)) {
725 end += 1
726 }
727
728 let insertAt = end
729
730 while (insertAt > headingAt + 1 && (lines[insertAt - 1] ?? '').trim() === '') {
731 insertAt -= 1
732 }
733
734 const added = insertAt === headingAt + 1 ? [cr, `${entry}${cr}`] : [`${entry}${cr}`]
735 const needsGap = end < lines.length && insertAt === end ? [cr] : []
736
737 return [...lines.slice(0, insertAt), ...added, ...needsGap, ...lines.slice(insertAt)].join('\n')
738}
739
740// Standing --------------------------------------------------------------------
741
742const CHECK_OK = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
743const CHECK_FAILED = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'STALE', 'ERROR'])
744
745/**
746 * The CI rollup of a `statusCheckRollup` list. Any failed check is `failing`;
747 * anything running, queued, unknown or absent is `pending`; only a non-empty
748 * list of finished, passing checks is `green`. Right after a push the list is
749 * empty, which must never read as green.
750 */
751export function ciRollup(rollup: unknown): CockpitCi {
752 if (!Array.isArray(rollup) || rollup.length === 0) {
753 return 'pending'
754 }
755
756 let isPending = false
757
758 for (const item of rollup as unknown[]) {
759 const check = typeof item === 'object' && item !== null ? (item as Record<string, unknown>) : null
760
761 if (check === null) {
762 isPending = true
763 continue
764 }
765
766 if ('status' in check || 'conclusion' in check) {
767 const status = String(check.status ?? '').toUpperCase()
768 const conclusion = String(check.conclusion ?? '').toUpperCase()
769
770 if (status === 'COMPLETED' && CHECK_FAILED.has(conclusion)) {
771 return 'failing'
772 }
773
774 isPending ||= status !== 'COMPLETED' || !CHECK_OK.has(conclusion)
775 continue
776 }
777
778 const state = String(check.state ?? '').toUpperCase()
779
780 if (state === 'FAILURE' || state === 'ERROR') {
781 return 'failing'
782 }
783
784 isPending ||= state !== 'SUCCESS'
785 }
786
787 return isPending ? 'pending' : 'green'
788}
789
790/** The pull request from `gh pr view --json ...` stdout, or null when it is not the shape asked for. */
791export function parsePr(stdout: string): CockpitPr | null {
792 let data: unknown
793
794 try {
795 data = JSON.parse(stdout)
796 } catch {
797 return null
798 }
799
800 if (typeof data !== 'object' || data === null) {
801 return null
802 }
803
804 const pr = data as Record<string, unknown>
805
806 if (typeof pr.number !== 'number') {
807 return null
808 }
809
810 const state = typeof pr.state === 'string' ? pr.state.toUpperCase() : 'UNKNOWN'
811
812 return {
813 number: pr.number,
814 state,
815 mergeable: typeof pr.mergeable === 'string' ? pr.mergeable.toUpperCase() : 'UNKNOWN',
816 mergeState: typeof pr.mergeStateStatus === 'string' ? pr.mergeStateStatus.toUpperCase() : 'UNKNOWN',
817 ci: ciRollup(pr.statusCheckRollup),
818 isMerged: state === 'MERGED' || (typeof pr.mergedAt === 'string' && pr.mergedAt !== ''),
819 headSha: typeof pr.headRefOid === 'string' && /^[0-9a-f]{7,64}$/i.test(pr.headRefOid) ? pr.headRefOid.toLowerCase() : null,
820 mergedAt: typeof pr.mergedAt === 'string' && Number.isFinite(Date.parse(pr.mergedAt)) ? Date.parse(pr.mergedAt) : null,
821 }
822}
823
824const MERGEABLE_WORD: Record<string, string> = { MERGEABLE: 'mergeable', CONFLICTING: 'conflicts' }
825
826const minutes = (ms: number) => Math.max(0, Math.floor(ms / 60_000))
827
828/** The PR, CI and merged parts of the standing line, from how the PR lookup went. */
829function prParts(standing: CockpitStanding): string[] {
830 const pr = standing.pr
831
832 if (standing.prStatus === 'ok' && pr !== null) {
833 const isOpen = pr.state === 'OPEN'
834 const mergeable = MERGEABLE_WORD[pr.mergeable] ?? 'mergeable?'
835 const behind = pr.mergeState === 'BEHIND' ? ', behind' : ''
836 const head = isOpen ? `PR #${pr.number} open/${mergeable}${behind}` : `PR #${pr.number} ${pr.state.toLowerCase()}`
837
838 // A MERGED state already says it; any other state says whether it merged.
839 return pr.state === 'MERGED' ? [head, `CI ${pr.ci}`] : [head, `CI ${pr.ci}`, pr.isMerged ? 'merged' : 'not merged']
840 }
841
842 return { none: ['no PR'], error: ['PR unknown'], unavailable: ['PR lookup unavailable'], ok: ['PR unknown'], skipped: [] }[standing.prStatus]
843}
844
845/**
846 * The standing line: "branch · PR #N state/mergeable · CI rollup · merged? ·
847 * idle Nm", with "(stale Nm)" once the last refresh is over three minutes old.
848 */
849export function standingLine(standing: CockpitStanding | null, now: number, lastToolAt: number, isWorking: boolean): string {
850 if (standing === null) {
851 return 'standing: checking…'
852 }
853
854 const parts: string[] = [
855 standing.branch === null
856 ? standing.prStatus === 'unavailable'
857 ? 'git unavailable'
858 : 'no git'
859 : standing.branch === 'HEAD'
860 ? 'detached HEAD'
861 : standing.branch,
862 ]
863
864 // Without a branch there is no PR to speak of.
865 if (standing.branch !== null) {
866 parts.push(...prParts(standing))
867 }
868
869 parts.push(isWorking ? 'working' : lastToolAt > 0 ? `idle ${minutes(now - lastToolAt)}m` : 'no tool calls yet')
870
871 const stale = now - standing.refreshedAt > STALE_MS ? ` (stale ${minutes(now - standing.refreshedAt)}m)` : ''
872
873 return `${parts.join(' · ')}${stale}`
874}
875
876/** Everything the band knows that bears on "is this session done". */
877export type CompletionInput = {
878 standing: CockpitStanding | null
879 now: number
880 isWorking: boolean
881 hasPending: boolean
882 waiting: number
883 openDecisions: number
884 background: { tasks: number; crons: number; at: number; seq: number; token: string; source: 'stop' | 'agents' } | null
885 /** The parent (lead) session: unknown until the app's record was read, else its id or none. */
886 lead: { state: 'unknown' | 'none' | 'known'; id: string | null }
887 lastReportAt: number
888}
889
890/**
891 * Whether the session is fully done, by what the band can observe, read
892 * fail-closed: the standing is fresh (under STALE_MS old, and refreshed after
893 * the latest turn's Stop); the PR is merged at this exact HEAD, or there is
894 * no PR, nothing is ahead of the upstream and HEAD is in the remote default
895 * branch; the tree is clean; nothing is
896 * working, queued or waiting on the person; no decision is open; the Stop
897 * after the latest turn listed no background task or wakeup; and the lead
898 * session, once known, was told after the merge was first seen. Each unmet
899 * condition is a short blocker; anything unknown is one too.
900 */
901export function completion(input: CompletionInput): { isReady: boolean; blockers: string[] } {
902 const blockers: string[] = []
903 const s = input.standing
904
905 if (s === null) {
906 blockers.push('standing unknown')
907 } else {
908 if (input.now - s.refreshedAt > STALE_MS) blockers.push('standing stale')
909 // Readings count only when the refresh started after the latest Stop, by sequence, never by a clock tie.
910 if (input.background !== null && (s.stopSeq !== input.background.seq || s.stopToken !== input.background.token)) blockers.push('standing older than the last turn')
911 if (s.branch === null) blockers.push('no git branch')
912 if (s.prStatus === 'ok' && s.pr !== null) {
913 if (!s.pr.isMerged) {
914 blockers.push(`PR #${s.pr.number} ${s.pr.state === 'OPEN' ? 'open' : s.pr.state.toLowerCase()}`)
915 } else if (s.pr.headSha === null || s.headSha === null) {
916 blockers.push('merged head unknown')
917 } else if (!s.headSha.startsWith(s.pr.headSha) && !s.pr.headSha.startsWith(s.headSha)) {
918 blockers.push('commits after the merged PR')
919 }
920 } else if (s.prStatus === 'none') {
921 // No PR: the work must already be in the remote default branch, and nothing unpushed.
922 // A repository with no remote at all has nowhere to push or merge: nothing to wait for.
923 if (s.hasRemote === false) {
924 // nothing to ship
925 } else if (s.hasRemote === null) {
926 blockers.push('remotes unknown')
927 } else {
928 if (s.aheadOfUpstream === null) blockers.push('unpushed commits unknown (no upstream)')
929 else if (s.aheadOfUpstream > 0) blockers.push(`${s.aheadOfUpstream} unpushed commit${s.aheadOfUpstream === 1 ? '' : 's'}`)
930 if (s.defaultBranch === null || s.inDefault === null) blockers.push('default branch unknown')
931 else if (!s.inDefault) blockers.push(`not in ${s.defaultBranch}`)
932 }
933 } else {
934 blockers.push('PR unknown')
935 }
936 if (s.isClean === false) blockers.push(`${s.dirtyFiles ?? '?'} uncommitted change${s.dirtyFiles === 1 ? '' : 's'}`)
937 else if (s.isClean !== true) blockers.push('working tree unknown')
938 }
939
940 if (input.isWorking) blockers.push('working')
941 if (input.hasPending) blockers.push('reply queued')
942 if (input.waiting > 0) blockers.push(`${input.waiting} waiting on you`)
943 if (input.openDecisions > 0) blockers.push(`${input.openDecisions} decision${input.openDecisions === 1 ? '' : 's'} open`)
944
945 if (input.background === null) {
946 blockers.push('background work unknown')
947 } else {
948 if (input.background.tasks > 0) {
949 const n = input.background.tasks
950 blockers.push(input.background.source === 'agents' ? `${n} running agent${n === 1 ? '' : 's'}` : `${n} background task${n === 1 ? '' : 's'}`)
951 }
952 if (input.background.crons > 0) blockers.push(`${input.background.crons} wakeup${input.background.crons === 1 ? '' : 's'} scheduled`)
953 }
954
955 if (input.lead.state === 'unknown') {
956 blockers.push('lead session unknown')
957 } else if (input.lead.state === 'known') {
958 // The report must follow the merge: GitHub's merge time for a PR, else the refresh that first
959 // saw HEAD in the default branch. Neither known is a blocker, never a pass.
960 const merged = s?.pr?.isMerged === true
961 const since = merged ? s?.mergedSeenAt : s?.prStatus === 'none' ? s?.inDefaultSeenAt : null
962 if (typeof since !== 'number') blockers.push(merged ? 'merge time unknown' : 'merge time unknown (no PR)')
963 else if (!(input.lastReportAt > 0 && input.lastReportAt >= since)) blockers.push('lead session not told')
964 }
965
966 return { isReady: blockers.length === 0, blockers }
967}
968
969/** The reason the archive request carries, from the standing. */
970export function archiveReason(standing: CockpitStanding | null): string {
971 const pr = standing?.pr
972 const what = pr && pr.isMerged ? `PR #${pr.number} merged` : standing?.hasRemote === false ? 'no remote, all committed' : 'no PR'
973 return `${what}, tree clean, nothing waiting`
974}
975
976/** What the band knows that bears on "is this a natural break to hand the session off". */
977export type HandoffBreakInput = {
978 standing: CockpitStanding | null
979 /** Items waiting on the person (the `WAITING ON JOSEPH:` lines of the last answer). */
980 waiting: number
981 background: CockpitBackground | null
982 context: CockpitContext | null
983 isWorking: boolean
984 hasPending: boolean
985 /** The option `handoffTokens`: an absolute token count, or 0 for 80 % of the window. */
986 tokensLine: number
987}
988
989export type HandoffBreak = { reason: 'merged' | 'waiting' | 'context'; text: string }
990
991/** A token count the way the chip shows it: 162k, 1.2M. */
992export function formatTokens(tokens: number): string {
993 if (tokens < 1000) return String(Math.max(0, Math.round(tokens)))
994 if (tokens < 999_500) return `${Math.round(tokens / 1000)}k`
995 return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
996}
997
998/**
999 * Whether now is a natural break to offer a hand-off to a fresh session, and
1000 * why: a PR merged at this exact HEAD (the same head test `completion` uses)
1001 * and seen merged no earlier than this session began (`mergedSeenAt`, GitHub's
1002 * merge time or the refresh that first saw it, against `context.startedAt`,
1003 * which /clear starts over: a fresh session on a branch merged before it is
1004 * not at a break, and an unknown start or merge time is no break);
1005 * items waiting on the person with no agent running (an observation of the
1006 * running agents is required: unknown is no break); or the context past an
1007 * absolute token line (`tokensLine`, 0 meaning 80 % of the window, and no
1008 * window then no line). Never while a turn runs or a reply is queued. One
1009 * reason at most, by precedence merged, waiting, context.
1010 *
1011 * Deliberately not a break: no PR with HEAD already in the default branch,
1012 * which a fresh session on main reads as from its first moment, so it would
1013 * be offered a hand-off at once.
1014 */
1015export function handoffBreak(input: HandoffBreakInput): HandoffBreak | null {
1016 if (input.isWorking || input.hasPending) return null
1017
1018 const s = input.standing
1019 const pr = s?.pr ?? null
1020 const startedAt = input.context?.startedAt ?? null
1021 if (
1022 s !== null &&
1023 pr !== null &&
1024 pr.isMerged === true &&
1025 pr.headSha !== null &&
1026 s.headSha !== null &&
1027 (s.headSha.startsWith(pr.headSha) || pr.headSha.startsWith(s.headSha)) &&
1028 s.mergedSeenAt !== null &&
1029 startedAt !== null &&
1030 s.mergedSeenAt >= startedAt
1031 ) {
1032 return { reason: 'merged', text: `PR #${pr.number} merged` }
1033 }
1034
1035 if (input.waiting > 0 && input.background !== null && input.background.tasks === 0) {
1036 return { reason: 'waiting', text: 'waiting on you, no agents running' }
1037 }
1038
1039 const tokens = input.context?.tokens
1040 if (typeof tokens === 'number' && Number.isFinite(tokens)) {
1041 const window = input.context?.window
1042 const line = input.tokensLine > 0 ? input.tokensLine : typeof window === 'number' && window > 0 ? 0.8 * window : Infinity
1043 if (tokens >= line) return { reason: 'context', text: `context ${formatTokens(tokens)}` }
1044 }
1045
1046 return null
1047}
1048types/index.d.ts 135 lines1// The cockpit band's session state: every $.state key the hooks module names,
2// held by the host for the session so it survives a hot reload. Any plugin can
3// read these values, so none of them carries raw prompt text except the rule
4// sentence the person is asked to confirm (shown verbatim before any write).
5
6/** A quick reply the band has handed to $.prompt.submit and not yet seen start. */
7export type CockpitPending = { text: string; at: number }
8
9/** One recent composer prompt, by the SHA-256 of its normalized text (never the text). */
10export type CockpitRecent = { hash: string; at: number }
11
12/** The CI rollup as the band shows it: anything empty or unrecognized is `pending`, never `green`. */
13export type CockpitCi = 'green' | 'failing' | 'pending'
14
15/** The pull request for the session's branch, as `gh pr view --json` reported it. */
16export type CockpitPr = {
17 number: number
18 state: string
19 mergeable: string
20 mergeState: string
21 ci: CockpitCi
22 isMerged: boolean
23 /** The PR head commit (`headRefOid`); null when gh did not say. A merged PR counts as done only at this exact HEAD. */
24 headSha: string | null
25 /** When GitHub merged it (`mergedAt`), ms since the epoch; null when not merged or unreadable. */
26 mergedAt: number | null
27}
28
29/**
30 * The standing line's last refresh. `prStatus` says how the PR lookup went:
31 * `ok` (pr set), `none` (no PR for the branch), `error` (the CLI ran and failed),
32 * `unavailable` (the CLI could not start), `skipped` (git failed, so no lookup).
33 */
34export type CockpitStanding = {
35 branch: string | null
36 pr: CockpitPr | null
37 prStatus: 'ok' | 'none' | 'error' | 'unavailable' | 'skipped'
38 refreshedAt: number
39 /** `git status --porcelain` empty; null when git could not say. */
40 isClean: boolean | null
41 /** Entries `git status --porcelain` listed (modified and untracked alike); null when git could not say. */
42 dirtyFiles: number | null
43 /** `git rev-parse HEAD`; null when git could not say. */
44 headSha: string | null
45 /** Commits on HEAD not on its upstream (`git rev-list --count @{u}..HEAD`); null without an upstream or when git could not say. */
46 aheadOfUpstream: number | null
47 /** Whether the repository has any remote (`git remote` lists one); null when git could not say. A repository with none has nowhere to push or merge: a clean tree is as done as it gets. */
48 hasRemote: boolean | null
49 /** The remote's default branch (`origin/main`), from `refs/remotes/origin/HEAD`; null when unknown. */
50 defaultBranch: string | null
51 /** HEAD is contained in the remote default branch (`git merge-base --is-ancestor`); null when git could not say. */
52 inDefault: boolean | null
53 /** When the PR was merged: GitHub's `mergedAt`, else the refresh that first saw it merged (kept while the same PR stays merged); the lead must be told after this. */
54 mergedSeenAt: number | null
55 /** The `seq` and `token` of the last main-loop Stop seen when this refresh started: readings count only for that exact Stop. */
56 stopSeq: number
57 stopToken: string
58 /** With no PR: when a refresh first saw HEAD in the remote default branch (kept while it stays so on the same branch); the lead must be told after this. */
59 inDefaultSeenAt: number | null
60}
61
62/**
63 * What the host told the band: whether the app's archive tool is listed, the parent (lead)
64 * session, and whether the handoff mod's `/handoff` command is listed. `leadState` is `unknown`
65 * until the app's record was read once; a later failed read never clears a lead it knew.
66 * `handoffCommand` is absent in state saved by cockpit 0.1.x: read it as false.
67 */
68export type CockpitHost = { archiveTool: boolean; lead: string | null; leadState: 'unknown' | 'none' | 'known'; handoffCommand: boolean }
69
70/**
71 * The live context window as last measured (`session.measure`, or `$.session.usage()` after a
72 * main-loop turn): `tokens` the fill, null until a response reported one (and after /clear);
73 * `window` the model's window, null when unknown; `startedAt` when the session these figures count
74 * from began (`$.session.usage().startedAt`, which /clear starts over), null until read and set back to null by a /clear until read again. Absolute
75 * tokens drive the Hand off chip; `startedAt` keeps a merge from before the session from being a break.
76 */
77export type CockpitContext = { tokens: number | null; window: number | null; at: number; startedAt: number | null }
78
79/**
80 * Background work an archive would drop, as last observed after the latest turn; null until then.
81 * `source` 'stop': the main-loop classic.Stop listed tasks and wakeups. `source` 'agents': the host
82 * raises no classic.Stop (the desktop), so `tasks` is the running subagents from `$.agent.list()` and
83 * `crons` is unknown (0): background shell tasks and wakeups stay the app's to guard there.
84 * `seq` counts observations this load; `token` marks the load.
85 */
86export type CockpitBackground = { tasks: number; crons: number; at: number; seq: number; token: string; source: 'stop' | 'agents' }
87
88export type CockpitOption = { value: string; label: string }
89
90/** One option question parsed from the last answer; `picked` is the person's Select choice. */
91export type CockpitQuestion = {
92 number: number
93 text: string
94 options: CockpitOption[]
95 recommended: string | null
96 picked: string | null
97}
98
99export type CockpitCards = { turnId: string; questions: CockpitQuestion[] }
100
101export type CockpitWaiting = { id: string; text: string }
102
103/** A standing-rule sentence detected in the person's prompt, awaiting their choice of file. */
104export type CockpitRule = { sentence: string; detectedAt: number }
105
106/** Which band sections are folded to their toggle alone. */
107export type CockpitCollapsed = {
108 replies: boolean
109 standing: boolean
110 cards: boolean
111 waiting: boolean
112 rule: boolean
113}
114
115declare module 'claude-code' {
116 interface PluginState {
117 cockpit: {
118 pending: CockpitPending | null
119 recent: CockpitRecent[]
120 standing: CockpitStanding | null
121 lastToolAt: number
122 cards: CockpitCards | null
123 waiting: CockpitWaiting[]
124 rule: CockpitRule | null
125 collapsed: CockpitCollapsed
126 /** When this session last sent a message to another session (SendMessage or the app's send_message). */
127 lastReportAt: number
128 background: CockpitBackground | null
129 host: CockpitHost
130 /** The context window's fill as last measured; null until the first reading. */
131 context: CockpitContext | null
132 }
133 }
134}
135