Never lose a session to the context limit: it watches context fill, writes a handoff into .claude/brain/ in time, then clears and resumes from it.

An add-on plugin of the loopkeeper set (its command stays /handoff). Never lose a session to the context limit. The plugin watches context fill and writes a session handoff into the project's handoff folder, .claude/brain/ (see The handoff folder), before the limit is reached. It then clears the session and continues from that handoff. What the session knew is written down as plain Markdown (status, next steps, dead ends, decisions, open questions) that you and the next session can read, instead of living only in the summary a compaction makes.
Use it when
Install: /plugin install lk-handoff-guard@loopkeeper, after /plugin marketplace add connectwithub/loopkeeper (more under Install). Requires Claude Code 2.1.294 or later.
Data handling: it keeps its settings, the last brief and a pending resume entry in its own plugin store, and its context readings in session state. It reads ~/.claude/handoff-guard.json and <project>/.claude/handoff-guard.json, and writes only the four handoff files in <project>/.claude/brain/. It runs read-only git rev-parse and git log. Each handoff makes one model call on your own Claude Code session, which spends tokens on your account; it sends nothing over the network of its own. It collects no personal data; the handoff files and saved settings stay until you delete them. Details under Footprint.
Commands: /handoff and its options are listed under Commands.
How it behaves as the context fills:
warnAt (70%): a quiet band, handoff ● ctx 41% in the ok colour.warnAt: the chip turns to the Claude orange (the kit's accent tone, not the theme's warning colour), and one toast says "context 72%: run /handoff when you reach a stopping point".handoffAt (85%), the chip turns red, the band's right side ("handoff due: /handoff clear") takes the default text colour instead of the quiet one, and the mode decides what happens:session.compact, auto or manual): if no handoff has been written since the last clear or compaction, the mod writes one first. Compaction then goes ahead, because the mod never blocks it.denyAt (95%), new tool calls get a reason telling Claude that the handoff is being written.Context fill comes from $.session.usage() at three points:
session.start;turn.complete;turn.step generator hook.The turn.step check also adds an estimate of tool output that has landed since the last response. That way a long, tool-heavy turn is caught mid-turn: the turn ends there, and the handoff runs. Subagent events (e.agentId) are ignored for measurement.
Needs Claude Code 2.1.294 or later. From the loopkeeper marketplace, inside a Claude Code session:
/plugin marketplace add connectwithub/loopkeeper
/plugin install lk-handoff-guard@loopkeeper
Turn it off or on again with /plugin disable lk-handoff-guard@loopkeeper and /plugin enable lk-handoff-guard@loopkeeper. To try it from a clone without installing it: claude --plugin-dir /path/to/loopkeeper/plugins/lk-handoff-guard. Check and test it from its folder with claude plugin validate --strict . and claude plugin test ..
| Command | What it does | | :- | :- | | /handoff | Write the handoff now, without clearing | | /handoff clear | Write the handoff, then /clear and resume from it | | /handoff status | Context %, levels, mode, and the last handoff's time and path | | /handoff show | Pane with the handoff state, the last 10 readings, the last brief, and a Write button (see Pane) | | /handoff mode ask\|auto\|off | Set the mode | | /handoff at <0-1> | Handoff level (0.85, 85 and 85% all work) | | /handoff warn <0-1> | Warning level | | /handoff band on\|off | Show or hide the band. The band stacks other mods' band content under its own line. |
/handoff show opens a pane drawn with the kit (hooks/kit.ts, a verbatim copy of the loopkeeper core's kit v3): bordered, unfilled cards on every surface, with ● text badges in the terminal and fully rounded Svg pills in the Desktop app. At the top, a hero card (kicker HANDOFF GUARD) gives the handoff state in the band's words, such as "Context 72% · /handoff at a stopping point", "handoff due: /handoff clear" or "next handoff at 85%", with the tokens, the mode and the last handoff's time under it. Levels use theme colours: ok in green, warn in the accent orange, due in red (the hero, the Context tile, the bars and the level badges); the warn pill is orange in the Desktop app too. Tiles show Context, Warn at (orange), Handoff at (red) and Mode, and a bar shows the context fill. The READINGS · n section lists the last 10 readings, each with a level badge (ok, warn, due), its time, where it was taken and its own bar. The LAST HANDOFF section shows the brief's source as a badge (fork and complete in green, facts in orange), its path, any unverified figures (an orange badge), and the brief itself. The Write handoff (w) and Close (c) buttons sit at the bottom. The band above the prompt is one kit line: the bold label, the context chip in the level's colour, and the right side in the quiet colour, or in the default text colour once the handoff is due.
The layers are merged in this order, and later layers win:
~/.claude/handoff-guard.json;<cwd>/.claude/handoff-guard.json;/handoff commands saved in $.store.{ "warnAt": 0.70, "handoffAt": 0.85, "reaskAt": 0.92, "denyAt": 0.95,
"mode": "ask", "model": "haiku", "maxBriefTokens": 2000, "band": true }
Levels are kept in order (warn ≤ handoff ≤ reask ≤ deny), and bad values are dropped.
$.process.run git);$.session.messages());AskUserQuestion calls with no answer;Credential-shaped strings are redacted.
$.model.fork first, because it reuses the session's prompt cache. If that fails, it falls back to $.model.complete with model and maxBriefTokens, and a compact transcript. The template has six headings: Status, Next steps, Dead ends, Open questions, Decisions (pointers only), Log (one line). A reply is accepted only if it has all six headings. Otherwise only the facts are written. After that, markUnverifiedFigures appends (unverified) to any number in the model text that does not appear in the facts. It skips counts under 10, dates, times, versions and inline code.$.fs, so it needs no script, python or other tool, and anything else that reads the folder (a resume hook at session start, a sync at session end) keeps working unchanged.project.md: rewrites only ## Status today, ## Next and ## Dead ends. Dead ends is left alone when the model found none. The other sections are kept, the frontmatter updated: field is set to today, and a stamp records the time, context % and branch. If the file is missing, it is created from the template described under The handoff folder.log.md: one - YYYY-MM-DD — text line per session, under 300 characters. A second handoff in the same session replaces the session's own line while it is still the last line.decisions.md and questions.md: dated lines are appended, and exact duplicates are skipped./handoff clear). A pending entry goes into $.store, and then $.command.run({command:'clear'}) runs from a short $.clock.after timer. The timer is needed because a command.run hook may not run a command, and a turn.step hook holds the turn. On the next classic.SessionStart with source=clear, the mod deletes the entry and submits one line: "Resume from the handoff in .claude/brain/project.md (written <time>); read it first."Why $.store, not module variables (claude-auto-handoff's choice). A hot reload of the module between the handoff and the clear would lose a module variable. $.store survives both a reload and the clear, and it is keyed by working directory. $.state holds only per-session state (readings, warned or asked flags, edited files). /clear resets it, which is the reset the mod wants. The entry is deleted before the prompt is submitted, so a session is never seeded twice, and entries older than 15 minutes are ignored.
The handoff lives in the project's .claude/brain/ folder as four Markdown files, written for people and for any tool that wants to pick a session up again:
project.md: YAML frontmatter (type, name, summary, tags, created, updated and a few status fields), then the sections ## What it is, ## Why, ## Status today, ## Next and ## Dead ends. The plugin rewrites only the last three and updated:; the first two are yours. A new file starts from a template whose own sections say "Not filled in yet".log.md: a # Log header, then one line per session, newest last: - YYYY-MM-DD — what changed, what is next, what is blocked, under 300 characters.decisions.md: a # Decisions header, then - YYYY-MM-DD — decision and why lines. Append only; to reverse a decision, add a new dated line.questions.md: a # Questions header, then - YYYY-MM-DD — question lines for questions only a person can answer. Remove a line once it is answered and record the answer as a decision.After /clear, the resume prompt points at project.md.
What claude plugin validate --strict . reports for this plugin (Claude Code 2.1.294), and what that means:
| Area | What the plugin has |
|---|---|
| Hooks module | One: ./register.ts, in hooks/hooks.json. |
| Hook events | session.start, classic.SessionStart, prompt.submit, turn.start, turn.step, turn.complete, tool.call, session.compact, command.run (handoff), ui.render (the band; the handoff-guard pane). |
| Refusals | tool.call refuses new tool calls only in auto mode past denyAt (95%); turn.step ends a turn to hand off. Compaction is never blocked. |
Calls ($.…) | clock (after, now); command (register, run); env.get; fs (exists, read, write); model (complete, fork); process.run; prompt.submit; session (cwd, id, messages, usage); state (get, set); store (delete, get, set); ui (ask, close, open, resolve, toast). |
| State | 16 atoms under lk-handoff-guard; it also reads the loopkeeper core's stageCurrent when the core is on. |
| Store | The /handoff settings, the last brief and the pending resume entry, keyed by working directory. |
| Env | Reads HOME; writes nothing. |
What it runs:
git rev-parse --abbrev-ref HEAD and git log -5 --pretty=format:%h %s, read-only.~/.claude/handoff-guard.json and <project>/.claude/handoff-guard.json; reads and writes project.md, log.md, decisions.md and questions.md in <project>/.claude/brain/. Nothing else.$.model.fork (which reuses the session's prompt cache), else $.model.complete with model (haiku) and maxBriefTokens. Both spend tokens on your account. If both fail, only the facts are written.$.prompt.submit) in the fresh session./clear, after a handoff in the ask and auto flows and on /handoff clear.The directory's validator listed these items for this plugin. Each bullet says what the code does and where (hooks/register.ts unless named).
MOD_RUNS_PROCESS, MOD_PROCESS_COMMAND_COMPUTED). Only git, from one helper, git() (line 279): $.process.run(['git', ...argv], { cwd, timeoutMs: 5000 }), in the session's directory. Its two calls are in collectFacts() (lines 306–307): git rev-parse --abbrev-ref HEAD and git log -5 --pretty=format:%h %s. Both read only; they put the branch and the last five commit subjects in the handoff's facts.MOD_LOCAL_DATA_LEAVES, MOD_SESSION_DATA_LEAVES). The arguments to git are fixed, so nothing read goes into a command. The conversation ($.session.messages(), line 298) is read when a handoff is written, to draft the note: first with $.model.fork (line 340), a fork of the session itself with a prompt that holds the facts block, else with $.model.complete (line 347) on the configured model (default haiku) with the most recent part of the transcript. Both go through Claude Code to the model service the session already uses; there is no other destination. The text the plugin composes for these prompts passes through redact() in hooks/logic.ts, which replaces credential-shaped strings with [redacted].MOD_WRITES_BUILD_FILE, note MOD_WRITES_FILES). All writes are in writeBrain() (lines 372, 380, 395) and go to <project>/.claude/brain/ only: project.md (its Status today, Next and Dead ends sections and the updated date), log.md (one dated line per session) and decisions.md / questions.md (dated lines appended). Each path is join(cwd, '.claude/brain', name) with the name written in the code. These are the user's own project notes in Markdown, not build, start-up or settings files; no tool runs them.MOD_RUNS_COMMAND). runClear() (line 457) runs $.command.run({ command: 'clear' }), that is /clear, and no other command. It runs only after a handoff was written: when the user answers Handoff now to the question at handoffAt (ask mode, the default), when the context passes handoffAt in auto mode, or on /handoff clear. If /clear is refused, a toast gives the handoff's path.MOD_DATA_LEAVES_BY_PROMPT). After a handoff that cleared, consumePending() (line 483) submits one prompt in the fresh session: Resume from the handoff in .claude/brain/project.md (written <YYYY-MM-DD HH:MM>); read it first. The only variable part is the time the handoff was written; it carries no file content. The queued entry is deleted before the submit, so it goes once, and only within 15 minutes of the handoff.classic.SessionStart hook (policy hold MOD_ANSWERS_PERMISSION, note MOD_HOOKS_POLICY_EVENT). The hook (line 613) calls next(e) first and returns its result unchanged: it never sets or alters the session's first message, settings, instructions or permissions. After clear, resume or compact it reloads the config and measures context fill; after clear it submits the queued resume prompt above.MCP_FORWARDS_CREDENTIAL_ENV). No credential is read or sent. The "key" the check found in hooks/logic.ts is redact() (line 244): regular expressions that match names such as API_KEY=… and token shapes such as sk-… or ghp_…, in order to replace them with [redacted]. http://www.w3.org/2000/svg in hooks/kit.ts is the SVG namespace inside the markup the UI kit draws, not a request. The plugin reads HOME only to find its config file.plugin.json (warnings UNKNOWN_KEY). documentationUrl and supportUrl are listing fields the directory reads. types points at types/index.d.ts, the $.state contract that claude plugin validate checks. Claude Code ignores all three at load time.ICON_MISSING). Added at .claude-plugin/icon.png (512 × 512); its source is docs/assets/icons/lk-handoff-guard.svg in the repo.MOD_CALL_NAMED_IN_TEXT). tool.call and session.compact are named in the header comment (lines 11–12), which lists the hooks. The tool.call hook (line 691) refuses a call only in auto mode, in the main loop, once context fill passes denyAt (default 95%), with a message that the handoff is being written; AskUserQuestion, the plugin's own tools and calls that carry a consent value are never refused. Otherwise it returns what next(e) gave back, unchanged, after noting edited file paths for the handoff.MOD_REGISTERS_SURFACES). It registers one slash command, /handoff, at session start (line 600). It registers no tools or agents.MOD_HOOKS_OPERATION). prompt.submit (line 633) passes every prompt on unchanged with next(e); it only clears the band's "resumed" marker once the user types a prompt. session.compact (line 727) writes the handoff first when none was written since the last clear, then lets compaction run; it never blocks it. command.run (line 750) has the matcher { command: 'handoff' }, so it runs only for /handoff. None of them makes the call it hooks.turn.complete and turn.step, with the "unmeasured" tool-output estimate;turn.step by yielding a stop chunk;markUnverifiedFigures, widened from token figures to any number;command.run and seeding with prompt.submit on classic.SessionStart.Unlike auto-handoff, this mod lets compaction proceed and refuses tool calls only in auto mode past 95%.
$.ui.ask from inside a turn.step hook (asking mid-turn) works in the test kit, but it has not been tried in a live session.$.model.fork answers nothing-to-fork before the first response, and complete is used instead. If both fail, only the facts are written.hooks/register.ts 866 lines1// handoff-guard: never lose a session to the context limit.
2//
3// session.start loads config, reads context fill, registers /handoff; seeds a session
4// that missed the clear signal (resume-before-load race, from session-saver)
5// classic.SessionStart after /clear: reloads config ($.state was reset) and submits the one-line
6// resume prompt queued in $.store by the handoff that cleared
7// turn.step before each main-loop request: measures fill (plus tool output not yet
8// measured) so a long tool-heavy turn is caught mid-turn; past handoffAt it
9// asks (ask) or hands off (auto) and ends the turn
10// turn.complete after each main-loop turn: measures, warns once at warnAt, asks or hands off
11// tool.call records edited files; in auto mode past denyAt refuses new calls
12// session.compact writes the handoff first when none was written since the last clear, then
13// lets compaction proceed (never blocks it)
14// command.run /handoff and its subcommands
15// ui.render the one-line band (stacks other mods' band under it) and the pane
16//
17// State that must survive /clear (the queued resume prompt, the last brief, settings) lives in
18// $.store; $.state is reset by /clear, which is exactly the per-session state we want reset.
19
20import { atom, read, update } from 'claude-code'
21import type { EngineInterface, On } from 'claude-code'
22
23import type { BriefInfo, Config, Reading } from '../types'
24import {
25 DEFAULTS,
26 EDIT_TOOLS,
27 FILE_HEADERS,
28 HELP,
29 MODES,
30 appendLine,
31 bandParts,
32 briefMarkdown,
33 buildSections,
34 clip,
35 completePrompt,
36 datedLine,
37 dateOf,
38 extractFromMessages,
39 factsBlock,
40 forkPrompt,
41 isMeta,
42 levelOf,
43 markUnverifiedFigures,
44 mergeConfig,
45 parseBrief,
46 parseFraction,
47 pct,
48 projectTemplate,
49 readingOf,
50 renderTranscript,
51 replaceSection,
52 sanitize,
53 setFrontmatter,
54 slugOf,
55 upsertLogLine,
56 whenOf,
57 wordsFor,
58} from './logic'
59import { LEVEL_TONE } from './logic'
60import type { Facts, Msg, Parsed, Sections } from './logic'
61import { TONES, makeKit, tokensShort } from './kit'
62import type { Elements, Tone } from './kit'
63
64type Api = EngineInterface
65
66const PANE = 'handoff-guard'
67const SETTINGS_KEY = 'settings'
68const CONFIG_FILE = '.claude/handoff-guard.json'
69const BRAIN_DIR = '.claude/brain'
70const PENDING_TTL_MS = 15 * 60_000
71const RACE_TTL_MS = 2 * 60_000
72const CHARS_PER_TOKEN = 4
73const CLEAR_DELAY_MS = 250
74const ASK_NOW = 'Handoff now'
75const ASK_OFF = 'Off this session'
76
77const configAtom = atom({ plugin: 'lk-handoff-guard', key: 'config' } as const, DEFAULTS)
78const readingAtom = atom({ plugin: 'lk-handoff-guard', key: 'reading' } as const, null as Reading | null)
79const historyAtom = atom({ plugin: 'lk-handoff-guard', key: 'history' } as const, [] as Reading[])
80const warnedAtom = atom({ plugin: 'lk-handoff-guard', key: 'warned' } as const, false)
81const nextAskAtom = atom({ plugin: 'lk-handoff-guard', key: 'nextAskAt' } as const, 0)
82const askedOutAtom = atom({ plugin: 'lk-handoff-guard', key: 'askedOut' } as const, false)
83const sessionOffAtom = atom({ plugin: 'lk-handoff-guard', key: 'sessionOff' } as const, false)
84const autoTriedAtom = atom({ plugin: 'lk-handoff-guard', key: 'autoTried' } as const, false)
85const writtenAtom = atom({ plugin: 'lk-handoff-guard', key: 'written' } as const, false)
86const clearingAtom = atom({ plugin: 'lk-handoff-guard', key: 'clearing' } as const, false)
87const busyAtom = atom({ plugin: 'lk-handoff-guard', key: 'busy' } as const, false)
88const editedSessionAtom = atom({ plugin: 'lk-handoff-guard', key: 'editedSession' } as const, [] as string[])
89const editedTurnAtom = atom({ plugin: 'lk-handoff-guard', key: 'editedTurn' } as const, [] as string[])
90const lastBriefAtom = atom({ plugin: 'lk-handoff-guard', key: 'lastBrief' } as const, null as BriefInfo | null)
91const resumedAtom = atom({ plugin: 'lk-handoff-guard', key: 'resumed' } as const, '')
92const cwdAtom = atom({ plugin: 'lk-handoff-guard', key: 'cwd' } as const, '')
93
94// Module variables: a guard against two handoffs at once, and tool output that has landed
95// since the last measured response (borrowed from auto-handoff's "unmeasured").
96let inFlight = false
97let unmeasured = 0
98
99type Pending = { at: number; when: string; path: string; from: string }
100
101// ---------------------------------------------------------------- small helpers
102
103const capPreview = (text: string) => (text.length > 6000 ? text.slice(0, 6000) + '\n…' : text)
104
105const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
106
107function toast($: Api, text: string): void {
108 try {
109 $.ui.toast(text)
110 } catch {
111 // Display only.
112 }
113}
114
115async function now($: Api): Promise<number> {
116 try {
117 const t = await $.clock.now()
118 return typeof t === 'number' && Number.isFinite(t) ? t : Date.now()
119 } catch {
120 return Date.now()
121 }
122}
123
124async function cwdOf($: Api): Promise<string> {
125 const held = await read($, cwdAtom)
126 if (held) return held
127 try {
128 const c = await $.session.cwd()
129 if (typeof c === 'string' && c) {
130 await update($, cwdAtom, () => c)
131 return c
132 }
133 } catch {
134 // Fall through.
135 }
136 return ''
137}
138
139async function sessionId($: Api): Promise<string> {
140 try {
141 return String((await $.session.id()) ?? '')
142 } catch {
143 return ''
144 }
145}
146
147const join = (dir: string, rel: string) => (dir ? dir.replace(/\/+$/, '') + '/' + rel : rel)
148
149async function readText($: Api, path: string): Promise<string | null> {
150 try {
151 if (!(await $.fs.exists(path))) return null
152 const t = await $.fs.read(path)
153 return typeof t === 'string' ? t : null
154 } catch {
155 return null
156 }
157}
158
159async function readJson($: Api, path: string): Promise<unknown> {
160 const t = await readText($, path)
161 if (t === null) return null
162 try {
163 return JSON.parse(t)
164 } catch {
165 return null
166 }
167}
168
169async function storeGet($: Api, key: string): Promise<unknown> {
170 try {
171 return await $.store.get(key)
172 } catch {
173 return undefined
174 }
175}
176
177// ---------------------------------------------------------------- config
178
179async function loadConfig($: Api): Promise<Config> {
180 let home = ''
181 try {
182 home = (await $.env.get('HOME')) ?? ''
183 } catch {
184 home = ''
185 }
186 const cwd = await cwdOf($)
187 const user = home ? await readJson($, join(home, CONFIG_FILE)) : null
188 const project = cwd ? await readJson($, join(cwd, CONFIG_FILE)) : null
189 const saved = await storeGet($, SETTINGS_KEY)
190 const cfg = mergeConfig(user, project, saved)
191 await update($, configAtom, () => cfg)
192 return cfg
193}
194
195async function saveSetting($: Api, patch: Partial<Config>): Promise<Config> {
196 const saved = sanitize(await storeGet($, SETTINGS_KEY))
197 await $.store.set(SETTINGS_KEY, { ...saved, ...patch })
198 return loadConfig($)
199}
200
201/** The last brief for this project, from $.store, so /handoff status and the pane survive /clear. */
202async function loadLast($: Api): Promise<void> {
203 const cwd = await cwdOf($)
204 const v = (await storeGet($, 'last:' + cwd)) as BriefInfo | undefined
205 if (v && typeof v === 'object' && typeof v.path === 'string' && typeof v.when === 'string') {
206 await update($, lastBriefAtom, () => v)
207 }
208}
209
210// ---------------------------------------------------------------- measurement
211
212async function measure($: Api, where: string, extraTokens = 0): Promise<Reading | null> {
213 try {
214 const u = await $.session.usage()
215 const r = readingOf(u && (u as { context?: unknown }).context, await now($), where, extraTokens)
216 if (!r) return null
217 await update($, readingAtom, () => r)
218 await update($, historyAtom, (h: Reading[]) => [...h, r].slice(-30))
219 return r
220 } catch {
221 return null
222 }
223}
224
225/** Warns once at warnAt; past handoffAt asks, hands off or does nothing by mode. 'stop' ends the turn. */
226async function evaluate($: Api, r: Reading, where: 'start' | 'step' | 'complete'): Promise<'stop' | 'none'> {
227 const cfg = await read($, configAtom)
228 const level = levelOf(r.frac, cfg)
229 if (level !== 'ok' && !(await read($, warnedAtom))) {
230 await update($, warnedAtom, () => true)
231 toast($, `context ${pct(r.frac)}%: run /handoff when you reach a stopping point`)
232 }
233 if (where === 'start' || level !== 'due') return 'none'
234 if (cfg.mode === 'off' || (await read($, sessionOffAtom))) return 'none'
235 if (inFlight || (await read($, clearingAtom))) return 'none'
236
237 if (cfg.mode === 'auto') {
238 if (await read($, autoTriedAtom)) return 'none'
239 await update($, autoTriedAtom, () => true)
240 const info = await doHandoff($, true)
241 return info ? 'stop' : 'none'
242 }
243
244 // ask
245 if (await read($, askedOutAtom)) return 'none'
246 const nextAsk = await read($, nextAskAtom)
247 const threshold = nextAsk > 0 ? nextAsk : cfg.handoffAt
248 if (r.frac < threshold) return 'none'
249 const first = threshold < cfg.reaskAt
250 const later = first ? `Later (ask at ${pct(cfg.reaskAt)}%)` : 'Later'
251 let answer = later
252 try {
253 answer = await $.ui.ask(`Context at ${pct(r.frac)}%. Write the handoff and clear now?`, {
254 options: [ASK_NOW, later, ASK_OFF],
255 header: 'Handoff',
256 })
257 } catch {
258 // Dismissed, or nobody to ask (-p): treat as Later.
259 answer = later
260 }
261 if (answer === ASK_NOW) {
262 const info = await doHandoff($, true)
263 return info ? 'stop' : 'none'
264 }
265 if (answer === ASK_OFF) {
266 await update($, sessionOffAtom, () => true)
267 toast($, 'handoff-guard: off for this session. /handoff still writes one.')
268 return 'none'
269 }
270 if (first) await update($, nextAskAtom, () => cfg.reaskAt)
271 else await update($, askedOutAtom, () => true)
272 return 'none'
273}
274
275// ---------------------------------------------------------------- the handoff
276
277async function git($: Api, cwd: string, argv: string[]): Promise<string> {
278 try {
279 const r = await $.process.run(['git', ...argv], { cwd: cwd || undefined, timeoutMs: 5000 })
280 return r && r.exitCode === 0 && typeof r.stdout === 'string' ? r.stdout.trim() : ''
281 } catch {
282 return ''
283 }
284}
285
286async function loopkeeperStage($: Api): Promise<string> {
287 try {
288 // The loopkeeper core's stage, not in this contract: typed loosely and read only when present.
289 const got = (await $.state.get({ plugin: 'loopkeeper', key: 'stageCurrent' } as never)) as { value?: unknown } | undefined
290 return got && typeof got.value === 'string' ? got.value : ''
291 } catch {
292 return ''
293 }
294}
295
296async function messagesOf($: Api): Promise<Msg[]> {
297 try {
298 const m = await $.session.messages()
299 return Array.isArray(m) ? (m as unknown as Msg[]) : []
300 } catch {
301 return []
302 }
303}
304
305async function collectFacts($: Api, messages: Msg[]): Promise<Facts> {
306 const cwd = await cwdOf($)
307 const branch = await git($, cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
308 const log = await git($, cwd, ['log', '-5', '--pretty=format:%h %s'])
309 const x = extractFromMessages(messages)
310 const seen = await read($, editedSessionAtom)
311 const editedSession = [...new Set([...x.edited, ...seen])]
312 const r = await read($, readingAtom)
313 return {
314 cwd,
315 when: whenOf(await now($)),
316 branch,
317 commits: log ? log.split('\n').map(l => clip(l, 120)).filter(Boolean).slice(0, 5) : [],
318 editedTurn: await read($, editedTurnAtom),
319 editedSession,
320 lastPrompt: x.lastPrompt,
321 lastAnswer: x.lastAnswer,
322 openQuestions: x.openQuestions,
323 stage: await loopkeeperStage($),
324 ctxPercent: r ? pct(r.frac) : null,
325 ctxTokens: r ? r.tokens : null,
326 ctxWindow: r ? r.window : null,
327 }
328}
329
330/** Fork first (shares the session's prompt cache), then complete; null when neither gives all headings. */
331async function draft($: Api, cfg: Config, facts: string, messages: Msg[]): Promise<{ parsed: Parsed | null; source: string; flagged: string[] }> {
332 const words = wordsFor(cfg.maxBriefTokens)
333 const tryText = (text: unknown, source: string) => {
334 if (typeof text !== 'string' || !parseBrief(text)) return null
335 const checked = markUnverifiedFigures(text, facts)
336 const parsed = parseBrief(checked.text)
337 return parsed ? { parsed, source, flagged: checked.flagged } : null
338 }
339 try {
340 const r = await $.model.fork({ prompt: forkPrompt(facts, words) })
341 const got = r && r.isAnswered ? tryText(r.text, 'fork') : null
342 if (got) return got
343 } catch {
344 // Fall back to complete.
345 }
346 try {
347 const r = await $.model.complete({
348 model: cfg.model,
349 system: 'You write short, precise handoff notes for coding sessions.',
350 prompt: completePrompt(facts, renderTranscript(messages), words),
351 maxTokens: cfg.maxBriefTokens,
352 timeoutMs: 60_000,
353 })
354 const got = r && r.isAnswered ? tryText(r.text, 'complete') : null
355 if (got) return got
356 } catch {
357 // Facts only.
358 }
359 return { parsed: null, source: 'facts', flagged: [] }
360}
361
362/** Writes project.md sections, a log line, decisions and questions in the handoff folder's shapes. */
363async function writeBrain($: Api, cwd: string, s: Sections, at: number, sid: string): Promise<string> {
364 const dir = join(cwd, BRAIN_DIR)
365 const date = dateOf(at)
366 const projectPath = join(dir, 'project.md')
367 let project = (await readText($, projectPath)) ?? projectTemplate(slugOf(cwd), date)
368 project = replaceSection(project, 'Status today', s.status)
369 project = replaceSection(project, 'Next', s.next)
370 if (s.deadEnds) project = replaceSection(project, 'Dead ends', s.deadEnds)
371 project = setFrontmatter(project, 'updated', date)
372 await $.fs.write(projectPath, project)
373
374 const logPath = join(dir, 'log.md')
375 const logLine = datedLine(date, s.log)
376 const prevKey = 'logline:' + cwd
377 const prev = (await storeGet($, prevKey)) as { sid?: string; line?: string } | undefined
378 const previous = prev && prev.sid === sid && typeof prev.line === 'string' ? prev.line : undefined
379 const logDoc = (await readText($, logPath)) ?? FILE_HEADERS['log.md']
380 await $.fs.write(logPath, upsertLogLine(logDoc, logLine, previous))
381 try {
382 await $.store.set(prevKey, { sid, line: logLine })
383 } catch {
384 // The next handoff appends instead of replacing.
385 }
386
387 for (const [file, items] of [
388 ['decisions.md', s.decisions],
389 ['questions.md', s.questions],
390 ] as const) {
391 if (!items.length) continue
392 const path = join(dir, file)
393 let doc = (await readText($, path)) ?? FILE_HEADERS[file]
394 for (const item of items) doc = appendLine(doc, datedLine(date, item)).doc
395 await $.fs.write(path, doc)
396 }
397 return projectPath
398}
399
400/** The whole handoff. With clear, queues the resume prompt in $.store and then /clear. */
401async function doHandoff($: Api, clear: boolean): Promise<BriefInfo | null> {
402 if (inFlight) return null
403 inFlight = true
404 await update($, busyAtom, () => true)
405 try {
406 const cfg = await read($, configAtom)
407 const cwd = await cwdOf($)
408 const sid = await sessionId($)
409 const messages = await messagesOf($)
410 const facts = await collectFacts($, messages)
411 const factsText = factsBlock(facts)
412 const { parsed, source, flagged } = await draft($, cfg, factsText, messages)
413 const sections = buildSections(parsed, facts)
414 const at = await now($)
415 const path = await writeBrain($, cwd, sections, at, sid)
416 const info: BriefInfo = {
417 at,
418 when: whenOf(at),
419 path,
420 sessionId: sid,
421 source,
422 flagged,
423 preview: capPreview(briefMarkdown(sections, factsText)),
424 }
425 await update($, lastBriefAtom, () => info)
426 await update($, writtenAtom, () => true)
427 try {
428 await $.store.set('last:' + cwd, info)
429 } catch {
430 // Status after /clear falls back to nothing.
431 }
432 const shown = path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
433 toast($, `Handoff written: ${shown}${source === 'facts' ? ' (facts only)' : ''}${flagged.length ? ` · ${flagged.length} unverified figure(s)` : ''}`)
434 if (clear) {
435 const pending: Pending = { at, when: info.when, path, from: sid }
436 await $.store.set('pending:' + cwd, pending)
437 await update($, clearingAtom, () => true)
438 // From a timer, outside any event: a command.run hook may not run a command, and a
439 // turn.step hook holds the turn. Not awaited: /clear runs once the session is idle.
440 $.clock.after(CLEAR_DELAY_MS, () => runClear($, cwd, shown))
441 }
442 return info
443 } catch (err) {
444 toast($, 'handoff-guard: the handoff failed: ' + errText(err))
445 return null
446 } finally {
447 inFlight = false
448 try {
449 await update($, busyAtom, () => false)
450 } catch {
451 // Display only.
452 }
453 }
454}
455
456function runClear($: Api, cwd: string, shown: string): void {
457 $.command.run({ command: 'clear' }).catch(async (err: unknown) => {
458 toast($, `handoff-guard: /clear was refused (${errText(err)}); the handoff is at ${shown}`)
459 try {
460 await $.store.delete('pending:' + cwd)
461 await update($, clearingAtom, () => false)
462 } catch {
463 // Nothing else to undo.
464 }
465 })
466}
467
468/** After /clear: submit the one-line resume prompt the handoff queued, once. */
469async function consumePending($: Api, maxAgeMs: number): Promise<boolean> {
470 const cwd = await cwdOf($)
471 const key = 'pending:' + cwd
472 const p = (await storeGet($, key)) as Pending | undefined
473 if (!p || typeof p !== 'object' || typeof p.when !== 'string' || typeof p.at !== 'number') return false
474 try {
475 await $.store.delete(key)
476 } catch {
477 return false // Never risk submitting twice.
478 }
479 if ((await now($)) - p.at > maxAgeMs) return false
480 await update($, resumedAtom, () => p.when)
481 const text = `Resume from the handoff in .claude/brain/project.md (written ${p.when}); read it first.`
482 // Not awaited: it resolves when the new turn starts.
483 $.prompt.submit({ text }).catch(() => {
484 toast($, `handoff-guard: could not start the resume turn; read .claude/brain/project.md (written ${p.when})`)
485 })
486 return true
487}
488
489/** Resets per-session state and reloads what a fresh session needs (after start or /clear). */
490async function onStart($: Api, cwd: string): Promise<void> {
491 if (cwd) await update($, cwdAtom, () => cwd)
492 unmeasured = 0
493 await loadConfig($)
494 await loadLast($)
495 const r = await measure($, 'start')
496 if (r) await evaluate($, r, 'start')
497}
498
499// ---------------------------------------------------------------- pane helpers (pure)
500
501/** The quiet color of the kit (secondary text), a theme key. */
502const QUIET = TONES.muted.fg
503
504/** The pane's hero line: the handoff state, in the words the band uses. */
505function heroTitle(r: Reading | null, cfg: Config, o: { busy: boolean; sessionOff: boolean }): string {
506 if (o.busy) return 'Writing the handoff…'
507 if (!r) return 'Context: no reading yet'
508 const ctx = `Context ${pct(r.frac)}%`
509 const level = levelOf(r.frac, cfg)
510 if (level === 'due') {
511 if (cfg.mode === 'off' || o.sessionOff) return `${ctx} · handoff due · guard off`
512 return cfg.mode === 'auto' ? `${ctx} · handoff due · auto handoff` : `${ctx} · handoff due: /handoff clear`
513 }
514 if (level === 'warn') return `${ctx} · /handoff at a stopping point`
515 return `${ctx} · next handoff at ${pct(cfg.handoffAt)}%`
516}
517
518// ---------------------------------------------------------------- /handoff
519
520async function statusText($: Api): Promise<string> {
521 const cfg = await read($, configAtom)
522 const r = await read($, readingAtom)
523 const last = await read($, lastBriefAtom)
524 const off = await read($, sessionOffAtom)
525 const ctx = r
526 ? `Context ${pct(r.frac)}%` + (r.tokens !== null && r.window !== null ? ` (${r.tokens} of ${r.window} tokens)` : '')
527 : 'Context: no reading yet'
528 const lines = [
529 `${ctx} · warn ${pct(cfg.warnAt)}% · handoff ${pct(cfg.handoffAt)}% · mode ${cfg.mode}${off ? ' (off this session)' : ''}`,
530 last ? `Last handoff: ${last.when} (${last.source}) at ${last.path}` : 'Last handoff: none for this project yet',
531 ]
532 return lines.join('\n')
533}
534
535async function runCommand($: Api, args: string): Promise<{ text?: string }> {
536 const words = args.trim().split(/\s+/).filter(Boolean)
537 const sub = (words[0] ?? '').toLowerCase()
538 const arg = words.slice(1).join(' ').toLowerCase()
539 if (sub === '' || sub === 'now' || sub === 'write') {
540 const info = await doHandoff($, false)
541 return { text: info ? `Handoff written to ${info.path} (${info.source}).` : 'handoff-guard: no handoff written (one is already running, or it failed).' }
542 }
543 if (sub === 'clear') {
544 const info = await doHandoff($, true)
545 return {
546 text: info
547 ? `Handoff written to ${info.path} (${info.source}). Clearing; the next session resumes from it.`
548 : 'handoff-guard: no handoff written, so nothing was cleared.',
549 }
550 }
551 if (sub === 'status') return { text: await statusText($) }
552 if (sub === 'show' || sub === 'pane') {
553 await $.ui.open({ id: PANE, title: 'Handoff guard', focus: true, closeOnEscape: true })
554 return {}
555 }
556 if (sub === 'mode') {
557 if (!(MODES as readonly string[]).includes(arg)) return { text: 'Mode must be ask, auto or off.' }
558 const cfg = await saveSetting($, { mode: arg as Config['mode'] })
559 if (arg !== 'off') await update($, sessionOffAtom, () => false)
560 return { text: cfg.mode === arg ? `Mode set to ${arg}.` : `Mode saved as ${arg}, but a config file overrides it: ${cfg.mode}.` }
561 }
562 if (sub === 'at' || sub === 'warn') {
563 const f = parseFraction(arg)
564 if (f === null) return { text: 'Give a fraction between 0 and 1, such as 0.85.' }
565 const cfg = await saveSetting($, sub === 'at' ? { handoffAt: f } : { warnAt: f })
566 if (sub === 'at') {
567 await update($, nextAskAtom, () => 0)
568 await update($, askedOutAtom, () => false)
569 await update($, autoTriedAtom, () => false)
570 } else {
571 await update($, warnedAtom, () => false)
572 }
573 return { text: `Warning at ${pct(cfg.warnAt)}%, handoff at ${pct(cfg.handoffAt)}%.` }
574 }
575 if (sub === 'band') {
576 if (arg !== 'on' && arg !== 'off') return { text: 'Use /handoff band on or /handoff band off.' }
577 const cfg = await saveSetting($, { band: arg === 'on' })
578 return { text: cfg.band ? 'Band shown above the prompt.' : 'Band hidden.' }
579 }
580 return { text: HELP }
581}
582
583// ---------------------------------------------------------------- hooks
584
585export function register(on: On) {
586 on('session.start', async ($, e, next) => {
587 try {
588 await onStart($, typeof e.cwd === 'string' ? e.cwd : '')
589 } catch {
590 // Defaults stay in place.
591 }
592 // The clear signal can fire before this module loads (a reload at the wrong moment): a
593 // fresh queued resume and an empty transcript count too (session-saver's race guard).
594 try {
595 if ((await messagesOf($)).length === 0) await consumePending($, RACE_TTL_MS)
596 } catch {
597 // The pending entry stays for classic.SessionStart.
598 }
599 try {
600 await $.command.register({
601 name: 'handoff',
602 description: 'Write the brain handoff now; or clear, status, show, mode, at, warn, band',
603 argumentHint: '[clear | status | show | mode ask|auto|off | at <0-1> | warn <0-1> | band on|off]',
604 immediate: true,
605 })
606 } catch {
607 // The name is taken: measuring and the band still work.
608 }
609 return next(e)
610 })
611
612 // /clear (and /resume, /branch) reset $.state without a session.start.
613 on('classic.SessionStart', async ($, e, next) => {
614 const result = await next(e)
615 const source = typeof e.source === 'string' ? e.source : ''
616 if (source === 'clear' || source === 'resume' || source === 'compact') {
617 try {
618 await onStart($, typeof e.cwd === 'string' ? e.cwd : '')
619 } catch {
620 // Defaults stay in place.
621 }
622 }
623 if (source === 'clear') {
624 try {
625 await consumePending($, PENDING_TTL_MS)
626 } catch {
627 // Nothing queued.
628 }
629 }
630 return result
631 })
632
633 on('prompt.submit', async ($, e, next) => {
634 try {
635 const kind = e.origin && typeof e.origin === 'object' ? (e.origin as { kind?: string; asUser?: boolean }) : undefined
636 const typed = !kind || ['composer', 'bridge', 'sdk'].includes(String(kind.kind)) || kind.asUser === true
637 if (typed && typeof e.text === 'string' && !isMeta(e.text) && (await read($, resumedAtom))) {
638 await update($, resumedAtom, () => '')
639 }
640 } catch {
641 // Display only.
642 }
643 return next(e)
644 })
645
646 on('turn.start', async ($, e, next) => {
647 try {
648 if (!e.agentId) await update($, editedTurnAtom, () => [])
649 } catch {
650 // Observing only.
651 }
652 return next(e)
653 })
654
655 on('turn.step', async function* ($, e, next) {
656 if (!e.agentId) {
657 let stop = false
658 try {
659 const r = await measure($, 'step', Math.ceil(unmeasured))
660 // Index 0 follows a turn.complete reading; act on the requests after tool calls.
661 if (r && typeof e.index === 'number' && e.index > 0) stop = (await evaluate($, r, 'step')) === 'stop'
662 } catch {
663 stop = false
664 }
665 if (stop) {
666 unmeasured = 0
667 yield { kind: 'text', index: 0, text: '[handoff-guard] Context is nearly full: the handoff is written to .claude/brain/ and this session is clearing.' } as never
668 yield { kind: 'stop', stopReason: 'end_turn', usage: null } as never
669 return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null } as never
670 }
671 }
672 const result = yield* next(e)
673 if (!e.agentId) unmeasured = 0
674 return result
675 })
676
677 on('turn.complete', async ($, e, next) => {
678 const result = await next(e)
679 if (!e.agentId) {
680 try {
681 unmeasured = 0
682 const r = await measure($, 'complete')
683 if (r) await evaluate($, r, 'complete')
684 } catch {
685 // Observing only.
686 }
687 }
688 return result
689 })
690
691 on('tool.call', async ($, e, next) => {
692 let deny: string | null = null
693 try {
694 const tool = typeof e.tool === 'string' ? e.tool : ''
695 const exempt = tool === 'AskUserQuestion' || tool.startsWith('mcp__handoff-guard__') || (typeof e.consent === 'string' && e.consent !== '')
696 if (!exempt) {
697 const cfg = await read($, configAtom)
698 // Only auto mode refuses, so only auto mode pays for a fresh reading here.
699 const r = cfg.mode === 'auto' && !e.agentId ? ((await measure($, 'tool', Math.ceil(unmeasured))) ?? (await read($, readingAtom))) : null
700 if (r && !(await read($, sessionOffAtom)) && r.frac >= cfg.denyAt) {
701 deny = `[handoff-guard] Not run: the context is at ${pct(r.frac)}%, past ${pct(cfg.denyAt)}%. The handoff to .claude/brain/ is being written and this session will clear; the next session redoes this call. Make no more tool calls: end with one line saying where you stopped.`
702 }
703 }
704 } catch {
705 deny = null
706 }
707 if (deny) return { deny }
708 const result = await next(e)
709 try {
710 const tool = typeof e.tool === 'string' ? e.tool : ''
711 const ok = result && typeof result === 'object' && !('deny' in result && result.deny) && !(result as { isError?: boolean }).isError
712 const path = (e as { file_path?: unknown; notebook_path?: unknown }).file_path ?? (e as { notebook_path?: unknown }).notebook_path
713 if (ok && EDIT_TOOLS.has(tool) && typeof path === 'string') {
714 await update($, editedSessionAtom, (xs: string[]) => [...xs.filter(x => x !== path), path].slice(-50))
715 await update($, editedTurnAtom, (xs: string[]) => [...xs.filter(x => x !== path), path].slice(-50))
716 }
717 if (!e.agentId && result && typeof result === 'object') {
718 const t = (result as { text?: unknown }).text ?? (result as { result?: unknown }).result
719 if (typeof t === 'string') unmeasured += t.length / CHARS_PER_TOKEN
720 }
721 } catch {
722 // Observing only.
723 }
724 return result
725 })
726
727 on('session.compact', async ($, e, next) => {
728 if (e.agentId || e.trigger === 'precompute') return next(e)
729 try {
730 const cfg = await read($, configAtom)
731 if (cfg.mode !== 'off' && !(await read($, sessionOffAtom)) && !(await read($, writtenAtom)) && !inFlight) {
732 await doHandoff($, false)
733 }
734 } catch {
735 // Never block compaction.
736 }
737 const result = await next(e)
738 try {
739 if (result && typeof result === 'object' && !('skip' in result)) {
740 // Compaction is a fresh start for the context: the next one writes again.
741 await update($, writtenAtom, () => false)
742 await measure($, 'compact')
743 }
744 } catch {
745 // Observing only.
746 }
747 return result
748 })
749
750 on('command.run', { command: 'handoff' }, async ($, e) => {
751 try {
752 return await runCommand($, typeof e.args === 'string' ? e.args : '')
753 } catch (err) {
754 return { text: 'handoff-guard: ' + errText(err) }
755 }
756 })
757
758 // The band, one kit line: a bold label, the context chip in the level's tone (ok, accent, danger;
759 // muted before the first reading), then what to do and the last handoff on the right side: quiet,
760 // or in the default text color once the handoff is due (kit.line draws its right side quiet).
761 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
762 const props = (e.props ?? {}) as { hasSurvey?: boolean }
763 const cfg = await read($, configAtom)
764 if (props.hasSurvey === true || !cfg.band) return next(e)
765 const r = await read($, readingAtom)
766 const last = await read($, lastBriefAtom)
767 const parts = bandParts(r, cfg, {
768 lastWhen: last ? last.when : undefined,
769 busy: await read($, busyAtom),
770 sessionOff: await read($, sessionOffAtom),
771 resumed: (await read($, resumedAtom)) || undefined,
772 })
773 const ui = $.ui.resolve(e)
774 const { Box } = ui
775 const kit = makeKit(ui as unknown as Elements, e.surface)
776 const chips = [{ text: parts.chip, tone: parts.tone }]
777 const right = parts.right.join(' · ')
778 const drawn = (
779 parts.urgent && right
780 ? Box({
781 flexDirection: 'row',
782 justifyContent: 'space-between',
783 columnGap: 2,
784 children: [kit.line('handoff', chips) as ReturnType<typeof Box>, kit.text(right, { wrap: 'truncate' }) as ReturnType<typeof Box>],
785 })
786 : kit.line('handoff', chips, parts.right)
787 ) as ReturnType<typeof Box>
788 const line = Box({ key: 'handoff-guard-band', flexDirection: 'column', children: [drawn] })
789 const theirs = await next(e)
790 if (!theirs) return line
791 return Box({ flexDirection: 'column', children: [line, theirs] })
792 })
793
794 // The pane, drawn with the kit: a hero for the handoff state, tiles, the readings, the last brief, buttons.
795 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
796 const cfg = await read($, configAtom)
797 const r = await read($, readingAtom)
798 const history = await read($, historyAtom)
799 const last = await read($, lastBriefAtom)
800 const busy = await read($, busyAtom)
801 const off = await read($, sessionOffAtom)
802 const ui = $.ui.resolve(e)
803 const kit = makeKit(ui as unknown as Elements, e.surface)
804 const level = r ? levelOf(r.frac, cfg) : 'ok'
805 const tone: Tone = r ? LEVEL_TONE[level] : 'muted'
806
807 const hero = kit.hero({
808 kicker: 'handoff guard',
809 title: heroTitle(r, cfg, { busy, sessionOff: off }),
810 sub: [
811 r && r.tokens !== null && r.window !== null ? `${tokensShort(r.tokens)} of ${tokensShort(r.window)} tokens` : '',
812 `mode ${cfg.mode}${off ? ' (off this session)' : ''}`,
813 last ? `last handoff ${last.when}` : 'no handoff yet',
814 ]
815 .filter(Boolean)
816 .join(' · '),
817 tone: busy ? 'info' : tone,
818 })
819
820 const tiles = kit.tiles([
821 { label: 'Context', value: r ? `${pct(r.frac)}%` : '--', tone },
822 { label: 'Warn at', value: `${pct(cfg.warnAt)}%`, tone: LEVEL_TONE.warn },
823 { label: 'Handoff at', value: `${pct(cfg.handoffAt)}%`, tone: 'danger' },
824 { label: 'Mode', value: off ? `${cfg.mode} (off)` : cfg.mode, tone: cfg.mode === 'off' || off ? 'muted' : cfg.mode === 'auto' ? 'accent' : 'info' },
825 ])
826
827 const shown = history.slice(-10)
828 const readings: unknown[] = shown.length
829 ? shown.map((h, i) => {
830 const lv = levelOf(h.frac, cfg)
831 return kit.row({
832 key: `reading-${i}`,
833 left: [kit.pill(lv.padEnd(4), LEVEL_TONE[lv]), whenOf(h.at).slice(11), kit.text(h.where.padEnd(8), { color: QUIET })],
834 right: [kit.bar(Math.min(1, h.frac), { width: 20, tone: LEVEL_TONE[lv] })],
835 })
836 })
837 : [kit.text('none yet', { color: QUIET })]
838
839 const brief: unknown[] = last
840 ? [
841 kit.row({
842 left: [
843 kit.pill(last.source, last.source === 'facts' ? 'accent' : 'ok'),
844 kit.text(last.path, { color: QUIET, wrap: 'truncate-start' }),
845 ],
846 }),
847 last.flagged.length ? kit.row({ left: [kit.pill('unverified', 'accent'), last.flagged.join(', ')] }) : null,
848 ui.Markdown({ text: last.preview.split('\n').slice(0, 60).join('\n') }),
849 ]
850 : [kit.text('No handoff written for this project yet. /handoff writes one.', { color: QUIET })]
851
852 return kit.page([
853 hero,
854 tiles,
855 // A full-width Svg bar where the surface draws Svg, the 40-column text bar on the terminal.
856 r ? kit.progress(Math.min(1, r.frac), kit.surface === 'terminal' ? { width: 40, tone } : { tone }) : null,
857 kit.stack([kit.header(`Readings · ${history.length}`, history.length > shown.length ? `last ${shown.length}` : undefined), ...readings]),
858 kit.stack([kit.header('Last handoff', last ? last.when : 'none yet'), ...brief]),
859 kit.buttons([
860 { key: 'write', label: 'Write handoff', hotkey: 'w', plain: true, onPress: () => doHandoff($, false) },
861 { key: 'close', label: 'Close', hotkey: 'c', plain: true, onPress: () => $.ui.close({ id: PANE }) },
862 ]),
863 ]) as never
864 })
865}
866hooks/logic.ts 608 lines1// handoff-guard: pure logic, no $ calls, so every rule here is unit tested on its own.
2//
3// Config merge and thresholds, the band, fact extraction from the transcript, the brief
4// template and its validation, the unverified-figures pass, and the text transforms that
5// write the handoff files (.claude/brain/{project,log,decisions,questions}.md) in the
6// handoff folder format the README describes.
7
8import type { Config, Mode, Reading } from '../types'
9
10export const MODES: readonly Mode[] = ['ask', 'auto', 'off']
11
12export const DEFAULTS: Config = {
13 warnAt: 0.7,
14 handoffAt: 0.85,
15 reaskAt: 0.92,
16 denyAt: 0.95,
17 mode: 'ask',
18 model: 'haiku',
19 maxBriefTokens: 2000,
20 band: true,
21}
22
23export const HELP = [
24 '/handoff write the handoff into .claude/brain/ now (no clear)',
25 '/handoff clear write the handoff, then /clear and resume from it',
26 '/handoff status context fill, last handoff time and path',
27 '/handoff show pane: last brief and readings',
28 '/handoff mode ask|auto|off',
29 '/handoff at <0-1> handoff level (default 0.85)',
30 '/handoff warn <0-1> warning level (default 0.70)',
31 '/handoff band on|off',
32].join('\n')
33
34// ---------------------------------------------------------------- config
35
36function frac(v: unknown): number | undefined {
37 return typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= 1 ? v : undefined
38}
39
40/** Keeps only the known, well-typed fields of one config layer. */
41export function sanitize(raw: unknown): Partial<Config> {
42 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
43 const r = raw as Record<string, unknown>
44 const out: Partial<Config> = {}
45 for (const k of ['warnAt', 'handoffAt', 'reaskAt', 'denyAt'] as const) {
46 const f = frac(r[k])
47 if (f !== undefined) out[k] = f
48 }
49 if (typeof r.mode === 'string' && (MODES as readonly string[]).includes(r.mode)) out.mode = r.mode as Mode
50 if (typeof r.model === 'string' && r.model.trim()) out.model = r.model.trim()
51 if (typeof r.maxBriefTokens === 'number' && Number.isFinite(r.maxBriefTokens)) {
52 out.maxBriefTokens = Math.min(8000, Math.max(300, Math.round(r.maxBriefTokens)))
53 }
54 if (typeof r.band === 'boolean') out.band = r.band
55 return out
56}
57
58/** Defaults, then each layer in order (user file, project file, $.store), later wins. */
59export function mergeConfig(...layers: unknown[]): Config {
60 let cfg: Config = { ...DEFAULTS }
61 for (const l of layers) cfg = { ...cfg, ...sanitize(l) }
62 // Keep the levels in order: warn <= handoff <= reask <= deny.
63 if (cfg.warnAt > cfg.handoffAt) cfg.warnAt = cfg.handoffAt
64 if (cfg.reaskAt < cfg.handoffAt) cfg.reaskAt = Math.min(1, cfg.handoffAt + 0.05)
65 if (cfg.denyAt < cfg.reaskAt) cfg.denyAt = Math.min(1, Math.max(cfg.reaskAt, cfg.handoffAt + 0.1))
66 return cfg
67}
68
69/** "0.8", "80" or "80%" as 0.8; null when it is not in (0, 1]. */
70export function parseFraction(arg: string): number | null {
71 const s = arg.trim()
72 if (!s) return null
73 const pct = s.endsWith('%')
74 const n = Number(pct ? s.slice(0, -1) : s)
75 if (!Number.isFinite(n)) return null
76 const f = pct || n > 1 ? n / 100 : n
77 return f > 0 && f <= 1 ? Math.round(f * 1000) / 1000 : null
78}
79
80// ---------------------------------------------------------------- readings and levels
81
82export type Level = 'ok' | 'warn' | 'due'
83
84/** A usage() context block as a reading; null when it carries no usable figure. */
85export function readingOf(context: unknown, at: number, where: string, extraTokens = 0): Reading | null {
86 if (!context || typeof context !== 'object') return null
87 const c = context as { tokens?: unknown; window?: unknown; percent?: unknown }
88 const tokens = typeof c.tokens === 'number' && Number.isFinite(c.tokens) ? c.tokens : null
89 const window = typeof c.window === 'number' && Number.isFinite(c.window) && c.window > 0 ? c.window : null
90 let f: number | null = null
91 if (tokens !== null && window !== null) f = (tokens + Math.max(0, extraTokens)) / window
92 else if (typeof c.percent === 'number' && Number.isFinite(c.percent)) f = c.percent / 100
93 if (f === null || !Number.isFinite(f) || f < 0) return null
94 return { tokens: tokens === null ? null : tokens + Math.max(0, extraTokens), window, frac: Math.min(f, 1.5), at, where }
95}
96
97export function levelOf(f: number, cfg: Config): Level {
98 if (f >= cfg.handoffAt) return 'due'
99 if (f >= cfg.warnAt) return 'warn'
100 return 'ok'
101}
102
103export const pct = (f: number) => Math.round(f * 100)
104
105/** The kit tone of a level (the kit's Tone names): ok, accent (the Claude orange, not the warning
106 * key) at warnAt, danger at handoffAt. */
107export const LEVEL_TONE: Record<Level, 'ok' | 'accent' | 'danger'> = { ok: 'ok', warn: 'accent', due: 'danger' }
108
109/** The band's parts: the chip (`ctx 41%`), its kit tone (muted before the first reading), the right
110 * side (what to do, then the last or resumed handoff) and whether it is urgent: at or past
111 * handoffAt the right side is drawn in the default text color, else quiet. */
112export type BandParts = { chip: string; tone: 'ok' | 'accent' | 'danger' | 'muted'; right: string[]; urgent: boolean }
113
114/** The one-line band, drawn as kit.line('handoff', [chip], right): `handoff ● ctx 41% last 14:02`. */
115export function bandParts(
116 r: Reading | null,
117 cfg: Config,
118 o: { lastWhen?: string; busy?: boolean; sessionOff?: boolean; resumed?: string } = {},
119): BandParts {
120 if (!r) return { chip: 'ctx --', tone: 'muted', right: o.lastWhen ? [`last ${shortWhen(o.lastWhen)}`] : [], urgent: false }
121 const level = levelOf(r.frac, cfg)
122 const bits: string[] = []
123 if (o.busy) bits.push('writing handoff…')
124 else if (level === 'due') {
125 if (cfg.mode === 'off' || o.sessionOff) bits.push('handoff due · guard off')
126 else bits.push(cfg.mode === 'auto' ? 'auto handoff' : 'handoff due: /handoff clear')
127 } else if (level === 'warn') bits.push('/handoff at a stopping point')
128 if (o.resumed) bits.push(`resumed from handoff ${shortWhen(o.resumed)}`)
129 else if (o.lastWhen) bits.push(`last ${shortWhen(o.lastWhen)}`)
130 return { chip: `ctx ${pct(r.frac)}%`, tone: LEVEL_TONE[level], right: bits, urgent: level === 'due' }
131}
132
133/** "2026-10-09 14:02" as "14:02" when it is today's, else as written. */
134export function shortWhen(when: string, today?: string): string {
135 if (today && when.startsWith(today + ' ')) return when.slice(today.length + 1)
136 return when
137}
138
139// ---------------------------------------------------------------- time
140
141const two = (n: number) => String(n).padStart(2, '0')
142
143export function dateOf(ms: number): string {
144 const d = new Date(ms)
145 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`
146}
147
148export function whenOf(ms: number): string {
149 const d = new Date(ms)
150 return `${dateOf(ms)} ${two(d.getHours())}:${two(d.getMinutes())}`
151}
152
153// ---------------------------------------------------------------- facts from the transcript
154
155/** The slice of a $.session.messages() row this mod reads. */
156export type Msg = {
157 role: string
158 text: string
159 toolUses?: readonly { tool: string; input?: Record<string, unknown>; result?: unknown; text?: string; isError?: boolean }[]
160}
161
162// Harness signals that arrive as user messages but are never the user's words (from auto-handoff).
163const META_PREFIXES = [
164 'Stop hook feedback',
165 '[handoff-guard]',
166 'Resume from the handoff in .claude/brain/',
167 '[Image',
168 '<system-reminder>',
169 '<command-name>',
170 '<local-command',
171 '<task-notification>',
172 '[Request interrupted',
173]
174
175export const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
176
177export function isMeta(text: string): boolean {
178 const t = text.trim()
179 return META_PREFIXES.some(p => t.startsWith(p))
180}
181
182export type Transcript = {
183 lastPrompt: string
184 lastAnswer: string
185 openQuestions: string[]
186 edited: string[]
187}
188
189/** The last real prompt, the last answer, unanswered AskUserQuestion calls and edited files. */
190export function extractFromMessages(messages: readonly Msg[]): Transcript {
191 let lastPrompt = ''
192 let lastAnswer = ''
193 const open: string[] = []
194 const answered = new Set<string>()
195 const edited = new Set<string>()
196 for (const m of Array.isArray(messages) ? messages : []) {
197 if (!m || typeof m !== 'object') continue
198 const text = typeof m.text === 'string' ? m.text.trim() : ''
199 if (m.role === 'user' && text && !isMeta(text)) lastPrompt = text
200 if (m.role === 'assistant' && text) lastAnswer = text
201 for (const t of Array.isArray(m.toolUses) ? m.toolUses : []) {
202 if (!t || typeof t.tool !== 'string') continue
203 const input = t.input && typeof t.input === 'object' ? t.input : {}
204 const path = input.file_path ?? input.notebook_path
205 if (EDIT_TOOLS.has(t.tool) && typeof path === 'string' && !t.isError) edited.add(path)
206 if (t.tool === 'AskUserQuestion') {
207 const qs = Array.isArray(input.questions) ? input.questions : []
208 const unanswered = t.isError === true || (t.result === undefined && t.text === undefined)
209 for (const q of qs) {
210 const s = q && typeof q === 'object' && typeof (q as { question?: unknown }).question === 'string' ? (q as { question: string }).question.trim() : ''
211 if (!s) continue
212 if (unanswered) open.push(s)
213 else answered.add(s)
214 }
215 }
216 }
217 }
218 const openQuestions = [...new Set(open.filter(q => !answered.has(q)))].slice(-5)
219 return { lastPrompt, lastAnswer, openQuestions, edited: [...edited] }
220}
221
222export type Facts = {
223 cwd: string
224 when: string
225 branch: string
226 commits: string[]
227 editedTurn: string[]
228 editedSession: string[]
229 lastPrompt: string
230 lastAnswer: string
231 openQuestions: string[]
232 stage: string
233 ctxPercent: number | null
234 ctxTokens: number | null
235 ctxWindow: number | null
236}
237
238export function clip(text: string, max: number): string {
239 const t = text.replace(/\s+/g, ' ').trim()
240 return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t
241}
242
243/** Strips credential-shaped strings before anything is written or sent. */
244export function redact(text: string): string {
245 return text
246 .replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)[A-Z0-9_]*)\s*[=:]\s*\S+/g, '$1=[redacted]')
247 .replace(/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})/g, '[redacted]')
248 .replace(/\b[A-Za-z0-9_-]{40,}\b/g, m => (/^[0-9a-f]{40}$/.test(m) ? m : '[redacted]'))
249}
250
251function rel(path: string, cwd: string): string {
252 const base = cwd.replace(/\/+$/, '') + '/'
253 return cwd && path.startsWith(base) ? path.slice(base.length) : path
254}
255
256/** The facts block: everything here came from code, not from a model. */
257export function factsBlock(f: Facts): string {
258 const list = (xs: string[], empty = 'None.') => (xs.length ? xs.map(x => `- ${x}`).join('\n') : empty)
259 const ctx =
260 f.ctxPercent === null
261 ? 'unknown'
262 : `${f.ctxPercent}%` + (f.ctxTokens !== null && f.ctxWindow !== null ? ` (${f.ctxTokens} of ${f.ctxWindow} tokens)` : '')
263 const lines = [
264 '## Facts (from code)',
265 `- Written: ${f.when}`,
266 `- Working directory: ${f.cwd || 'unknown'}`,
267 `- Git branch: ${f.branch || 'not a git repository'}`,
268 `- Context at handoff: ${ctx}`,
269 ]
270 if (f.stage) lines.push(`- Pipeline stage: ${f.stage}`)
271 lines.push('', '### Last commits', list(f.commits))
272 lines.push('', '### Files edited this turn', list(f.editedTurn.map(p => rel(p, f.cwd))))
273 const earlier = f.editedSession.filter(p => !f.editedTurn.includes(p))
274 lines.push('', '### Files edited earlier this session', list(earlier.slice(-20).map(p => rel(p, f.cwd))))
275 lines.push('', '### Unanswered questions to the user', list(f.openQuestions))
276 lines.push('', '### Last user request', f.lastPrompt ? clip(redact(f.lastPrompt), 600) : 'None found.')
277 lines.push('', '### Last answer (excerpt)', f.lastAnswer ? clip(redact(f.lastAnswer), 600) : 'None found.')
278 return lines.join('\n')
279}
280
281// ---------------------------------------------------------------- the brief
282
283export const HEADINGS = ['Status', 'Next steps', 'Dead ends', 'Open questions', 'Decisions', 'Log'] as const
284export type Heading = (typeof HEADINGS)[number]
285
286export function briefTemplate(maxWords: number): string {
287 return `Write the judgment sections of a session handoff for the next session in this project. Use exactly these six headings, in this order, and nothing before the first:
288
289## Status
290Two to four sentences: where the work stands, what works, what is broken.
291
292## Next steps
293- One to three concrete next steps, most important first.
294
295## Dead ends
296- Approaches tried and dropped this session, with the reason. Write "None." if there were none.
297
298## Open questions
299- Questions only the human can answer. Write "None." if there are none.
300
301## Decisions
302- One line per decision someone outside this repo should know, as a pointer (a file, an ADR or a commit), not the reasoning. Write "None." if there are none.
303
304## Log
305One line, past tense, under 250 characters: what changed. Next: the next step. Blocked: only when something is.
306
307Rules: use only what the conversation and the Facts block support. Copy any number exactly from the Facts block and write no other figures. No secrets, tokens, credentials, phone numbers or personal names. Plain sentences, no em dashes. Under ${maxWords} words in total.`
308}
309
310export function wordsFor(maxBriefTokens: number): number {
311 return Math.max(120, Math.min(600, Math.round(maxBriefTokens * 0.3)))
312}
313
314/** The fork's one user message: it already sees the conversation, so only facts and the template. */
315export function forkPrompt(facts: string, maxWords: number): string {
316 return `[handoff-guard] A side request from a mod, not from the user, and not part of the task: the context window is nearly full, so the session is writing its handoff before it clears. Do not call tools and do not continue the task. Reply with the handoff only.
317
318${facts}
319
320---
321
322${briefTemplate(maxWords)}`
323}
324
325/** A compact transcript for the $.model.complete fallback, which has no conversation of its own. */
326export function renderTranscript(messages: readonly Msg[], maxChars = 60_000): string {
327 const rows = (Array.isArray(messages) ? messages : []).slice(-80).map(m => {
328 const role = m.role === 'user' && isMeta(String(m.text ?? '')) ? 'system signal (not the user)' : m.role
329 const text = clip(redact(String(m.text ?? '')), 1500)
330 const tools = (Array.isArray(m.toolUses) ? m.toolUses : []).map(
331 t => ` [tool ${t.tool}${t.isError ? ' ERROR' : ''}] ${clip(redact(JSON.stringify(t.input ?? {})), 200)}`,
332 )
333 return [`### ${role}`, text, ...tools].filter(Boolean).join('\n')
334 })
335 const joined = rows.join('\n\n')
336 return joined.length > maxChars ? joined.slice(-maxChars) : joined
337}
338
339export function completePrompt(facts: string, transcript: string, maxWords: number): string {
340 // Data first, instructions last (as auto-handoff does).
341 return `${facts}\n\n## Conversation (most recent part)\n${transcript || '(empty)'}\n\n---\n\n${briefTemplate(maxWords)}`
342}
343
344export type Parsed = Record<Heading, string>
345
346/** The reply's six sections, or null when any heading is missing (then the facts stand alone). */
347export function parseBrief(text: unknown): Parsed | null {
348 if (typeof text !== 'string' || !text.trim()) return null
349 const t = text.replace(/^```[a-z]*\n?/i, '').replace(/\n?```\s*$/, '')
350 const re = /^#{1,4}\s*\**\s*(Status|Next steps|Dead ends|Open questions|Decisions|Log)\s*\**\s*:?\s*$/gim
351 const marks: { h: Heading; start: number; end: number }[] = []
352 let m: RegExpExecArray | null
353 while ((m = re.exec(t)) !== null) {
354 const h = HEADINGS.find(x => x.toLowerCase() === m![1].toLowerCase())
355 if (h) marks.push({ h, start: m.index, end: m.index + m[0].length })
356 }
357 const out: Partial<Parsed> = {}
358 marks.forEach((mk, i) => {
359 if (out[mk.h] !== undefined) return
360 out[mk.h] = t.slice(mk.end, i + 1 < marks.length ? marks[i + 1].start : t.length).trim()
361 })
362 return HEADINGS.every(h => out[h] !== undefined) ? (out as Parsed) : null
363}
364
365const NONE = /^(none|n\/a|nothing|no\b.*(?:dead ends?|questions?|decisions?))\.?$/i
366
367/** Bullet lines of a section ("None." gives []); prose without bullets is one item. */
368export function itemsOf(section: string): string[] {
369 const lines = section.split('\n').map(l => l.trim()).filter(Boolean)
370 const bullets = lines.filter(l => /^([-*•]|\d+[.)])\s+/.test(l)).map(l => l.replace(/^([-*•]|\d+[.)])\s+/, '').trim())
371 const items = bullets.length ? bullets : lines.length ? [lines.join(' ')] : []
372 return items.map(i => i.replace(/\s+/g, ' ').trim()).filter(i => i && !NONE.test(i))
373}
374
375// ---------------------------------------------------------------- unverified figures
376
377// A figure: 93, 93.5, 1,200, 72%, 93k, 3x. Not part of a word, path, version-free id or date.
378const FIGURE = /(?<![\w.\/#:@-])(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)(%|k\b|K\b|x\b)?(?![\w\/]|\.\d|-\d)/g
379
380function valueOf(num: string, suffix: string | undefined): number {
381 const n = Number(num.replace(/,/g, ''))
382 return suffix === 'k' || suffix === 'K' ? n * 1000 : n
383}
384
385/** Masks spans whose numbers are not claims: inline code, ISO dates, times, versions. */
386function maskSpans(text: string): string {
387 return text
388 .replace(/`[^`\n]*`/g, s => ' '.repeat(s.length))
389 .replace(/\b\d{4}-\d{2}-\d{2}\b/g, s => ' '.repeat(s.length))
390 .replace(/\b\d{1,2}:\d{2}\b/g, s => ' '.repeat(s.length))
391 .replace(/\bv?\d+\.\d+\.\d+\b/g, s => ' '.repeat(s.length))
392}
393
394/**
395 * Marks every figure in the model's text that the facts do not hold, by appending
396 * " (unverified)". Single-digit counts (step 1, 3 files) are left alone. Borrowed from
397 * auto-handoff's markUnverifiedFigures, widened from token figures to any number.
398 */
399export function markUnverifiedFigures(text: string, facts: string): { text: string; flagged: string[] } {
400 const allowed: number[] = []
401 for (const m of maskSpans(facts).matchAll(FIGURE)) allowed.push(valueOf(m[1], m[2]))
402 for (const m of facts.matchAll(/\d+(?:\.\d+)?/g)) allowed.push(Number(m[0]))
403 const masked = maskSpans(text)
404 const flagged: string[] = []
405 const cuts: { at: number; fig: string }[] = []
406 for (const m of masked.matchAll(FIGURE)) {
407 const v = valueOf(m[1], m[2])
408 if (!m[2] && Number.isInteger(v) && v < 10) continue
409 const k = m[2] === 'k' || m[2] === 'K'
410 if (allowed.some(a => (k ? Math.abs(a - v) < 1000 : a === v))) continue
411 flagged.push(m[0])
412 cuts.push({ at: (m.index ?? 0) + m[0].length, fig: m[0] })
413 }
414 let out = text
415 for (const c of cuts.reverse()) out = out.slice(0, c.at) + ' (unverified)' + out.slice(c.at)
416 return { text: out, flagged }
417}
418
419// ---------------------------------------------------------------- sections for the brain files
420
421export type Sections = {
422 /** Body of `## Status today`. */
423 status: string
424 /** Body of `## Next` (bullets). */
425 next: string
426 /** Body of `## Dead ends`, or null to leave the section as it is. */
427 deadEnds: string | null
428 /** One log line, without the date prefix. */
429 log: string
430 decisions: string[]
431 questions: string[]
432}
433
434const bullets = (xs: string[]) => xs.map(x => `- ${x}`).join('\n')
435
436function ctxPhrase(f: Facts): string {
437 return f.ctxPercent === null ? '' : `, context ${f.ctxPercent}%`
438}
439
440function atCtx(f: Facts): string {
441 return f.ctxPercent === null ? '' : ` at ${f.ctxPercent}% context`
442}
443
444/** What goes into the files: the model's sections when they parsed, else facts only. */
445export function buildSections(parsed: Parsed | null, f: Facts): Sections {
446 const stamp = `(Written by handoff-guard ${f.when}${ctxPhrase(f)}${f.branch ? `, branch ${f.branch}` : ''}${parsed ? '' : ', facts only'}.)`
447 const qs = [...f.openQuestions]
448 if (parsed) {
449 for (const q of itemsOf(parsed['Open questions'])) if (!qs.includes(q)) qs.push(q)
450 const next = itemsOf(parsed['Next steps']).slice(0, 5)
451 const dead = itemsOf(parsed['Dead ends'])
452 const log = itemsOf(parsed.Log)[0] ?? ''
453 return {
454 status: clip(parsed.Status.replace(/^[-*]\s+/gm, ''), 1200) + '\n\n' + stamp,
455 next: next.length ? bullets(next) : '- Re-read the last request in the log and continue.',
456 deadEnds: dead.length ? bullets(dead.slice(0, 6)) : null,
457 log: oneLine(log || `Handed off by handoff-guard${atCtx(f)}.`),
458 decisions: itemsOf(parsed.Decisions).slice(0, 5).map(x => oneLine(x)),
459 questions: qs.slice(0, 5).map(x => oneLine(x)),
460 }
461 }
462 const parts: string[] = []
463 if (f.commits.length) parts.push(`Last commits: ${f.commits.slice(0, 3).join('; ')}.`)
464 const edited = (f.editedTurn.length ? f.editedTurn : f.editedSession).slice(-6).map(p => rel(p, f.cwd))
465 if (edited.length) parts.push(`Edited: ${edited.join(', ')}.`)
466 if (f.stage) parts.push(`Pipeline stage: ${f.stage}.`)
467 if (f.lastPrompt) parts.push(`Last request: "${clip(redact(f.lastPrompt), 300)}"`)
468 const next = ['Re-read the last request below and check git status before continuing.']
469 if (f.lastPrompt) next.push(`Continue: ${clip(redact(f.lastPrompt), 160)}`)
470 return {
471 status: (parts.join(' ') || 'No facts could be collected.') + '\n\n' + stamp,
472 next: bullets(next),
473 deadEnds: null,
474 log: oneLine(
475 `(handoff-guard, facts only) Handed off${atCtx(f)}${edited.length ? ` after editing ${edited.slice(0, 3).join(', ')}` : ''}. Next: re-read the last request.`,
476 ),
477 decisions: [],
478 questions: qs.slice(0, 5).map(x => oneLine(x)),
479 }
480}
481
482/** The whole brief as shown in the pane: sections, then the facts. */
483export function briefMarkdown(s: Sections, facts: string): string {
484 return [
485 '## Status today',
486 s.status,
487 '',
488 '## Next',
489 s.next,
490 ...(s.deadEnds ? ['', '## Dead ends', s.deadEnds] : []),
491 '',
492 `Log: ${s.log}`,
493 ...(s.decisions.length ? ['', 'Decisions:', bullets(s.decisions)] : []),
494 ...(s.questions.length ? ['', 'Open questions:', bullets(s.questions)] : []),
495 '',
496 facts,
497 ].join('\n')
498}
499
500// ---------------------------------------------------------------- handoff folder file shapes
501
502/** One data line's text: no newlines, no leading bullet, under 300 characters (the handoff folder's line rule). */
503export function oneLine(text: string, max = 290): string {
504 return clip(redact(text.replace(/^\s*[-*]\s+/, '')), max)
505}
506
507export function datedLine(date: string, text: string): string {
508 return `- ${date} — ${oneLine(text)}`
509}
510
511export function slugOf(cwd: string): string {
512 const name = cwd.replace(/\/+$/, '').split('/').pop() ?? ''
513 return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
514}
515
516/** The handoff folder's project.md template, filled in, for a project that has no .claude/brain/ yet. */
517export function projectTemplate(slug: string, date: string): string {
518 return `---
519type: project
520name: "${slug}"
521group: ""
522status: active
523role: ""
524public: false
525live_url:
526summary: "${slug}: handoff written by handoff-guard; replace this line with what the project is."
527tags: [project, active]
528created: ${date}
529updated: ${date}
530---
531
532## What it is
533
534Not filled in yet: handoff-guard created this file. Describe the project here.
535
536## Why
537
538Not filled in yet.
539
540## Status today
541
542## Next
543
544## Dead ends
545`
546}
547
548export const FILE_HEADERS: Record<'log.md' | 'decisions.md' | 'questions.md', string> = {
549 'log.md': '# Log\n\nOne line per session, newest last: `- YYYY-MM-DD — what changed, what is next, what is blocked`.\n',
550 'decisions.md':
551 '# Decisions\n\nOne line per decision worth knowing outside this repo: `- YYYY-MM-DD — decision and why`. Append only; to reverse one, add a new dated line.\n',
552 'questions.md':
553 '# Questions\n\nOne line per question only the human can answer: `- YYYY-MM-DD — question`. Remove a line once it is answered and record the answer as a decision.\n',
554}
555
556/**
557 * Rewrites the body of `## <heading>` (up to the next `## ` or the end), keeping every
558 * other section and the frontmatter. A missing section is added at the end.
559 */
560export function replaceSection(doc: string, heading: string, body: string): string {
561 const lines = doc.replace(/\r\n/g, '\n').split('\n')
562 const head = `## ${heading}`
563 const start = lines.findIndex(l => l.trim() === head)
564 const block = [head, '', ...body.trim().split('\n'), '']
565 if (start === -1) {
566 const trimmed = lines.join('\n').replace(/\s+$/, '')
567 return trimmed + '\n\n' + block.join('\n')
568 }
569 let end = lines.length
570 for (let i = start + 1; i < lines.length; i++) {
571 if (/^## /.test(lines[i])) {
572 end = i
573 break
574 }
575 }
576 const out = [...lines.slice(0, start), ...block, ...lines.slice(end)]
577 return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
578}
579
580/** Sets `key: value` in the frontmatter block, when there is one. */
581export function setFrontmatter(doc: string, key: string, value: string): string {
582 const m = /^---\n([\s\S]*?)\n---\n/.exec(doc)
583 if (!m) return doc
584 const re = new RegExp(`^${key}:.*$`, 'm')
585 const front = re.test(m[1]) ? m[1].replace(re, `${key}: ${value}`) : `${m[1]}\n${key}: ${value}`
586 return `---\n${front}\n---\n` + doc.slice(m[0].length)
587}
588
589/** Appends a dated line unless the identical line is there (the handoff folder's append rule). */
590export function appendLine(doc: string, line: string): { doc: string; added: boolean } {
591 const body = doc.replace(/\s+$/, '')
592 if (body.split('\n').includes(line)) return { doc: body + '\n', added: false }
593 return { doc: body + '\n' + line + '\n', added: true }
594}
595
596/**
597 * The session's log line: replaces the line this session wrote before when it is still
598 * the last one (a second handoff in one session refines it), else appends.
599 */
600export function upsertLogLine(doc: string, line: string, previous: string | undefined): string {
601 const lines = doc.replace(/\s+$/, '').split('\n')
602 if (previous && lines.length && lines[lines.length - 1] === previous) {
603 lines[lines.length - 1] = line
604 return lines.join('\n') + '\n'
605 }
606 return appendLine(doc, line).doc
607}
608hooks/kit.ts 955 lines1// kit.ts: the shared UI kit (kit v3).
2//
3// The module has no imports and touches no mods API: every helper takes the elements a hook got
4// from `$.ui.resolve(e)` and the surface (`e.surface`) and returns an element tree.
5//
6// const k = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
7// return k.stack([k.hero({ kicker: 'right now', title: 'Reading client.test.js' }), k.tiles([...])], 1)
8//
9// What v2 changed on purpose (v1 painted grey fills and dimmed grey text, unreadable on the desktop):
10// - colors are theme keys (suggestion, success, warning, error, claude, inactive) that the host
11// tunes for light and dark; PALETTE below swaps them for named colors in one line;
12// - no dimColor, no backgroundColor, no yellow, white, gray, black or subtle on content: secondary
13// text is `inactive`, state is a bold `● word` chip (glyph and word, so color is never the only cue);
14// - hero and card are one round frame (paddingX 1), tiles are single-border boxes with the label
15// over a bold value; terminal and desktop draw the same structure (the Svg doodle is desktop-only);
16// - kicker, section and tile labels keep the caller's case (v1 upper-cased kicker and section);
17// - additive: line(label, chips, right?) draws one band line.
18//
19// Calibration (claude plugin test, Claude Code 2.1.294): the test harness validates the tree a
20// ui.render hook returns (a Text with a bogus prop makes `$.ui.mount` reject on both surfaces), but
21// not how the desktop paints a theme key. A Button given `plain: false` throws when built (plain and
22// autoFocus take only `true`), so the kit leaves false and undefined props out; a Box inside a Text
23// is refused. doodle() draws its Svg on the desktop only (a pane holding only an Svg opens empty in
24// the terminal).
25//
26// What v3 adds (strictly additive: every v2 export above renders as before). The desktop paints
27// Svg, and its light theme made the v1 fills unreadable, so v3 draws small pictures as Svg wherever
28// the element table has one (desktop, mobile, vscode) and as text on the terminal:
29// - pill(text, tone?): a fully rounded Svg chip (PILL 'svg', the default); PILL 'fill' draws a
30// theme-key backgroundColor under `inverseText` instead (the only fill in the kit); the v2
31// `● text` chip on the terminal;
32// - header(label, right?): an upper-cased `inactive` section label with a right-hand count;
33// - icon(name, tone?), typeBadge(fileName) and progress(ratio, opts?): Svg icons, file-type badges
34// and a rounded bar; a glyph, a bold label and the v2 bar on the terminal;
35// - buttons() items take an optional `variant` ('primary' | 'secondary');
36// - nav(items, currentKey): a pane's nav bar, the current item bold Text, the others plain Buttons;
37// - pure helpers: pillSvg, shortPath and extOf; TONE_HEX, the Svg colors of icons and bars, and
38// CHIP_HEX, the darker pill fills that keep white text at 4.5:1 or more.
39// Svg markup cannot read theme keys, so it uses fixed mid-tone hexes that read on light and dark
40// grounds (never a color word), and each distinct drawing is built once and cached.
41//
42// What v3.1 adds (spacing; additive): on the desktop, blocks stacked with no gap touch border to
43// border and wrapped rows sit line on line. So every wrapping row (buttons, tiles, nav) gets rowGap 1,
44// buttons get columnGap 2, and two new columns space a pane out:
45// - page(children): a pane's top-level column, one row between blocks;
46// - list(children): a column of repeated cards or blocks, one row between them.
47// Rows inside one card or block stay tight (stack with no gap); stack() keeps its props. Bands that
48// draw buttons() are one row, so the new gaps change nothing there. buttons() items also take
49// `autoFocus` (passed only when true).
50
51export const KIT_VERSION = '3.1.0'
52
53/** How pill() draws on a surface with Svg: 'svg' (a fully rounded chip) or 'fill' (a theme-key
54 * background). makeKit's `pill` option overrides it for one kit. */
55export const PILL: 'fill' | 'svg' = 'svg'
56
57/** kit v3: makeKit's options. `pill` overrides PILL for this kit. */
58export type KitOptions = { pill?: 'fill' | 'svg' }
59
60/** 'theme': theme keys that follow the person's light or dark theme. 'named': named colors, for a
61 * host that does not paint theme keys (never yellow: warn is red plus its glyph). One-line switch. */
62export const PALETTE: 'theme' | 'named' = 'theme'
63
64export type Surface = 'terminal' | 'desktop' | (string & {})
65export type Tone = 'info' | 'ok' | 'warn' | 'danger' | 'muted' | 'accent'
66export type Role = 'main' | 'recon' | 'execute' | 'verify' | 'hard' | 'reviewer' | 'other'
67export type DoodleState = 'running' | 'done' | 'failed' | 'waiting' | 'idle'
68/** The tones of pill() and icon(): the six tones plus violet. */
69export type PillTone = Tone | 'violet'
70export type IconName =
71 | 'eye'
72 | 'pencil'
73 | 'plus'
74 | 'branch'
75 | 'search'
76 | 'folder'
77 | 'folder-open'
78 | 'file'
79 | 'shield'
80 | 'flag'
81 | 'check'
82 | 'dot'
83 | 'clock'
84/** What `$.ui.resolve(e)` returns, loosely typed so the kit compiles without the engine's types. */
85export type Elements = Record<string, (props: Record<string, unknown>) => unknown>
86
87export type Kit = {
88 hero(p: { kicker?: string; title: string; sub?: string; icon?: string; tone?: Tone }): unknown
89 card(p: { title?: string; right?: string; tone?: Tone; children: unknown[] }): unknown
90 section(label: string, right?: string): unknown
91 tiles(items: { label: string; value: string; tone?: Tone }[]): unknown
92 badge(text: string, tone?: Tone): unknown
93 legend(items: { tone: Tone; label: string }[]): unknown
94 bar(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
95 /** One row, left side truncating, right side muted. `align: 'center'` (kit v3) centers a row holding a pill. */
96 row(p: { left: unknown[] | string; right?: unknown[] | string; key?: string; align?: 'center' }): unknown
97 kv(label: string, value: string, tone?: Tone): unknown
98 buttons(
99 items: {
100 key: string
101 label: string
102 hotkey?: string
103 onPress: (e?: unknown) => unknown
104 plain?: boolean
105 /** kit v3: the Button's variant, passed only when set. */
106 variant?: 'primary' | 'secondary'
107 /** kit 3.1: focus this Button when the pane opens; passed only when true. */
108 autoFocus?: boolean
109 }[],
110 ): unknown
111 doodle(role: Role, state?: DoodleState): unknown
112 stack(children: unknown[], gap?: number): unknown
113 /** kit 3.1: a pane's top-level column, one row between its blocks; null children are dropped. */
114 page(children: unknown[]): unknown
115 /** kit 3.1: a column of repeated cards or blocks, one row between them; null children are dropped. */
116 list(children: unknown[]): unknown
117 text(s: string, props?: Record<string, unknown>): unknown
118 /**
119 * One band line: `label ● a ● b right · text`. A chip is `{ text, tone? }`, a string (a muted
120 * chip) or an element such as badge(); at most four are drawn. `right` is a string or a list
121 * joined with ` · `, drawn `inactive` and truncated. One row high.
122 */
123 line(label: string, chips: unknown[], right?: string | string[]): unknown
124 /** kit v3: a rounded chip (Svg surfaces) or the v2 `● text` chip (terminal). */
125 pill(text: string, tone?: Tone | 'violet'): unknown
126 /** kit v3: one row, the label upper-cased, bold and `inactive`; `right` upper-cased, right-aligned. */
127 header(label: string, right?: string): unknown
128 /** kit v3: a 16×16 stroke icon (Svg surfaces) or one glyph in the tone (terminal). */
129 icon(name: IconName, tone?: Tone | 'violet'): unknown
130 /** kit v3: a file-type badge ('JS', 'PDF', …) for a file name; null when the name has no extension. */
131 typeBadge(fileName: string): unknown
132 /** kit v3: a rounded bar (Svg surfaces; `width` in CSS pixels, absent: the slot's width) or bar(). */
133 progress(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
134 /**
135 * kit v3: a pane's nav bar, one row keyed 'nav': the item whose key is `currentKey` as bold Text in the accent
136 * tone (not pressable), the others plain Buttons with their hotkeys (`1: Stage`); one without onPress quiet Text.
137 */
138 nav(items: { key: string; label: string; hotkey?: string; onPress?: () => unknown }[], currentKey: string): unknown
139 surface: Surface
140}
141
142/** The quiet color: secondary text, labels, units, timestamps, frame and tile borders. */
143const QUIET = 'inactive'
144
145const THEME_TONES: Record<Tone, string> = {
146 info: 'suggestion',
147 ok: 'success',
148 warn: 'warning',
149 danger: 'error',
150 muted: QUIET,
151 accent: 'claude',
152}
153
154const NAMED_TONES: Record<Tone, string> = {
155 info: 'blue',
156 ok: 'green',
157 warn: 'red',
158 danger: 'red',
159 muted: QUIET,
160 accent: 'magenta',
161}
162
163const TONE_COLORS = PALETTE === 'named' ? NAMED_TONES : THEME_TONES
164
165/** fg: the color of the tone, a theme key (a named color when PALETTE is 'named'). bg equals fg: v2
166 * draws no fills; the field stays so v1 callers compile. */
167export const TONES: Record<Tone, { fg: string; bg: string }> = {
168 info: { fg: TONE_COLORS.info, bg: TONE_COLORS.info },
169 ok: { fg: TONE_COLORS.ok, bg: TONE_COLORS.ok },
170 warn: { fg: TONE_COLORS.warn, bg: TONE_COLORS.warn },
171 danger: { fg: TONE_COLORS.danger, bg: TONE_COLORS.danger },
172 muted: { fg: TONE_COLORS.muted, bg: TONE_COLORS.muted },
173 accent: { fg: TONE_COLORS.accent, bg: TONE_COLORS.accent },
174}
175
176/**
177 * kit v3: the Svg colors of icons and bars, a mid-tone hex per tone that reads on light and dark grounds
178 * (Svg markup cannot read theme keys). warn and accent share the Claude orange: the theme's warning
179 * color is unreadable as a fill. A pill fills with CHIP_HEX instead.
180 *
181 * info #3B82F6 ok #22A35A warn #D97757 danger #E5484D accent #D97757 muted #8A8F98
182 * violet #8B5CF6
183 */
184export const TONE_HEX: Record<PillTone, string> = {
185 info: '#3B82F6',
186 ok: '#22A35A',
187 warn: '#D97757',
188 danger: '#E5484D',
189 accent: '#D97757',
190 muted: '#8A8F98',
191 violet: '#8B5CF6',
192}
193
194/**
195 * kit v3: a pill's fill per tone, darker than TONE_HEX so its white text has a contrast ratio of at
196 * least 4.5:1 (WCAG AA for small text). The ratios against white:
197 *
198 * info #2563EB 5.17 ok #15803D 5.02 warn #C2410C 5.18 danger #B91C1C 6.47 accent #C2410C 5.18
199 * muted #5B6170 6.20 violet #7C3AED 5.70
200 */
201export const CHIP_HEX: Record<PillTone, string> = {
202 info: '#2563EB',
203 ok: '#15803D',
204 warn: '#C2410C',
205 danger: '#B91C1C',
206 accent: '#C2410C',
207 muted: '#5B6170',
208 violet: '#7C3AED',
209}
210
211/** kit v3: the theme key a pill fills with, per tone (warn is 'claude': a 'warning' fill is unreadable). */
212const PILL_FILLS: Record<PillTone, string> = {
213 info: 'suggestion',
214 ok: 'success',
215 warn: 'claude',
216 danger: 'error',
217 accent: 'claude',
218 muted: QUIET,
219 violet: 'permission',
220}
221
222/** kit v3: the stroke of an icon drawn without a tone. */
223const ICON_STROKE = '#6B7280'
224/** kit v3: the track of progress(): the muted hex at quarter opacity. */
225const TRACK_HEX = '#8A8F98'
226/** kit v3: the text on a light fill (the amber JS badge); light text everywhere else. */
227const INK_DARK = '#1A1A1A'
228const INK_LIGHT = '#FFFFFF'
229
230/** kit v3: icon markup on a 16×16 grid, stroked with `currentColor`'s stand-in `S`. */
231const ICONS: Record<IconName, string> = {
232 eye: '<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>',
233 pencil: '<path d="M11 2.5 13.5 5 5.5 13H3v-2.5z"/><path d="M9.5 4 12 6.5"/>',
234 plus: '<path d="M8 3v10M3 8h10"/>',
235 branch:
236 '<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="4.5" cy="12.5" r="1.5"/><circle cx="11.5" cy="5" r="1.5"/>' +
237 '<path d="M4.5 5v6M11.5 6.5c0 2.5-2 3.5-4 3.8-1.6.2-3 .7-3 .7"/>',
238 search: '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/>',
239 folder: '<path d="M1.75 3.25h4.5l1.5 1.75h6.5v7.75H1.75z"/>',
240 'folder-open': '<path d="M1.75 12.75v-9.5h4.5l1.5 1.75h5v2"/><path d="M1.75 12.75 3.75 7h10.5l-2 5.75z"/>',
241 file: '<path d="M9 1.75H3v12.5h10v-8.5z"/><path d="M9 1.75v4h4"/>',
242 shield: '<path d="M8 1.75 13 3.75v4c0 3.2-2.2 5.4-5 6.5-2.8-1.1-5-3.3-5-6.5v-4z"/>',
243 flag: '<path d="M3 14.5V2"/><path d="M3 2.5h8.5L9.75 5.5l1.75 3H3"/>',
244 check: '<path d="M3 8.5 6 11.5 13 4.5"/>',
245 dot: '<circle cx="8" cy="8" r="3" fill="S"/>',
246 clock: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/>',
247}
248
249/** kit v3: the terminal glyph of each icon. */
250const ICON_GLYPHS: Record<IconName, string> = {
251 eye: '◉',
252 pencil: '✎',
253 plus: '+',
254 branch: '⎇',
255 search: '⌕',
256 folder: '▸',
257 'folder-open': '▾',
258 file: '·',
259 shield: '⛨',
260 flag: '⚑',
261 check: '✓',
262 dot: '●',
263 clock: '◷',
264}
265
266/** kit v3: file-type badges by extension: the label, its Svg fill and its terminal tone. */
267const FILE_TYPES: { exts: string[]; label: string; fill: string; tone: Tone }[] = [
268 { exts: ['js', 'jsx', 'mjs', 'cjs'], label: 'JS', fill: '#F0C419', tone: 'accent' },
269 { exts: ['ts', 'tsx'], label: 'TS', fill: '#3178C6', tone: 'info' },
270 { exts: ['pdf'], label: 'PDF', fill: '#E5484D', tone: 'danger' },
271 { exts: ['md', 'mdx'], label: 'MD', fill: '#4A7BD0', tone: 'info' },
272 { exts: ['doc', 'docx'], label: 'DOC', fill: '#2B579A', tone: 'info' },
273 { exts: ['csv'], label: 'CSV', fill: '#22A35A', tone: 'ok' },
274 { exts: ['json'], label: 'JSON', fill: '#64748B', tone: 'muted' },
275 { exts: ['yml', 'yaml'], label: 'YML', fill: '#64748B', tone: 'muted' },
276 { exts: ['html'], label: 'HTML', fill: '#E8743B', tone: 'accent' },
277 { exts: ['css'], label: 'CSS', fill: '#4A7BD0', tone: 'info' },
278 { exts: ['py'], label: 'PY', fill: '#3776AB', tone: 'info' },
279 { exts: ['sh'], label: 'SH', fill: '#64748B', tone: 'muted' },
280 { exts: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'], label: 'IMG', fill: '#8B5CF6', tone: 'accent' },
281 { exts: ['txt'], label: 'TXT', fill: '#64748B', tone: 'muted' },
282]
283const OTHER_FILL = '#64748B'
284
285const GLYPHS: Record<Role, string> = {
286 main: '◉',
287 recon: '◈',
288 execute: '▣',
289 verify: '✓',
290 hard: '◆',
291 reviewer: '◎',
292 other: '●',
293}
294
295const STATE_TONES: Record<DoodleState, Tone> = {
296 running: 'info',
297 done: 'ok',
298 failed: 'danger',
299 waiting: 'warn',
300 idle: 'muted',
301}
302
303const STATE_COLORS: Record<DoodleState, string> = {
304 running: TONES[STATE_TONES.running].fg,
305 done: TONES[STATE_TONES.done].fg,
306 failed: TONES[STATE_TONES.failed].fg,
307 waiting: TONES[STATE_TONES.waiting].fg,
308 idle: TONES[STATE_TONES.idle].fg,
309}
310
311// Desktop doodles (svg fills are not text colors, so they stay hex): 12×12 cells, 3 px each. B = body (by role), A = accent (by state), . = empty.
312const BODY_FILL: Record<Role, string> = {
313 main: '#6c63ff',
314 recon: '#1f9eaa',
315 execute: '#3b7ddd',
316 verify: '#2f9e55',
317 hard: '#b5523b',
318 reviewer: '#c08a1e',
319 other: '#7d8590',
320}
321
322const ACCENT_FILL: Record<DoodleState, string> = {
323 running: '#22d3ee',
324 done: '#4ade80',
325 failed: '#f87171',
326 waiting: '#facc15',
327 idle: '#e5e7eb',
328}
329
330const SPRITES: Record<Role, string[]> = {
331 // a crowned blob
332 main: [
333 '............',
334 '..A..AA..A..',
335 '..AAAAAAAA..',
336 '..BBBBBBBB..',
337 '.BBBBBBBBBB.',
338 '.BBAABBAABB.',
339 '.BBAABBAABB.',
340 '.BBBBBBBBBB.',
341 '.BBBAAAABBB.',
342 '.BBBBBBBBBB.',
343 '..BB....BB..',
344 '..BB....BB..',
345 ],
346 // a one-eyed scout drone
347 recon: [
348 '....BBBB....',
349 '..BBBBBBBB..',
350 '.BBBBBBBBBB.',
351 'BBBBAAAABBBB',
352 'BBBAAAAAABBB',
353 'BBBAA..AABBB',
354 'BBBAAAAAABBB',
355 'BBBBAAAABBBB',
356 '.BBBBBBBBBB.',
357 '..BBBBBBBB..',
358 '...B.BB.B...',
359 '..B..BB..B..',
360 ],
361 // a boxy robot with an antenna
362 execute: [
363 '.....AA.....',
364 '.....BB.....',
365 '.BBBBBBBBBB.',
366 '.BBBBBBBBBB.',
367 '.BBAABBAABB.',
368 '.BBAABBAABB.',
369 '.BBBBBBBBBB.',
370 '.BBAAAAAABB.',
371 '.BBBBBBBBBB.',
372 '...BBBBBB...',
373 '..BBB..BBB..',
374 '..BB....BB..',
375 ],
376 // a shield with a check mark
377 verify: [
378 '.BBBBBBBBBB.',
379 'BBBBBBBBBBBB',
380 'BBBBBBBBBBBB',
381 'BBBBBBBBBAAB',
382 'BBBBBBBBAABB',
383 'BAABBBBAABBB',
384 'BBAABBAABBBB',
385 '.BBAAAABBBB.',
386 '.BBBAABBBBB.',
387 '..BBBBBBBB..',
388 '...BBBBBB...',
389 '.....BB.....',
390 ],
391 // a gem golem
392 hard: [
393 '.....BB.....',
394 '....BBBB....',
395 '...BBBBBB...',
396 '..BBBBBBBB..',
397 '.BBAABBAABB.',
398 'BBBAABBAABBB',
399 'BBBBBBBBBBBB',
400 '.BBBBAABBBB.',
401 '..BBBBBBBB..',
402 '...BBBBBB...',
403 '....BBBB....',
404 '.....BB.....',
405 ],
406 // a round face with glasses
407 reviewer: [
408 '...BBBBBB...',
409 '..BBBBBBBB..',
410 '.BBBBBBBBBB.',
411 'BBBBBBBBBBBB',
412 'BAAAABBAAAAB',
413 'BA..AAAA..AB',
414 'BAAAABBAAAAB',
415 'BBBBBBBBBBBB',
416 'BBBBAAAABBBB',
417 '.BBBBBBBBBB.',
418 '..BBBBBBBB..',
419 '...BBBBBB...',
420 ],
421 // a ghost
422 other: [
423 '....BBBB....',
424 '..BBBBBBBB..',
425 '.BBBBBBBBBB.',
426 '.BBBBBBBBBB.',
427 'BBBAABBAABBB',
428 'BBBAABBAABBB',
429 'BBBBBBBBBBBB',
430 'BBBBBBBBBBBB',
431 'BBBBBBBBBBBB',
432 'BBBBBBBBBBBB',
433 'BBB.BBBB.BBB',
434 'B....BB....B',
435 ],
436}
437
438const CELL = 3
439const GRID = 12
440
441// ---------------------------------------------------------------- formatters
442
443/** '≈$0.08', '≈$1.2', '≈$12'; '$0' for zero, '<$0.01' below a cent, '—' when unknown. */
444export function money(usd: number | null | undefined): string {
445 if (typeof usd !== 'number' || !Number.isFinite(usd)) return '—'
446 const v = Math.max(0, usd)
447 if (v === 0) return '$0'
448 if (v < 0.005) return '<$0.01'
449 const cents = Math.round(v * 100) / 100
450 if (cents < 1) return '≈$' + cents.toFixed(2)
451 const dimes = Math.round(v * 10) / 10
452 if (dimes < 10) return '≈$' + dimes.toFixed(1)
453 return '≈$' + String(Math.round(v))
454}
455
456/** '0', '950', '1.5k', '37k', '1.2M', '15M'. */
457export function tokensShort(n: number): string {
458 if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '0'
459 if (n < 1000) return String(Math.round(n))
460 const k = n / 1000
461 if (k < 10) {
462 const r = Math.round(k * 10) / 10
463 if (r < 10) return trimZero(r.toFixed(1)) + 'k'
464 }
465 if (Math.round(k) < 1000) return String(Math.round(k)) + 'k'
466 const m = n / 1_000_000
467 const r = Math.round(m * 10) / 10
468 if (r < 10) return trimZero(r.toFixed(1)) + 'M'
469 return String(Math.round(m)) + 'M'
470}
471
472/** '0:40', '10:05', '1:02:03'. */
473export function clock(ms: number): string {
474 const total = typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
475 const h = Math.floor(total / 3600)
476 const m = Math.floor((total % 3600) / 60)
477 const s = total % 60
478 if (h > 0) return `${h}:${pad2(m)}:${pad2(s)}`
479 return `${m}:${pad2(s)}`
480}
481
482/** '4%', '100%'. Not capped at 100 (a budget can run over); negative and NaN read 0%. */
483export function pct(ratio: number): string {
484 if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio <= 0) return '0%'
485 return String(Math.round(ratio * 100)) + '%'
486}
487
488/** '━━━━╌╌╌╌╌╌' for 0.4 at width 10; the ratio is clamped to 0..1. */
489export function barText(ratio: number, width: number = 10): string {
490 const w = typeof width === 'number' && Number.isFinite(width) && width >= 1 ? Math.floor(width) : 10
491 const filled = Math.round(clamp01(ratio) * w)
492 return '━'.repeat(filled) + '╌'.repeat(w - filled)
493}
494
495function clamp01(ratio: number): number {
496 if (typeof ratio !== 'number' || !Number.isFinite(ratio)) return 0
497 return Math.min(1, Math.max(0, ratio))
498}
499
500function pad2(n: number): string {
501 return n < 10 ? '0' + n : String(n)
502}
503
504function trimZero(s: string): string {
505 return s.endsWith('.0') ? s.slice(0, -2) : s
506}
507
508function toneOf(tone: unknown, fallback: Tone): Tone {
509 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONES, tone) ? (tone as Tone) : fallback
510}
511
512function roleOf(role: unknown): Role {
513 return typeof role === 'string' && Object.prototype.hasOwnProperty.call(GLYPHS, role) ? (role as Role) : 'other'
514}
515
516function stateOf(state: unknown): DoodleState {
517 return typeof state === 'string' && Object.prototype.hasOwnProperty.call(STATE_COLORS, state)
518 ? (state as DoodleState)
519 : 'idle'
520}
521
522/** Drops undefined and false props: some props (Button plain, autoFocus) take only `true`. */
523function clean(props: Record<string, unknown>): Record<string, unknown> {
524 const out: Record<string, unknown> = {}
525 for (const k of Object.keys(props)) {
526 const v = props[k]
527 if (v === undefined || v === false) continue
528 out[k] = v
529 }
530 return out
531}
532
533function present(children: unknown[]): unknown[] {
534 return children.filter(c => c !== null && c !== undefined && c !== false && c !== '')
535}
536
537/** The SVG document of one doodle: a 12×12 sprite at 3 px per cell, runs merged per row. */
538export function doodleSvg(role: Role, state: DoodleState = 'idle'): string {
539 const r = roleOf(role)
540 const s = stateOf(state)
541 const fills: Record<string, string> = { B: BODY_FILL[r], A: ACCENT_FILL[s] }
542 const size = GRID * CELL
543 let rects = ''
544 SPRITES[r].forEach((line, y) => {
545 let x = 0
546 while (x < GRID) {
547 const ch = line[x] ?? '.'
548 let end = x + 1
549 while (end < GRID && line[end] === ch) end++
550 const fill = fills[ch]
551 if (fill) {
552 rects += `<rect x="${x * CELL}" y="${y * CELL}" width="${(end - x) * CELL}" height="${CELL}" fill="${fill}"/>`
553 }
554 x = end
555 }
556 })
557 return (
558 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" ` +
559 `shape-rendering="crispEdges">${rects}</svg>`
560 )
561}
562
563/** The terminal glyph of a role, as doodle() draws it and as the desktop doodle's alt text. */
564export function glyph(role: Role): string {
565 return GLYPHS[roleOf(role)]
566}
567
568// ---------------------------------------------------------------- kit v3: pure helpers and Svg
569
570/** 'ts' for 'src/a.test.TS', '' for 'Makefile', '.gitignore' or 'notes.'. Lower case, no dot. */
571export function extOf(name: string): string {
572 const base = String(name ?? '').split(/[\\/]/).pop() ?? ''
573 const dot = base.lastIndexOf('.')
574 return dot > 0 && dot < base.length - 1 ? base.slice(dot + 1).toLowerCase() : ''
575}
576
577/**
578 * A path for display: inside `root` it is relative ('.' for the root itself), inside `home` it is
579 * '~/' and the rest, anywhere else '…/' and its last two segments, so no absolute path from outside
580 * the project is drawn. A relative path is already short and comes back as given. Trailing slashes
581 * are dropped.
582 */
583export function shortPath(path: string, root: string, home: string): string {
584 const p = trimSlash(path)
585 const r = trimSlash(root)
586 const h = trimSlash(home)
587 if (!p.startsWith('/')) return p
588 if (r && p === r) return '.'
589 if (r && r !== '/' && p.startsWith(r + '/')) return p.slice(r.length + 1)
590 if (h && p === h) return '~'
591 if (h && h !== '/' && p.startsWith(h + '/')) return '~/' + p.slice(h.length + 1)
592 return '…/' + p.split('/').filter(Boolean).slice(-2).join('/')
593}
594
595function trimSlash(s: string): string {
596 const t = String(s ?? '')
597 return t.length > 1 ? t.replace(/\/+$/, '') || '/' : t
598}
599
600const SVG_CACHE = new Map<string, string>()
601const SVG_CACHE_MAX = 512
602
603/** Builds each distinct drawing once; the cache is cleared when it grows past its bound. */
604function cached(key: string, build: () => string): string {
605 const hit = SVG_CACHE.get(key)
606 if (hit !== undefined) return hit
607 if (SVG_CACHE.size >= SVG_CACHE_MAX) SVG_CACHE.clear()
608 const svg = build()
609 SVG_CACHE.set(key, svg)
610 return svg
611}
612
613function xml(s: string): string {
614 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
615}
616
617/** Dark text on a light fill (relative luminance above one half), light text otherwise. */
618function inkOn(hex: string): string {
619 const n = parseInt(hex.slice(1), 16)
620 const lin = (c: number) => {
621 const v = c / 255
622 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
623 }
624 const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255)
625 return l > 0.5 ? INK_DARK : INK_LIGHT
626}
627
628function pillToneOf(tone: unknown, fallback: PillTone): PillTone {
629 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONE_HEX, tone) ? (tone as PillTone) : fallback
630}
631
632function iconOf(name: unknown): IconName {
633 return typeof name === 'string' && Object.prototype.hasOwnProperty.call(ICONS, name) ? (name as IconName) : 'dot'
634}
635
636const FONT = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
637
638/** A label in a rounded rect: the shape of a pill (height 18) and of a type badge (height 16). */
639function labelSvg(text: string, fill: string, width: number, height: number, rx: number, size: number, ink = inkOn(fill)): string {
640 return (
641 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` +
642 `<rect width="${width}" height="${height}" rx="${rx}" fill="${fill}"/>` +
643 `<text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="central" ${FONT} ` +
644 `font-size="${size}" font-weight="700" fill="${ink}">${xml(text)}</text></svg>`
645 )
646}
647
648/** A pill's height in CSS pixels; its corners are fully rounded (rx = height / 2). */
649const PILL_HEIGHT = 18
650
651/** A pill's width in CSS pixels: about 6.5 per character of 11 px bold text plus 7 each side. */
652function pillWidth(text: string): number {
653 return Math.max(PILL_HEIGHT, Math.round(text.length * 6.5 + 14))
654}
655
656/** The Svg of a pill: a fully rounded rect in the tone's CHIP_HEX fill, white text. */
657export function pillSvg(text: string, tone: Tone | 'violet' = 'muted'): string {
658 const t = pillToneOf(tone, 'muted')
659 const s = String(text)
660 return cached(`pill|${t}|${s}`, () => labelSvg(s, CHIP_HEX[t], pillWidth(s), PILL_HEIGHT, PILL_HEIGHT / 2, 11, INK_LIGHT))
661}
662
663function iconSvg(name: IconName, hex: string): string {
664 return cached(`icon|${name}|${hex}`, () =>
665 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" ` +
666 `stroke="${hex}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">` +
667 `${ICONS[name].replace(/"S"/g, `"${hex}"`)}</svg>`,
668 )
669}
670
671function fileType(fileName: string): { label: string; fill: string; tone: Tone } | null {
672 const ext = extOf(fileName)
673 if (!ext) return null
674 const known = FILE_TYPES.find(t => t.exts.includes(ext))
675 return known ?? { label: ext.slice(0, 3).toUpperCase(), fill: OTHER_FILL, tone: 'muted' }
676}
677
678/** A type badge is 16 high and about 7 px per letter plus 8 wide. */
679function badgeWidth(label: string): number {
680 return Math.max(16, label.length * 7 + 8)
681}
682
683function badgeSvg(label: string, fill: string): string {
684 return cached(`badge|${label}|${fill}`, () => labelSvg(label, fill, badgeWidth(label), 16, 3, 10))
685}
686
687/** A track and a fill, 6 high. Without a width the markup is 100% wide and stretches to the slot. */
688function progressSvg(ratio: number, hex: string, width: number | undefined): string {
689 const full = width ?? 100
690 const fill = Math.round(clamp01(ratio) * full * 10) / 10
691 return cached(`progress|${fill}|${hex}|${width ?? ''}`, () => {
692 const size = width === undefined ? 'width="100%" height="6" preserveAspectRatio="none"' : `width="${width}" height="6"`
693 return (
694 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${full} 6" ${size}>` +
695 `<rect width="${full}" height="6" rx="3" fill="${TRACK_HEX}" opacity="0.25"/>` +
696 (fill > 0 ? `<rect width="${fill}" height="6" rx="3" fill="${hex}"/>` : '') +
697 `</svg>`
698 )
699 })
700}
701
702// ---------------------------------------------------------------- the kit
703
704const MAX_CHIPS = 4
705
706export function makeKit(ui: Elements, surface: Surface, opts: KitOptions = {}): Kit {
707 const desktop = surface === 'desktop'
708 const pillMode = opts.pill ?? PILL
709
710 const el = (type: string, props: Record<string, unknown>, children?: unknown[]): unknown => {
711 const make = ui[type]
712 const p = clean(props)
713 if (children !== undefined) p.children = present(children)
714 return make(p)
715 }
716 const T = (s: string, props: Record<string, unknown> = {}): unknown => el('Text', props, [s])
717 const color = (tone: Tone | undefined): string | undefined => (tone ? TONES[tone].fg : undefined)
718 const pieces = (part: unknown[] | string, right: boolean): unknown[] =>
719 (typeof part === 'string' ? [part] : part).map(c =>
720 typeof c === 'string' || typeof c === 'number'
721 ? T(String(c), { color: right ? QUIET : undefined, wrap: 'truncate' })
722 : c,
723 )
724 const frame = (children: unknown[], tone: Tone | undefined): unknown =>
725 el('Box', { flexDirection: 'column', borderStyle: 'round', borderColor: color(tone) ?? QUIET, paddingX: 1 }, children)
726 const chip = (text: string, tone: Tone): unknown =>
727 T('● ' + text, { color: TONES[tone].fg, bold: tone !== 'muted', wrap: 'truncate' })
728 // kit v3: Svg wherever the table has it (desktop, mobile, vscode); the terminal draws text.
729 const svgOk = surface !== 'terminal' && typeof ui.Svg === 'function'
730 const svg = (source: string, alt: string, width?: number, height?: number): unknown =>
731 el('Svg', { source, alt: alt || ' ', width, height })
732 /** The theme key of a v3 tone on the terminal: the v2 tone's color, violet 'permission'. */
733 const fg = (tone: PillTone): string => (tone === 'violet' ? PILL_FILLS.violet : TONES[tone].fg)
734
735 const kit: Kit = {
736 surface,
737
738 hero(p) {
739 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
740 const title = (p.icon ? p.icon + ' ' : '') + p.title
741 return frame(
742 [
743 p.kicker ? T(p.kicker, { color: QUIET, wrap: 'truncate' }) : null,
744 T(title, { bold: true, color: color(tone), wrap: 'truncate' }),
745 p.sub ? T(p.sub, { color: QUIET, wrap: 'wrap' }) : null,
746 ],
747 tone,
748 )
749 },
750
751 card(p) {
752 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
753 const head =
754 p.title || p.right
755 ? el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
756 p.title ? T(p.title, { bold: true, wrap: 'truncate' }) : null,
757 p.right ? T(p.right, { color: QUIET, wrap: 'truncate' }) : null,
758 ])
759 : null
760 return frame([head, ...(Array.isArray(p.children) ? p.children : [])], tone)
761 },
762
763 section(label, right) {
764 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
765 T(String(label), { bold: true, wrap: 'truncate' }),
766 right ? T(right, { color: QUIET, wrap: 'truncate' }) : null,
767 ])
768 },
769
770 tiles(items) {
771 return el(
772 'Box',
773 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 1 },
774 items.map(it => {
775 const tone = it.tone ? toneOf(it.tone, 'info') : undefined
776 return el(
777 'Box',
778 { flexDirection: 'column', flexGrow: 1, borderStyle: 'single', borderColor: QUIET, paddingX: 1 },
779 [
780 T(it.label, { color: QUIET, wrap: 'truncate' }),
781 T(it.value, { bold: true, color: color(tone), wrap: 'truncate' }),
782 ],
783 )
784 }),
785 )
786 },
787
788 badge(text, tone) {
789 return chip(text, toneOf(tone, 'muted'))
790 },
791
792 legend(items) {
793 const parts: unknown[] = []
794 items.forEach((it, i) => {
795 if (i > 0) parts.push(' ')
796 parts.push(T('●', { color: TONES[toneOf(it.tone, 'muted')].fg }))
797 parts.push(' ' + it.label)
798 })
799 return el('Text', { wrap: 'wrap' }, parts)
800 },
801
802 bar(ratio, opts = {}) {
803 const t = toneOf(opts.tone, 'info')
804 return el('Text', { wrap: 'truncate' }, [
805 T(barText(ratio, opts.width ?? 10), { color: TONES[t].fg }),
806 T(' ' + pct(ratio), { color: QUIET }),
807 ])
808 },
809
810 row(p) {
811 const left = pieces(p.left, false)
812 const right = p.right === undefined || p.right === '' ? [] : pieces(p.right, true)
813 const alignItems = p.align === 'center' ? 'center' : undefined
814 return el('Box', { key: p.key, flexDirection: 'row', justifyContent: 'space-between', alignItems, columnGap: 1 }, [
815 el('Box', { flexDirection: 'row', flexShrink: 1, alignItems, columnGap: 1 }, left),
816 right.length ? el('Box', { flexDirection: 'row', alignItems, columnGap: 1 }, right) : null,
817 ])
818 },
819
820 kv(label, value, tone) {
821 const t = tone ? toneOf(tone, 'info') : undefined
822 return el('Text', { wrap: 'truncate' }, [
823 T(label + ' ', { color: QUIET }),
824 T(value, { bold: true, color: color(t) }),
825 ])
826 },
827
828 buttons(items) {
829 return el(
830 'Box',
831 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
832 items.map(b =>
833 el('Button', {
834 key: b.key,
835 label: b.label,
836 hotkey: b.hotkey,
837 onPress: b.onPress,
838 plain: b.plain === true ? true : undefined,
839 variant: b.variant === 'primary' || b.variant === 'secondary' ? b.variant : undefined,
840 autoFocus: b.autoFocus === true ? true : undefined,
841 }),
842 ),
843 )
844 },
845
846 doodle(role, state = 'idle') {
847 const r = roleOf(role)
848 const s = stateOf(state)
849 if (desktop && typeof ui.Svg === 'function') {
850 return el('Svg', { alt: GLYPHS[r], width: GRID * CELL, height: GRID * CELL, source: doodleSvg(r, s) })
851 }
852 return T(GLYPHS[r], { color: STATE_COLORS[s], bold: s === 'running' })
853 },
854
855 stack(children, gap = 0) {
856 return el('Box', { flexDirection: 'column', rowGap: gap > 0 ? Math.floor(gap) : undefined }, children)
857 },
858
859 page(children) {
860 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
861 },
862
863 list(children) {
864 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
865 },
866
867 text(s, props = {}) {
868 return el('Text', props, [String(s)])
869 },
870
871 line(label, chips, right) {
872 const drawn = (Array.isArray(chips) ? chips : [])
873 .filter(c => c !== null && c !== undefined && c !== false && c !== '')
874 .slice(0, MAX_CHIPS)
875 .map(c => {
876 if (typeof c === 'string') return chip(c, 'muted')
877 if (isChipSpec(c)) return chip(c.text, toneOf(c.tone, 'muted'))
878 return c
879 })
880 const quiet = (Array.isArray(right) ? right.filter(Boolean).join(' · ') : right ?? '').trim()
881 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 2 }, [
882 el('Box', { flexDirection: 'row', flexShrink: 1, columnGap: 2 }, [
883 T(String(label), { bold: true, wrap: 'truncate' }),
884 ...drawn,
885 ]),
886 quiet ? T(quiet, { color: QUIET, wrap: 'truncate' }) : null,
887 ])
888 },
889
890 // ------------------------------------------------------------ kit v3
891
892 pill(text, tone) {
893 const t = pillToneOf(tone, 'muted')
894 const s = String(text)
895 if (!svgOk) return t === 'violet' ? T('● ' + s, { color: fg(t), bold: true, wrap: 'truncate' }) : chip(s, t)
896 if (pillMode === 'svg') return svg(pillSvg(s, t), s, pillWidth(s), PILL_HEIGHT)
897 return T(` ${s} `, { backgroundColor: PILL_FILLS[t], color: 'inverseText', bold: true, wrap: 'truncate' })
898 },
899
900 header(label, right) {
901 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
902 T(String(label).toUpperCase(), { bold: true, color: QUIET, wrap: 'truncate' }),
903 right ? T(String(right).toUpperCase(), { color: QUIET, wrap: 'truncate' }) : null,
904 ])
905 },
906
907 icon(name, tone) {
908 const n = iconOf(name)
909 const t = tone === undefined ? undefined : pillToneOf(tone, 'muted')
910 if (svgOk) return svg(iconSvg(n, t ? TONE_HEX[t] : ICON_STROKE), n, 16, 16)
911 return T(ICON_GLYPHS[n], { color: t ? fg(t) : QUIET })
912 },
913
914 typeBadge(fileName) {
915 const type = fileType(fileName)
916 if (!type) return null
917 if (svgOk) {
918 return svg(badgeSvg(type.label, type.fill), type.label, badgeWidth(type.label), 16)
919 }
920 return T(type.label, { bold: true, color: TONES[type.tone].fg })
921 },
922
923 progress(ratio, opts = {}) {
924 if (!svgOk) return kit.bar(ratio, opts)
925 const t = toneOf(opts.tone, 'info')
926 const width = typeof opts.width === 'number' && Number.isFinite(opts.width) && opts.width >= 1 ? Math.floor(opts.width) : undefined
927 // Without a width the Svg gets no width prop and markup 100% wide: the user's look confirms the
928 // desktop draws it the full width of its slot.
929 return svg(progressSvg(ratio, TONE_HEX[t], width), pct(ratio), width, 6)
930 },
931
932 nav(items, currentKey) {
933 return el(
934 'Box',
935 { key: 'nav', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
936 (Array.isArray(items) ? items : []).map(it => {
937 if (it.key === currentKey) return T(String(it.label), { bold: true, color: TONES.accent.fg, wrap: 'truncate' })
938 if (typeof it.onPress !== 'function') return T(String(it.label), { color: QUIET, wrap: 'truncate' })
939 return el('Button', { key: it.key, label: String(it.label), hotkey: it.hotkey, onPress: it.onPress, plain: true })
940 }),
941 )
942 },
943 }
944 return kit
945}
946
947function isChipSpec(c: unknown): c is { text: string; tone?: Tone } {
948 return (
949 typeof c === 'object' &&
950 c !== null &&
951 !('type' in c) &&
952 typeof (c as { text?: unknown }).text === 'string'
953 )
954}
955types/index.d.ts 83 lines1export type Mode = 'ask' | 'auto' | 'off'
2
3/** The merged settings: defaults, ~/.claude/handoff-guard.json, <cwd>/.claude/handoff-guard.json, then /handoff changes in $.store. */
4export type Config = {
5 /** Context fraction where the band turns yellow and one toast shows, in (0, 1]. */
6 warnAt: number
7 /** Context fraction where the handoff is due (ask, auto or just a red band). */
8 handoffAt: number
9 /** Where "Later" asks again. */
10 reaskAt: number
11 /** In auto mode, new tool calls are refused from here until the handoff clears the session. */
12 denyAt: number
13 mode: Mode
14 /** Model alias for the brief when $.model.fork is unavailable. */
15 model: string
16 /** maxTokens for the $.model.complete fallback; also caps the words asked for. */
17 maxBriefTokens: number
18 /** Draw the band above the prompt (/handoff band on|off). */
19 band: boolean
20}
21
22/** One context measurement. */
23export type Reading = {
24 tokens: number | null
25 window: number | null
26 /** Fill as a fraction of the window, 0..1. */
27 frac: number
28 /** $.clock.now() when it was taken. */
29 at: number
30 /** start, step, complete or compact. */
31 where: string
32}
33
34/** The last handoff written, kept in $.state and mirrored to $.store per project. */
35export type BriefInfo = {
36 at: number
37 /** Local "YYYY-MM-DD HH:MM". */
38 when: string
39 /** Absolute path of project.md. */
40 path: string
41 sessionId: string
42 /** fork, complete, or facts (the model reply was unusable). */
43 source: string
44 /** Figures in the model text that the facts do not hold. */
45 flagged: string[]
46 /** The brief as written, capped for display. */
47 preview: string
48}
49
50declare module 'claude-code' {
51 interface PluginState {
52 'lk-handoff-guard': {
53 config: Config
54 reading: Reading | null
55 /** The last 30 readings, oldest first. */
56 history: Reading[]
57 /** The warnAt toast has shown this session. */
58 warned: boolean
59 /** The next fill that asks in ask mode; 0 means handoffAt. */
60 nextAskAt: number
61 /** No more asks this session (Later at the re-ask level). */
62 askedOut: boolean
63 /** "Off this session" was picked. */
64 sessionOff: boolean
65 /** Auto mode has tried once this session. */
66 autoTried: boolean
67 /** A handoff was written since the last /clear (or compaction). */
68 written: boolean
69 /** A /clear is queued after a handoff. */
70 clearing: boolean
71 /** A handoff is being written. */
72 busy: boolean
73 /** Files changed by Edit/Write/MultiEdit/NotebookEdit this session and this turn. */
74 editedSession: string[]
75 editedTurn: string[]
76 lastBrief: BriefInfo | null
77 /** "written <when>" while this session was seeded from a handoff and the user has not typed yet. */
78 resumed: string
79 cwd: string
80 }
81 }
82}
83