SLOPSHOPPER

cockpit

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…

newbandguardtoastpromptprocess
v0.2.0MITupdated 2026-10-12Joseph-Ayoub/claude-mods/cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ▾ Replies [ Go ] [ Resume ] [ Try again ] [ Status ] [ Session completion ] ▾ Standing feat/auth-refresh · PR unknown · idle 0m archive after: PR unknown, 2 uncommitted changes, background work unknown, lead session unknown ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▾ Replies [ Go ] [ Resume ] [ Try again ] [ Status ] [ Session completion ] ▾ Standing feat/auth-refresh · PR unknown · idle 0m archive after: PR unknown, 2 uncommitted changes, background work unknown, lead session unknown
README

cockpit

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.

What it shows

Each section folds to its toggle (the ▾/▸ label on its left); the fold is kept for the session.

  1. Replies. Five buttons, each sending a fixed phrase as your own words ($.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.
KeyButtonSends (default)
-GoGo ahead.
-ResumeResume from where you left off; do not restart from scratch.
tTry againTry again.
sStatusStatus update: what ran and passed, what is merged, what is pending, and the link where it can be seen.
-Session completionIs 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.

  1. Standing. One line: 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.
  1. Decisions. After each main-loop answer, deterministic patterns look for option questions: a header (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.
  2. The recommendation. Only two things preset a picker.
  3. A line that opens with 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.
  4. The option's own label carrying a whole mark: a bracket holding nothing else ((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.
  5. Prose never names an option (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.
  6. Each question gets a picker preset to the recommended option; Send answers puts 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.
  7. The cards clear when you send your next prompt (typed, from a phone, or from the desktop app). A background-task notification, a peer session's message, or a prompt that a hook beneath blocked does not clear them.
  1. Waiting on you. Lines in the last answer starting 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.
  1. Save as a standing rule? When a prompt you type contains "going forward", "from now on", "standing rule", "add/put this/that to/in memory", or a request to "remember that/to" (not bare "always" or "never"; "Do you remember that bug?" and "I can't remember" are not requests), the band shows that sentence with Global rules, Project rules and Dismiss. A lead-in ending in a colon ("Going forward:") takes the lines under it as the rule ("Going forward: use pnpm; no yarn"). Nothing is written until you press one. The sentence is cleaned of invisible and reordering characters (controls, zero-width, bidi overrides, the Unicode tag block) before it is shown, so what the band shows is exactly what is written to the file, which loads as standing instructions in later sessions.
  2. Global writes to $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.
  3. Project writes to <session root>/CLAUDE.md.
  4. The rule goes under a ## 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.
  5. Before offering, the band checks the global file and this project's 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.
  1. Hand off at a natural break. Joseph's standing rule is to end a lead session at a milestone and continue in a fresh one from a written note. At a natural break the Standing row offers Hand off, followed by a dim reason:
  2. 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;
  3. 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);
  4. 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.

Hooks it registers

HookKindWhat it does
session.startobserverArms 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.endobserverAfter 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.attachobserverArms the refresh when the desktop app attaches to a session that started without a band surface.
tool.callobserver, gating site with .catch that replays nextStamps 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.startobserverClears a pending quick reply once its turn starts, and marks a main-loop turn in flight (a Hand off press re-checks it).
turn.completeobserverMain 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.measureobserverKeeps the context fill (e.context.tokens and window) after next(e), written only when it moved, for the Hand off chip.
prompt.submitguard, 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 AbovePromptdrawingDraws 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.

Options

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

Loading it

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

Privacy

  • Recent prompts are compared by the SHA-256 of their normalized text; the text itself is never kept.
  • A drop reason quotes your prompt only when it is 60 characters or less and has nothing token-shaped in it; otherwise it says "your earlier prompt".
  • A sentence that looks like it holds a credential (known token prefixes, private-key headers, any unbroken 32+ character run) is never offered as a rule.
  • For the Hand off chip it reads the command list ($.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.

Known limits

  • The band draws on the terminal and the desktop Code tab only; VS Code and mobile never raise it.
  • The standing line needs 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.
  • If the refresh timer died with a hot reload, it is re-armed by the next session event (a prompt, a tool call, a finished turn), not by a redraw; until then the line shows (stale Nm) once its data is over three minutes old.
  • Decision cards come from patterns, not understanding: an unusual layout can be missed, and a lettered list under a real question can still be read as one. Dismiss clears a wrong card. The recommendation rules are strict on purpose, so an unusual but honest recommendation ("b) Postgres, which I recommend", "I recommend option B" in prose, 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.
  • When the prompt box refuses the answers for a reason the engine does not name (another plugin's refusal), the cards stay rather than being submitted, since only a definite "no prompt box" is safe to submit through. That fallback is unit-tested (fillOutcome) but not end to end: the test kit strips the refusal's cause.
  • Clicking a band control may leave the keyboard focus on the band, where a typed 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.
  • Text sent as your words is ASCII only: a Done item in another script loses those characters (the band shows what is left before you press).
  • The PR lookup is one GitHub API call per session per minute while a band surface is attached.
  • Another plugin drawing the same band above this one hides it; this one does not draw what plugins beneath it would have (by design: it is meant to be the band's one owner).
  • VS Code and mobile get nothing from this mod: there is no status-line or suggestion fallback for them (verifier V10; the spec does not require one, awaiting the lead's acceptance).
  • While Claude works, a quick-reply press says 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).
  • The duplicate guard, rule capture and waiting list treat 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.
  • After you click a band button, the keyboard focus can stay on it, and the declarations say Enter under the focus presses a focused Button. So an Enter pressed while the band holds the focus could press Go, Resume or Session completion again once the turn ends. Send answers, Done and the rule buttons cannot fire twice (each clears what it sends). Not reproduced: no inte
Source 3 files
hooks/register.tsx 1398 lines
1// 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 lines
1// 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}
1048
types/index.d.ts 135 lines
1// 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