Hands a Claude Code session off to a fresh one before the context fills: a Haiku-written brief, /clear, and a seed that points the new session at the brief.

A Claude Code mod that hands a long session off to a fresh one before the context fills up. It replaces auto-compact.
At the threshold, Haiku writes a structured handoff brief to disk. Then the mod runs /clear and seeds the new session with one line that points at the brief. The fresh session reads the brief and keeps working.

A live run on Haiku with the threshold at 80k. The mod refuses a read at the threshold, writes the brief, clears, and the fresh session is back at work about 7 seconds later at 31k. (video)
Auto-compact summarizes in place, and you can't control what it keeps. A handoff brief has a fixed structure that you can edit. It covers work in progress, decisions, assumptions to verify, dead ends, your last request and whether it was answered, and the next step. The files, commits and issues sections come from the transcript in code, so they don't depend on the model's memory.
~/.claude/state/auto-handoff/<session-id>.md./clear and sends the fresh session one line: read the brief and follow its Instructions section. In the transcript, that line's brief path and viewer URL are drawn as links. Claude Code makes them clickable only when it detects a terminal that supports links. Over plain SSH it usually doesn't, so set FORCE_HYPERLINK=1 if your terminal handles links, or use the status line link below.✓ handed off · 162k → 45k with an open brief link, then collapses after 10 seconds. Failures, the loop-guard pause, a facts-only brief and a too-tight threshold stay up until you press Dismiss. Typing /clear yourself closes the panel, including one waiting for Dismiss, unless a handoff is running. The panel steps aside while a survey holds that band. The band is drawn on the terminal and desktop only, so on the mobile app or in VS Code the threshold, the result, and anything that stays up also arrive as a toast.~/.claude/state/auto-handoff/pages/. The page shows the brief and every handoff in the same run, linked in order. The served link is short, like http://100.x.y.z:3846/1a2b3c4d, so it fits on one line on a phone. By default the mod serves these pages on your Tailscale IP at port 3846, so you can open them from any device on your tailnet. Devices off your tailnet can't reach them. The server starts with the first session that loads the mod and runs while that session is open; if it stops, including when the mod reloads, the next session to finish a turn starts it again. A session that finds the port already taken logs one line and leaves the running server alone, since it serves the same pages. Without Tailscale, the mod serves on 127.0.0.1 instead, so the link opens only on this machine. If Tailscale comes up later, a session already serving on localhost keeps using it; the next new session can serve on the Tailscale IP.statusline/handoff-link.sh wraps your status line command and adds a ↪ <link> line when the session came from a handoff. Set it as the statusLine command in ~/.claude/settings.json, with your existing command after it: "statusLine": { "type": "command", "command": "~/claude-auto-handoff/statusline/handoff-link.sh ~/.claude/my-statusline.sh" }
It needs jq. It finds the link in the previous brief's header, which names this session in to: and the page in viewer:.
Loop guards stop a fresh session that starts large from handing off again right away. They also cap how many handoffs run in a row before you type something.
Requires a Claude Code build with mods (function-hook plugins).
git clone https://github.com/alexknowshtml/claude-auto-handoff.git ~/claude-auto-handoff
claude --plugin-dir ~/claude-auto-handoff
To load it in every session, set CLAUDE_CODE_PLUGIN_DIRS to the folder in your shell environment, or in the env block of ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-auto-handoff" } }
Every setting is a row in /config under auto-handoff. They're stored in ~/.claude/settings.json under pluginConfigs.
| Setting | Default | What it does |
|---|---|---|
threshold | 160000 | Context tokens that trigger a handoff. Sized for a 200k window: it leaves room for the brief and the turn in flight. A seeded session hands off no sooner than 40k past its own starting size, whatever this says; set it lower than that and the panel tells you where the line actually is |
maxConsecutiveHandoffs | 2 | Handoffs allowed before you type a prompt; past this, the mod pauses until you do |
briefTemplate | ~/.claude/auto-handoff/brief.md | Your copy of the sections Haiku writes |
instructionsTemplate | ~/.claude/auto-handoff/instructions.md | Your copy of what the fresh session is told to do |
ignoreFiles | blank | Regex for edited files to leave out of the brief, such as caches or synced state |
viewer | tailscale:3846 | Where to serve the brief pages, as host:port. tailscale as the host means this machine's Tailscale IP, or 127.0.0.1 when Tailscale isn't set up. Leave blank for no server; the link is then the local file |
Environment variables:
AUTO_HANDOFF_TOKENS=60000 overrides the threshold for one run, so you can watch a handoff without filling 160k first. It stays set in that shell after the test. Seeded sessions start near 45k, so a value under about 85k leaves them less than 40k of room: the mod then hands off at start + 40k instead and the panel shows threshold 60k (AUTO_HANDOFF_TOKENS) leaves 15k ... so you know the override is still live.AUTO_HANDOFF_DISABLE=1 turns the mod off for one session, viewer server included.DISABLE_AUTO_COMPACT also turns it off. When something else manages the context limit, such as a wrapper that pipes the session, /clear would break that pipe. The viewer server still runs there.The brief is shaped by two markdown files. The defaults live in this repo's templates/ folder:
templates/brief.md is the prompt Haiku gets after the transcript. Each ## heading is a section of the brief.templates/instructions.md goes at the top of the brief and tells the fresh session what to do with it.On a session's first start, the mod copies both files to ~/.claude/auto-handoff/ if they aren't there yet. Edit those copies, not the ones in the repo, so a git pull never overwrites your changes. The next handoff uses your version.
To get the current default back, delete your copy. The next start copies it fresh. To keep your files somewhere else, point briefTemplate or instructionsTemplate in /config at them.
brief.mdAdd, remove, rename or reorder ## sections. The text under each heading tells Haiku what to put there. A Haiku reply counts as valid if it contains at least one of your headings. Otherwise the mod falls back to a facts-only brief.
Leave out files and commits sections. The mod adds them from the transcript in code.
instructions.mdIt has one switch:
{{#priority}}Shown when the last request is not fully answered.{{/priority}}
{{^priority}}Shown when it is.{{/priority}}
The switch reads the brief's ## Last Request from the User section and its Status: line. Keep both in brief.md if you want it to work.
Everything the mod does is logged to ~/.claude/state/auto-handoff/auto-handoff.log.
claude plugin validate .
claude plugin test .
The mod hot-reloads when you save while it's loaded with --plugin-dir.
sh (Windows), the brief page is still written, and the link opens it as a local file instead of a server that never started. The viewer no longer shells out to mkdir.USERPROFILE when HOME is unset, so briefs no longer land in <project>/undefined/. Where there is no sh, the log is written through $.fs. The tests pass on Windows. The viewer server still needs a POSIX shell.[unverified: not in Handoff Numbers] and logged. The figure is marked, not removed./clear closes the panel. A second session that finds the viewer port taken exits quietly instead of logging a stack trace.open brief link.127.0.0.1 when Tailscale isn't available./clear and a seed prompt. It includes the tool gate, the check before each request, auto-compact replaced by a handoff, and the loop guards.MIT
hooks/register.tsx 616 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { assembleBrief, briefPrompt, extractFacts, isValidBrief, markUnverifiedFigures } from './brief.ts'
3import { chainOf, parseBrief, renderPage, viewerLink, withHeader } from './viewer.ts'
4import type { Entry } from './viewer.ts'
5import { SERVER_JS, parseAddress } from './server.ts'
6import { isSpinning, panelTree } from './panel.tsx'
7import type { Line, Panel } from './panel.tsx'
8import { BRIEF_DIR, DEFAULTS, LAST_RESORT_INSTRUCTIONS, MIN_HEADROOM, SEED_PREFIX, TEMPLATES, expand, k, linkify, parseConfig, short } from './config.ts'
9import type { Config, TemplateKey } from './config.ts'
10
11// At the token threshold, Haiku writes a handoff brief, the mod runs /clear, then seeds the
12// fresh session with a pointer to the brief. Interactive terminal sessions only: where a
13// wrapper pipes the session and owns the context limit (DISABLE_AUTO_COMPACT), the mod only logs.
14
15let cfg: Config = DEFAULTS
16// Origins the engine stamps on a prompt the person sent; the seed arrives as { kind: 'plugin' }.
17const USER_ORIGINS = new Set(['composer', 'bridge'])
18// Rough chars-per-token for tool output, used to project the next request's size.
19const CHARS_PER_TOKEN = 4
20// The panel above the prompt (panel.tsx) is the whole UI. No status entry: the host draws one
21// as "⚠ auto-handoff:", which reads as an error.
22const LOG = `~/${BRIEF_DIR}/auto-handoff.log`
23
24// problem: why the brief is facts only, when Haiku's summary was unusable.
25type Pending = { oldSession: string; briefPath: string; tokens: number; chain: string; link: string; problem?: string }
26
27
28
29// Module variables survive /clear; $.state does not.
30let pending: Pending | undefined
31let inFlight = false
32let seededSession: string | undefined
33let floor: number | undefined
34let unattended = 0 // handoffs since the user last typed a prompt
35let pausedSession: string | undefined
36// The handoff the fresh session came from, until its first request is measured and shown on the panel.
37let handedFrom: { session: string; tokens: number; link: string; problem?: string } | undefined
38// The seeded session's place in its chain of handoffs, for its own brief's header. Also kept in
39// the store as lineage:<session>, because a hot reload resets module variables: a session seeded
40// before a reload would otherwise start a new chain when it hands off.
41type Lineage = { from: string; chain: string; depth?: number }
42let lineage: Lineage | undefined
43// Tokens added since the last response measured the context: tool results and the
44// response's own output. turn.complete alone missed a turn whose reads jumped from 63k
45// straight past the window, because the request that would have measured it failed.
46let unmeasured = 0
47// The session whose tool call the gate refused. The refusal tells the model a handoff is coming,
48// so one must follow even when the next response measures under the threshold: the gate counts
49// tool output at CHARS_PER_TOKEN, which ran high in a live test (projected 83.7k, measured 72.5k)
50// and left a session that stopped working with no handoff.
51let gated: string | undefined
52// From the latest SessionStart; /clear starts a new transcript file.
53let transcriptPath: string | undefined
54
55// Windows sets USERPROFILE, not HOME. With HOME unset, every path built on it began with
56// "undefined/", which $.fs resolves under the session's working directory: briefs, templates
57// and pages landed inside the user's project.
58async function homeDir($: EngineInterface): Promise<string | undefined> {
59 return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
60}
61
62async function log($: EngineInterface, line: string) {
63 try {
64 const path = `${await homeDir($)}/${BRIEF_DIR}/auto-handoff.log`
65 const stamped = `${new Date().toISOString()} ${line}`
66 // Where there is a `sh`, append: an append is atomic, so concurrent sessions and the viewer
67 // server (which appends its own output here) never drop each other's lines.
68 try {
69 const { exitCode } = await $.process.run(['sh', '-c', 'mkdir -p "$(dirname "$2")" && printf "%s\\n" "$1" >> "$2"', 'sh', stamped, path])
70 if (exitCode === 0) return
71 } catch {}
72 // Windows has no `sh`. $.fs has no append, so the log is read and rewritten: two lines logged
73 // at the same instant can lose one. It keeps the last ~500 KB, cut at a line, so a read never
74 // hits $.fs's 4 MiB cap.
75 const old = await $.fs.read(path).catch(() => '')
76 const kept = typeof old !== 'string' ? '' : old.length > 1_000_000 ? old.slice(old.indexOf('\n', old.length - 500_000) + 1) : old
77 await $.fs.write(path, `${kept}${stamped}\n`)
78 } catch {}
79}
80
81
82const readText = async ($: EngineInterface, path: string) => {
83 try {
84 const text = await $.fs.read(path)
85 if (typeof text === 'string' && text.trim()) return text
86 } catch {}
87 return undefined
88}
89const shipped = ($: EngineInterface, key: TemplateKey) => `${$.plugin.root}/templates/${TEMPLATES.find(t => t[0] === key)![1]}`
90
91// The template file at its configured path, else the default the mod ships, else ''.
92async function template($: EngineInterface, key: TemplateKey): Promise<string> {
93 return await readText($, expand(cfg[key], await homeDir($) ?? '')) ?? await readText($, shipped($, key)) ?? ''
94}
95
96// A new session writes each template to its path if nothing is there yet, so the files exist
97// to be edited. A file the user wrote is never touched.
98async function writeMissingTemplates($: EngineInterface) {
99 const home = await homeDir($) ?? ''
100 for (const [key] of TEMPLATES) {
101 const path = expand(cfg[key], home)
102 try { await $.fs.read(path); continue } catch {}
103 const text = await readText($, shipped($, key))
104 if (!text) { await log($, `template default unreadable ${shipped($, key)}`); continue }
105 try { await $.fs.write(path, text) } catch (err) { await log($, `template write failed ${path} ${String(err)}`) }
106 }
107}
108
109// The viewer server's address: the Tailscale host resolved to this machine's IPv4, or localhost
110// when Tailscale is missing, logged out or has no address, so the link still opens on this machine.
111async function serveAddress($: EngineInterface): Promise<{ host: string; port: string } | undefined> {
112 const addr = cfg.viewer ? parseAddress(cfg.viewer) : undefined
113 if (!addr || addr.host !== 'tailscale') return addr
114 const local = { host: '127.0.0.1', port: addr.port }
115 try {
116 const { exitCode, stdout } = await $.process.run(['tailscale', 'ip', '-4'])
117 const ip = stdout.trim().split('\n')[0]?.trim()
118 return exitCode === 0 && ip ? { host: ip, port: addr.port } : local
119 } catch {
120 return local
121 }
122}
123
124let lastServeTry = 0
125// Starts the server detached (setsid, else nohup), so the pages stay served after this session
126// exits: a brief's link is opened later, often from a phone, long after the handoff. When a
127// server already holds the port, the new child exits at once, so a launch is safe to repeat.
128// Its output goes to the mod's log. Answers whether the launch ran: it needs `sh`, which Windows
129// lacks, and then the link falls back to the local file.
130async function launchServer($: EngineInterface, pagesDir: string, addr: { host: string; port: string }): Promise<boolean> {
131 try {
132 const home = await homeDir($)
133 const { exitCode } = await $.process.run(['sh', '-c', 'mkdir -p "$1"; if command -v setsid >/dev/null 2>&1; then d=setsid; else d=nohup; fi; $d node -e "$2" "$1" "$3" "$4" >>"$5" 2>&1 </dev/null &',
134 'sh', pagesDir, SERVER_JS, addr.host, addr.port, `${home}/${BRIEF_DIR}/auto-handoff.log`])
135 if (exitCode === 0) return true
136 await log($, `viewer server failed exit=${exitCode}`)
137 } catch (err) {
138 await log($, `viewer server failed ${String(err)}`)
139 }
140 return false
141}
142
143// Brings the server back if it died (a reboot, a crash). Called on startup and after each turn,
144// at most once in five minutes. Sessions with the kill switches set serve too: the switches stop
145// handoffs, and serving old briefs is not one.
146async function keepServing($: EngineInterface) {
147 if (Date.now() - lastServeTry < 300_000) return
148 lastServeTry = Date.now()
149 const addr = await serveAddress($)
150 const home = await homeDir($)
151 if (addr && home) await launchServer($, `${home}/${BRIEF_DIR}/pages`, addr)
152}
153
154/** Writes the page of every brief in sessionId's chain, so each page lists the whole chain. */
155async function writeChainPages($: EngineInterface, briefDir: string, pagesDir: string, sessionId: string): Promise<void> {
156 const own = await $.fs.read(`${briefDir}/${sessionId}.md`)
157 if (typeof own !== 'string') return
158 const all: Entry[] = []
159 for (const f of await $.fs.list(briefDir)) {
160 if (f.kind !== 'file' || !f.name.endsWith('.md')) continue
161 const id = f.name.slice(0, -3)
162 try {
163 const text = await $.fs.read(`${briefDir}/${f.name}`)
164 if (typeof text !== 'string') continue
165 const { header, body } = parseBrief(text)
166 all.push({ id, header, body })
167 } catch {}
168 }
169 const chain = chainOf(all, sessionId)
170 for (const e of chain) await $.fs.write(`${pagesDir}/${e.id}.html`, renderPage(e, chain))
171}
172
173// Writes the pages for sessionId's chain and makes sure the server is up. Returns the page's
174// link, or '' when the pages could not be written. Never throws: the viewer is not the handoff.
175async function viewer($: EngineInterface, briefDir: string, pagesDir: string, sessionId: string): Promise<string> {
176 try {
177 // $.fs.write creates pagesDir, so no `mkdir`: a subprocess Windows can't run.
178 await writeChainPages($, briefDir, pagesDir, sessionId)
179 const addr = await serveAddress($)
180 const served = addr && await launchServer($, pagesDir, addr)
181 return viewerLink(served ? addr : undefined, pagesDir, sessionId)
182 } catch (err) {
183 await log($, `viewer error session=${sessionId} ${String(err)}`)
184 return ''
185 }
186}
187
188async function storedLineage($: EngineInterface, sessionId: string): Promise<Lineage | undefined> {
189 try {
190 const v = await $.store.get(`lineage:${sessionId}`) as Partial<Lineage> | undefined
191 return typeof v?.from === 'string' && typeof v.chain === 'string' ? { from: v.from, chain: v.chain, depth: typeof v.depth === 'number' ? v.depth : undefined } : undefined
192 } catch {
193 return undefined
194 }
195}
196
197async function handoff($: EngineInterface, sessionId: string, tokens: number, threshold: number) {
198 try {
199 const own = sessionId === seededSession && lineage ? lineage : await storedLineage($, sessionId)
200 const messages = await $.session.messages()
201 const facts = extractFacts(messages, cfg.ignoreFiles)
202 const { base, source } = await configured($)
203 facts.handoffTokens = tokens
204 facts.threshold = threshold
205 facts.thresholdSource = threshold > base ? `${source} (${k(base)}), raised to leave ${k(MIN_HEADROOM)} above the starting size` : source
206 if (sessionId === seededSession && floor !== undefined) facts.seededSessionStartSize = floor
207 facts.unattendedCount = unattended
208 // A session with no lineage starts its chain. One seeded before depth existed stays unknown.
209 const depth = own ? own.depth : 1
210 if (depth !== undefined) facts.depth = depth
211 const briefTemplate = await template($, 'briefTemplate')
212 const result = await $.model.complete({
213 model: 'haiku',
214 system: 'You summarize coding sessions into precise handoff briefs.',
215 prompt: briefPrompt(messages, facts, briefTemplate),
216 maxTokens: 4_000,
217 timeoutMs: 60_000,
218 })
219 // A failed, empty or sectionless reply falls back to the facts.
220 const text = result.isAnswered ? result.text : ''
221 const problem = !result.isAnswered ? result.reason : !text.trim() ? 'empty' : !isValidBrief(text, briefTemplate) ? 'no-sections' : undefined
222 if (problem) await log($, `haiku brief unusable session=${sessionId} reason=${problem}; using facts-only brief`)
223 const checked = markUnverifiedFigures(text, facts)
224 if (!problem && checked.flagged.length) await log($, `brief figures not in Handoff Numbers session=${sessionId}: ${checked.flagged.join(', ')}`)
225 const home = await homeDir($)
226 const cwd = await $.session.cwd()
227 const brief = assembleBrief({
228 sessionId,
229 // Claude Code keeps transcripts under the cwd with every non-alphanumeric character as '-'.
230 transcript: transcriptPath ?? `~/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${sessionId}.jsonl`,
231 instructions: await template($, 'instructionsTemplate') || LAST_RESORT_INSTRUCTIONS,
232 }, facts, problem ? undefined : checked.text)
233 const briefDir = `${home}/${BRIEF_DIR}`
234 const briefPath = `${briefDir}/${sessionId}.md`
235 const pagesDir = `${briefDir}/pages`
236 const chain = own?.chain ?? sessionId
237 const header = { from: own?.from, chain, depth: depth !== undefined ? String(depth) : undefined, tokens: String(tokens), at: new Date().toISOString(), cwd }
238 await $.fs.write(briefPath, withHeader(header, brief))
239 const link = await viewer($, briefDir, pagesDir, sessionId)
240 pending = { oldSession: sessionId, briefPath, tokens, chain, link, problem }
241 steps($, [briefStep(problem), { mark: 'spin', text: 'clearing' }])
242 await $.store.set(`fired:${sessionId}`, problem ? `clearing-facts-only:${problem}` : 'clearing')
243 await log($, `brief written ${briefPath} (${brief.length} chars); queueing /clear`)
244 $.command.run({ command: 'clear' }).catch(async (err: unknown) => {
245 pending = undefined
246 // fired stays 'clearing', so this session does not try again; it carries on as it is.
247 failed($, '/clear was rejected', `this session keeps going; the brief is at ${briefPath}`)
248 await log($, `clear rejected ${String(err)}`)
249 })
250 } catch (err) {
251 pending = undefined
252 // fired stays 'briefing', which tryHandoff treats as an orphan: the next turn tries again.
253 failed($, 'no brief written', 'this session keeps going and tries again after the next turn')
254 await log($, `handoff error session=${sessionId} ${String(err)}; no clear`)
255 }
256}
257
258// Shared by turn.complete and turn.step: the fired, kill-switch and loop-guard checks, then
259// the handoff itself. Returns true when a handoff started.
260async function tryHandoff($: EngineInterface, sessionId: string, tokens: number, threshold: number, via: string): Promise<boolean> {
261 const fired = await $.store.get(`fired:${sessionId}`)
262 // Every caller checks inFlight first, so a 'briefing' marker seen here is an orphan: the
263 // module reloaded mid-handoff and the brief never landed. Retry instead of going quiet.
264 if (fired === 'briefing') await log($, `stale briefing marker session=${sessionId} (mod reloaded mid-handoff); retrying`)
265 else if (fired) return false
266
267 const pane = await paneVar($)
268 if (pane) {
269 await $.store.set(`fired:${sessionId}`, 'skipped-pane')
270 await log($, `skip session=${sessionId} tokens=${tokens} reason=${pane} set`)
271 return false
272 }
273
274 // Not marked fired: once the user types, the next turn can hand off.
275 if (unattended >= cfg.maxUnattended) {
276 if (pausedSession !== sessionId) {
277 pausedSession = sessionId
278 await log($, `loop guard session=${sessionId}: ${unattended} handoffs with no user prompt; paused until one`)
279 showPanel($, { header: { mark: 'warn', text: `auto-handoff paused after ${unattended} handoffs in a row` }, steps: [{ mark: 'warn', text: 'send a message to resume' }], sticky: true },
280 `paused after ${unattended} handoffs in a row: send a message to resume`)
281 }
282 return false
283 }
284 unattended++
285
286 inFlight = true
287 gated = undefined
288 await $.store.set(`fired:${sessionId}`, 'briefing')
289 await log($, `threshold session=${sessionId} tokens=${tokens} threshold=${threshold} via=${via}`)
290 showPanel($, { header: { mark: 'spin', text: `auto-handoff · ${k(tokens)} / ${k(threshold)}` }, steps: [{ mark: 'spin', text: 'writing brief' }] },
291 `context ${k(tokens)} is past ${k(threshold)}: handing off`)
292 // Not awaited: the brief can take a while and /clear only runs once the session is idle.
293 handoff($, sessionId, tokens, threshold).finally(() => { inFlight = false })
294 return true
295}
296
297// Kill switches. AUTO_HANDOFF_DISABLE turns the mod off for one session. DISABLE_AUTO_COMPACT
298// means something else owns the context limit (a wrapper that pipes the session, where /clear
299// would break the pipe), so the mod stays out of its way too.
300async function paneVar($: EngineInterface): Promise<string | undefined> {
301 // Literal names: the host lists the variables a module reads.
302 if (await $.env.get('AUTO_HANDOFF_DISABLE')) return 'AUTO_HANDOFF_DISABLE'
303 if (await $.env.get('DISABLE_AUTO_COMPACT')) return 'DISABLE_AUTO_COMPACT'
304 return undefined
305}
306
307// Whether tryHandoff would go ahead for this session. The tool gate refuses calls only then,
308// so a session the mod will not hand off (pane, loop guard, already fired) is never blocked.
309async function canHandOff($: EngineInterface, sessionId: string): Promise<boolean> {
310 if (unattended >= cfg.maxUnattended || await paneVar($)) return false
311 const fired = await $.store.get(`fired:${sessionId}`)
312 return !fired || fired === 'briefing'
313}
314
315// The panel above the prompt. A module variable rather than $.state: $.state does not survive
316// /clear, and the panel carries the handoff across it.
317const FRAME_MS = 100 // the host redraws the band ten times a second at most
318const DONE_MS = 10_000
319let shown: Panel | undefined
320let frame = 0
321let spin: Timer | undefined
322let collapse: Timer | undefined
323
324// The band above the prompt is drawn on the terminal and desktop only. On the mobile app or in
325// VS Code nothing shows it, so the moments that matter go out as a toast there too: the
326// threshold tripping, the result, and anything that stays up until dismissed. Never per step.
327const BAND_SURFACES = new Set(['terminal', 'desktop'])
328async function toastOffBand($: EngineInterface, text: string) {
329 try {
330 if ((await $.session.surfaces()).some((s) => !BAND_SURFACES.has(s))) $.ui.toast(text, { timeoutMs: 30_000 })
331 } catch (err) {
332 await log($, `surfaces error ${String(err)}`)
333 }
334}
335
336function showPanel($: EngineInterface, p: Panel, toast?: string) {
337 if (toast) void toastOffBand($, toast)
338 shown = p
339 collapse?.cancel()
340 collapse = undefined
341 if (isSpinning(p)) {
342 spin ??= $.clock.every(FRAME_MS, () => {
343 frame++
344 $.ui.invalidate('ui.render')
345 })
346 } else {
347 spin?.cancel()
348 spin = undefined
349 if (!p.sticky) collapse = $.clock.after(DONE_MS, () => hidePanel($))
350 }
351 $.ui.invalidate('ui.render')
352}
353
354function hidePanel($: EngineInterface) {
355 shown = undefined
356 spin?.cancel()
357 collapse?.cancel()
358 spin = collapse = undefined
359 $.ui.invalidate('ui.render')
360}
361
362// The steps under the panel's current header; a panel lost to a reload gets a plain one.
363function steps($: EngineInterface, lines: Line[]) {
364 showPanel($, { header: shown?.header ?? { mark: 'spin', text: 'auto-handoff' }, steps: lines })
365}
366
367// Adds a step to the panel on screen, or opens one under `header` when nothing is showing.
368function addStep($: EngineInterface, step: Line, header: Line, sticky = false) {
369 const p = shown ?? { header, steps: [] }
370 showPanel($, { ...p, steps: [...p.steps, step], sticky: p.sticky || sticky }, step.mark === 'spin' || step.mark === 'done' ? undefined : step.text)
371}
372
373// A facts-only brief is the one quiet failure: the handoff works, but the brief is thin.
374const briefStep = (problem?: string): Line => problem
375 ? { mark: 'warn', text: `brief is facts only: the summary failed (${problem})` }
376 : { mark: 'done', text: 'brief written' }
377
378function failed($: EngineInterface, what: string, next: string) {
379 showPanel($, { header: { mark: 'fail', text: `handoff failed: ${what}` }, steps: [{ mark: 'fail', text: next }, { mark: 'fail', text: `log: ${LOG}` }], sticky: true },
380 `handoff failed: ${what}. ${next}`)
381}
382
383// The fresh session's first measurement: the panel's last step, which says the handoff worked.
384// It collapses on its own unless the brief was facts only.
385function showHandedOff($: EngineInterface, fresh: number) {
386 if (!handedFrom) return
387 const { tokens, link, problem } = handedFrom
388 showPanel($, { header: { mark: 'done', text: `handed off · ${k(tokens)} → ${k(fresh)}` }, steps: problem ? [briefStep(problem)] : [], link: link || undefined, sticky: !!problem },
389 `↪ handed off · ${k(tokens)} → ${k(fresh)}${problem ? ' · the brief is facts only' : ''}`)
390 handedFrom = undefined
391}
392
393// The configured threshold and where it came from. The env var wins so a test run needs no
394// /config change; it also outlives the test in that shell, which is why the headroom warning names it.
395async function configured($: EngineInterface): Promise<{ base: number; source: string }> {
396 const env = Number(await $.env.get('AUTO_HANDOFF_TOKENS'))
397 return env > 0 ? { base: env, source: 'AUTO_HANDOFF_TOKENS' } : { base: cfg.threshold, source: 'threshold in /config' }
398}
399
400// A seeded session hands off no sooner than MIN_HEADROOM past its floor, whatever the threshold says.
401async function thresholdFor($: EngineInterface, sessionId: string): Promise<number> {
402 const { base } = await configured($)
403 return sessionId === seededSession ? Math.max(base, (floor ?? 0) + MIN_HEADROOM) : base
404}
405
406// Once per seeded session, as its floor lands: when the configured threshold leaves less than
407// MIN_HEADROOM above the floor, say so, with the number, the source, and where the line moved to.
408// Without this the 2026-10-04 chain looked like a guard bug; nobody had run `env | grep AUTO_HANDOFF`.
409let warnedSession: string | undefined
410async function warnTightThreshold($: EngineInterface, sessionId: string) {
411 if (floor === undefined || warnedSession === sessionId) return
412 warnedSession = sessionId
413 const { base, source } = await configured($)
414 const headroom = base - floor
415 if (headroom >= MIN_HEADROOM) return
416 await log($, `tight threshold session=${sessionId} threshold=${base} source=${source} floor=${floor} headroom=${headroom} effective=${floor + MIN_HEADROOM}`)
417 const left = headroom > 0 ? `leaves ${k(headroom)}` : 'is below'
418 addStep($, { mark: 'warn', text: `threshold ${k(base)} (${source}) ${left} this session's ${k(floor)} start: handing off at ${k(floor + MIN_HEADROOM)} instead` }, { mark: 'warn', text: 'auto-handoff' }, true)
419}
420
421
422export const register: Register = (on, options) => {
423 cfg = parseConfig(options)
424 on('turn.complete', async ($, e, next) => {
425 const r = await next(e)
426 try {
427 if (!e.agentId) await keepServing($)
428 if (e.agentId || inFlight || pending) return r // subagent turns fire turn.complete too
429 const tokens = (await $.session.usage()).context.tokens
430 if (tokens === undefined) return r
431 const sessionId = await $.session.id()
432 if (sessionId === seededSession && floor === undefined) {
433 // Fallback only: turn.step sets the floor from the seed turn's first response. Reached
434 // when that response carried no usage.
435 floor = tokens
436 await log($, `floor set at turn end session=${sessionId} tokens=${tokens} (first response carried no usage)`)
437 showHandedOff($, tokens)
438 await warnTightThreshold($, sessionId)
439 return r
440 }
441 const threshold = await thresholdFor($, sessionId)
442 if (tokens < threshold && gated !== sessionId) return r
443 await tryHandoff($, sessionId, tokens, threshold, gated === sessionId ? 'turn.complete gated' : 'turn.complete')
444 } catch (err) {
445 await log($, `turn.complete error ${String(err)}`)
446 }
447 return r
448 })
449
450 // Tool output lands in the next request; count it before that request is sent. Past the
451 // threshold, refuse the call instead: one step of parallel reads took a session from 67k to
452 // 437k with no request in between for turn.step to stop. A refused call never runs; the
453 // next request trips turn.step and the handoff goes through the normal path.
454 on('tool.call', async ($, e, next) => {
455 if (!e.agentId) {
456 try {
457 const sessionId = await $.session.id()
458 const tokens = (await $.session.usage()).context.tokens
459 const projected = (tokens ?? 0) + unmeasured
460 const threshold = await thresholdFor($, sessionId)
461 if (inFlight || pending || (tokens !== undefined && projected >= threshold && await canHandOff($, sessionId))) {
462 if (!inFlight && !pending) gated = sessionId
463 await log($, `tool refused session=${sessionId} tool=${e.tool} projected=${projected} threshold=${threshold}`)
464 return { deny: `[auto-handoff] Not run: the context is past the handoff threshold (${k(projected)} ≥ ${k(threshold)}). This session is handing off to a fresh one, which will redo this call. Make no more tool calls.` }
465 }
466 } catch (err) {
467 await log($, `tool.call gate error ${String(err)}`)
468 }
469 }
470 const r = await next(e)
471 if (!e.agentId && typeof r.text === 'string') unmeasured += Math.ceil(r.text.length / CHARS_PER_TOKEN)
472 return r
473 })
474
475 // Before each main-loop request: if the last measured size plus what has landed since
476 // crosses the threshold, end the turn here and hand off instead of sending a request
477 // that may overflow the window.
478 on('turn.step', async function* ($, e, next) {
479 if (!e.agentId && !inFlight && !pending && e.index > 0) {
480 try {
481 const tokens = (await $.session.usage()).context.tokens
482 const sessionId = await $.session.id()
483 const isSeedTurn = sessionId === seededSession && floor === undefined
484 if (tokens !== undefined && !isSeedTurn) {
485 const projected = tokens + unmeasured
486 const threshold = await thresholdFor($, sessionId)
487 const isGated = gated === sessionId
488 if ((projected >= threshold || isGated) && await tryHandoff($, sessionId, projected, threshold, `turn.step measured=${tokens}${isGated ? ' gated' : ''}`)) {
489 unmeasured = 0
490 // A gated session can measure under the threshold here; "would carry" a number below it reads as a bug.
491 const why = projected >= threshold
492 ? `The next request would carry about ${Math.round(projected / 1000)}k tokens (threshold ${Math.round(threshold / 1000)}k).`
493 : `A tool call was refused at the handoff threshold (${Math.round(threshold / 1000)}k).`
494 yield { kind: 'text', index: 0, text: `[auto-handoff] ${why} Stopping this turn to hand off to a fresh session.` }
495 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
496 return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null }
497 }
498 }
499 } catch (err) {
500 await log($, `turn.step error ${String(err)}`)
501 }
502 }
503 const before = unmeasured
504 const seeding = !e.agentId && seededSession !== undefined && floor === undefined
505 const r = yield* next(e)
506 // The response measured everything up to its request; its own output is new, and so is
507 // any tool output that landed while it streamed (core runs tools before the stream ends).
508 if (!e.agentId && r.usage) unmeasured = unmeasured - before + r.usage.output_tokens
509 // The seed turn's first request is the fresh session's true starting size. Measuring at
510 // the end of that turn instead let a busy first turn (47k to 129k of reads) set
511 // the floor at 129k, with the pre-request check off the whole way.
512 if (seeding && r.usage) {
513 floor = r.usage.input_tokens + r.usage.cache_read_input_tokens + r.usage.cache_creation_input_tokens
514 await log($, `floor session=${seededSession} tokens=${floor} (seed turn's first request)`)
515 showHandedOff($, floor)
516 await warnTightThreshold($, seededSession!)
517 }
518 return r
519 })
520
521 // The engine's own auto-compact runs ahead of the turn.step check (live tests 2026-10-03:
522 // two compactions, no turn.step line). Catch it here and hand off in its place.
523 on('session.compact', async ($, e, next) => {
524 if (e.trigger !== 'auto' || e.agentId) return next(e)
525 if (inFlight || pending) return { skip: 'auto-handoff in progress' }
526 try {
527 const sessionId = await $.session.id()
528 const tokens = ((await $.session.usage()).context.tokens ?? 0) + unmeasured
529 const threshold = await thresholdFor($, sessionId)
530 await log($, `auto-compact session=${sessionId} projected=${tokens}`)
531 if (await tryHandoff($, sessionId, tokens, threshold, 'session.compact')) {
532 unmeasured = 0
533 return { skip: 'auto-handoff: handing off to a fresh session instead of compacting' }
534 }
535 } catch (err) {
536 await log($, `session.compact error ${String(err)}`)
537 }
538 return next(e)
539 })
540
541 // The seed row in the transcript: the brief path and the viewer URL drawn as links, so a
542 // click opens them. The stored message stays as submitted; only the drawing changes. A
543 // Markdown element linkifies http:, https: and file: (the Link element refuses the
544 // Tailscale IP), and the panel draws its brief link the same way.
545 // Yields the band to a survey, and passes when there is nothing to show.
546 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
547 if (!shown || e.props.hasSurvey) return next(e)
548 return panelTree($.ui.resolve(e), shown, frame, () => hidePanel($))
549 })
550
551 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'plugin' } } }, async ($, e, next) => {
552 const origin = e.props.origin
553 if (origin.kind !== 'plugin' || origin.name !== $.plugin.name || !e.props.text.startsWith(SEED_PREFIX)) return next(e)
554 const { Markdown } = $.ui.resolve(e)
555 return <Markdown text={linkify(e.props.text)} />
556 })
557
558 on('prompt.submit', async ($, e, next) => {
559 if (e.origin && USER_ORIGINS.has(e.origin.kind)) {
560 unattended = 0
561 if (pausedSession) hidePanel($) // the pause panel's "send a message to resume" is done
562 pausedSession = undefined
563 }
564 return next(e)
565 })
566
567 on('classic.SessionStart', async ($, e, next) => {
568 const r = await next(e)
569 if (e.transcript_path) transcriptPath = e.transcript_path
570 if (e.source === 'startup') {
571 await writeMissingTemplates($)
572 await keepServing($)
573 }
574 // A /clear of the person's own leaves no handoff to report; the panel from the last one goes too.
575 if (e.source === 'clear' && !pending && !inFlight && shown) hidePanel($)
576 if (e.source !== 'clear' || !pending) return r
577 const p = pending
578 pending = undefined
579 try {
580 const newSession = await $.session.id()
581 seededSession = newSession
582 floor = undefined
583 unmeasured = 0
584 await $.store.set(`fired:${p.oldSession}`, `seeded:${newSession}`)
585 await log($, `seeding new=${newSession} from=${p.oldSession}`)
586 handedFrom = { session: p.oldSession, tokens: p.tokens, link: p.link, problem: p.problem }
587 steps($, [briefStep(p.problem), { mark: 'done', text: 'cleared' }, { mark: 'spin', text: 'starting the fresh session' }])
588 const own = await storedLineage($, p.oldSession)
589 const prior = own ? own.depth : 1
590 lineage = { from: p.oldSession, chain: p.chain, depth: prior !== undefined ? prior + 1 : undefined }
591 await $.store.set(`lineage:${newSession}`, lineage)
592 // The old brief learns where it went, and its chain's pages link forward.
593 try {
594 const old = parseBrief(await $.fs.read(p.briefPath) as string)
595 // viewer: the page link, read by the status line script for the session it handed off to.
596 await $.fs.write(p.briefPath, withHeader({ ...old.header, to: newSession, ...(p.link ? { viewer: p.link } : {}) }, old.body))
597 const briefDir = p.briefPath.replace(/\/[^/]+$/, '')
598 await viewer($, briefDir, `${briefDir}/pages`, p.oldSession)
599 } catch (err) {
600 await log($, `viewer forward link failed ${String(err)}`)
601 }
602 // One line on screen; the model reads the brief from disk. A full brief as the
603 // seed showed up as a wall of text the person never wrote.
604 const text = `${SEED_PREFIX} ${short(p.oldSession)}. The previous session hit its context limit and was cleared. Read the brief at ${p.briefPath} before doing anything else${p.link ? ` (readable copy: ${p.link})` : ''} and follow its Instructions section. Open your first reply with the line "↪ Handoff from session ${short(p.oldSession)}".`
605 $.prompt.submit({ text }).catch((err: unknown) => {
606 handedFrom = undefined
607 failed($, 'the seed prompt was rejected', `paste the brief path to carry on: ${p.briefPath}`)
608 return log($, `seed rejected ${String(err)}`)
609 })
610 } catch (err) {
611 await log($, `seed error ${String(err)}`)
612 }
613 return r
614 })
615}
616hooks/brief.ts 191 lines1import type { SessionMessage } from 'claude-code'
2import { renderTemplate, sectionHeadings } from './templates.ts'
3
4// Brief building.
5// Files, commits, issues and the last real request come from the transcript in code;
6// Haiku only writes the judgment sections. Its reply is checked, and the facts alone
7// stand in when it fails.
8
9const MAX_MESSAGES = 120
10const MAX_MSG_CHARS = 2_000
11const MAX_TRANSCRIPT_CHARS = 150_000
12
13// Harness signals that arrive as user messages but are never the user's words.
14const META_PREFIXES = ['Stop hook feedback', '[Automatic handoff]', '[auto-handoff]', '[Image', '<system-reminder>', '<command-name>', '<local-command']
15
16const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
17const GIT_COMMIT_OUTPUT = /^\[[\w./-]+(?: \(root-commit\))? ([0-9a-f]{7,})\] (.+)$/m
18
19export type Facts = {
20 filesModified: string[]
21 commits: string[]
22 issues: string[]
23 lastUserMessage?: string
24 handoffTokens?: number
25 threshold?: number
26 /** Where the base threshold came from: the env override or the /config setting. */
27 thresholdSource?: string
28 seededSessionStartSize?: number
29 /** Handoffs since the user last typed, this one included. */
30 unattendedCount?: number
31 /** Which handoff this is in its chain (1 for the first, 2 for the second, etc.). */
32 depth?: number
33}
34
35/** The user's own words, or undefined for a harness signal or a tool-result-only message. */
36export function userText(m: SessionMessage): string | undefined {
37 if (m.role !== 'user') return undefined
38 const raw = m.text.trim()
39 if (!raw) return undefined
40 return isMeta(raw) ? undefined : raw
41}
42
43function isMeta(text: string): boolean {
44 return META_PREFIXES.some(p => text.startsWith(p))
45}
46
47function commitSubject(command: string): string | undefined {
48 if (!/\bgit\b[^\n|;&]*\bcommit\b/.test(command)) return undefined
49 const heredoc = command.match(/<<\s*'?(\w+)'?\n([\s\S]*?)\n\1/)
50 if (heredoc) return heredoc[2]?.trim().split('\n')[0]
51 return command.match(/-m\s+(["'])(.+?)\1/)?.[2]
52}
53
54/** ignoreFiles: paths that never count as edits (auto-synced state, caches); the ignoreFiles setting. */
55export function extractFacts(messages: readonly SessionMessage[], ignoreFiles?: RegExp): Facts {
56 const files = new Set<string>()
57 const commits: string[] = []
58 const issues = new Set<string>()
59 let lastUserMessage: string | undefined
60 for (const m of messages) {
61 const said = userText(m)
62 if (said) lastUserMessage = said
63 for (const n of `${said ?? (m.role === 'assistant' ? m.text : '')}`.matchAll(/(?<![\w&/])#(\d{2,5})\b/g)) issues.add(`#${n[1]}`)
64 for (const t of m.toolUses) {
65 const path = t.input.file_path ?? t.input.notebook_path
66 if (EDIT_TOOLS.has(t.tool) && typeof path === 'string' && !t.isError && !ignoreFiles?.test(path)) files.add(path)
67 if (t.tool !== 'Bash' || typeof t.input.command !== 'string' || t.isError) continue
68 // git's own "[main abc1234] subject" line is the proof a commit landed; the command is the fallback
69 const out = t.text?.match(GIT_COMMIT_OUTPUT)
70 const subject = out ? `${out[2]} (${out[1]})` : t.text === undefined ? commitSubject(t.input.command) : undefined
71 if (subject && !commits.includes(subject)) commits.push(subject)
72 }
73 }
74 return { filesModified: [...files].slice(-20), commits: commits.slice(-10), issues: [...issues].slice(-15), lastUserMessage }
75}
76
77/** The transcript as Haiku reads it. Harness signals are labelled so they are not taken for the user. */
78export function renderTranscript(messages: readonly SessionMessage[]): string {
79 const lines = messages.slice(-MAX_MESSAGES).map(m => {
80 const role = m.role === 'user' && m.text.trim() && !userText(m) ? 'system signal (not the user)' : m.role
81 const text = m.text.length > MAX_MSG_CHARS ? m.text.slice(0, MAX_MSG_CHARS) + ' [truncated]' : m.text
82 const tools = m.toolUses.map(t => ` [tool ${t.tool}${t.isError ? ' ERROR' : ''}] ${JSON.stringify(t.input).slice(0, 300)}`)
83 return [`### ${role}`, text, ...tools].filter(Boolean).join('\n')
84 })
85 const joined = lines.join('\n\n')
86 return joined.length > MAX_TRANSCRIPT_CHARS ? joined.slice(-MAX_TRANSCRIPT_CHARS) : joined
87}
88
89function list(items: string[], empty: string): string {
90 return items.length ? items.map(i => `- ${i}`).join('\n') : empty
91}
92
93// The only token figures the brief may use. Haiku once wrote "burned its 200k budget" for a
94// session at 93k because the prompt held no numbers at all.
95function handoffNumbersBlock(f: Facts): string {
96 const lines = []
97 if (f.depth !== undefined) lines.push(`- **Handoff depth:** ${f.depth}`)
98 if (f.handoffTokens !== undefined) lines.push(`- **Tokens at handoff:** ${f.handoffTokens} (${k(f.handoffTokens)})`)
99 if (f.threshold !== undefined) lines.push(`- **Threshold:** ${f.threshold} (${k(f.threshold)})${f.thresholdSource ? `, from ${f.thresholdSource}` : ''}`)
100 if (f.seededSessionStartSize !== undefined) lines.push(`- **This session's starting size (seeded from a handoff):** ${f.seededSessionStartSize} (${k(f.seededSessionStartSize)})`)
101 if (f.unattendedCount !== undefined) lines.push(`- **Handoffs in a row with no user message:** ${f.unattendedCount}`)
102 return lines.length ? `## Handoff Numbers\n${lines.join('\n')}\n` : ''
103}
104
105const k = (n: number) => `${Math.round(n / 1000)}k`
106
107export function factsBlock(f: Facts, withLastMessage = true): string {
108 const numbers = handoffNumbersBlock(f)
109 const files = `## Files Modified (from Edit/Write calls)
110${list(f.filesModified, 'None.')}
111
112## Commits This Session
113${list(f.commits, 'None.')}
114
115## GitHub Issues Mentioned
116${f.issues.length ? f.issues.join(', ') : 'None.'}`
117 const all = [numbers, files].filter(Boolean).join('\n')
118 return withLastMessage ? `${all}\n\n## Last Real User Message (verbatim)\n${f.lastUserMessage ?? 'None found.'}` : all
119}
120
121export function briefPrompt(messages: readonly SessionMessage[], facts: Facts, template: string): string {
122 // Data first, instructions last.
123 return `## Extracted Facts\n${factsBlock(facts)}\n\n## Conversation\n${renderTranscript(messages)}\n\n---\n\n${template.trim()}`
124}
125
126/** A reply that holds none of the template's sections is a dialogue fragment, not a brief. */
127export function isValidBrief(text: string, template: string): boolean {
128 return sectionHeadings(template).some(h => text.includes(h))
129}
130
131// Whether the brief's last-request section
132// says the request is not (fully) answered. A chain with no user message is not a real request.
133export function hasUnansweredLastRequest(text: string): boolean {
134 const heading = /##\s*Last Request from the User/i.exec(text)
135 if (!heading) return false
136 const rest = text.slice(heading.index + heading[0].length)
137 const next = /^#{1,6}\s/m.exec(rest)
138 const section = next ? rest.slice(0, next.index) : rest
139 if (/No user message found|^\s*\[auto-handoff\]/i.test(section)) return false
140 return /\bStatus[\s*_`]*:[\s*_`"]*(Not (?:yet |fully )?answered|Partially answered|Unanswered)/i.test(section)
141}
142
143export type BriefContext = {
144 sessionId: string
145 /** The session's transcript file. */
146 transcript: string
147 /** The instructions template, rendered at the top of the brief. */
148 instructions: string
149}
150
151export function assembleBrief(ctx: BriefContext, facts: Facts, haiku: string | undefined): string {
152 const header = `${renderTemplate(ctx.instructions, { priority: haiku ? hasUnansweredLastRequest(haiku) : false })}
153
154## Session Handoff Brief
155
156- **Previous Session:** ${ctx.sessionId}
157- **Transcript:** \`${ctx.transcript}\`
158
159## How to Use This Brief
160${haiku ? 'Haiku wrote the judgment sections from conversation text with tool output abbreviated. The facts sections came from tool calls in code.' : 'Haiku did not return a usable brief, so this holds only facts extracted in code. Read the transcript for the rest.'} Treat every line as a starting point, not a fact. Before acting on anything here, spawn a subagent to verify: run git status, gh pr view, or Read the file directly. If a fact is missing, grep the transcript before asking the user.`
161 // A valid Haiku brief already quotes the last request in its own section.
162 return [header, haiku?.trim(), factsBlock(facts, !haiku)].filter(Boolean).join('\n\n')
163}
164
165// A token figure: "93k", "93.1k", "93,105 tokens", "93105 tokens".
166const TOKEN_FIGURE = /\b(\d{1,4}(?:\.\d+)?)k\b|\b(\d{1,3}(?:,\d{3})+|\d{4,7})(?= tokens\b)/gi
167
168const figureValue = (k?: string, whole?: string) => k !== undefined ? Number(k) * 1000 : Number(whole!.replace(/,/g, ''))
169
170/**
171 * Marks every token figure in Haiku's text that the Handoff Numbers block does not hold, within
172 * rounding to the nearest thousand. The template asks Haiku to copy those numbers; this makes it a
173 * rule. Figures are marked, not removed, so the next session sees what was claimed and that it is
174 * unchecked. With no numbers block, every figure is marked.
175 */
176export function markUnverifiedFigures(text: string, f: Facts): { text: string; flagged: string[] } {
177 const block = handoffNumbersBlock(f)
178 const allowed = [
179 ...[...block.matchAll(/\b(\d{1,4}(?:\.\d+)?)k\b/g)].map(m => figureValue(m[1])),
180 ...[...block.matchAll(/\b\d{4,7}\b/g)].map(m => Number(m[0])),
181 ]
182 const flagged: string[] = []
183 const marked = text.replace(TOKEN_FIGURE, (match: string, k?: string, whole?: string) => {
184 const value = figureValue(k, whole)
185 if (allowed.some(a => Math.abs(a - value) < 1000)) return match
186 flagged.push(match)
187 return `${match} [unverified: not in Handoff Numbers]`
188 })
189 return { text: marked, flagged }
190}
191hooks/viewer.ts 174 lines1// The handoff viewer: one self-contained HTML page per brief, written to a pages/ folder beside
2// the briefs. Each brief starts with a small header (from, to, chain, tokens, at, cwd); briefs that
3// share a chain id or a from/to link are one run of handoffs, and every page in a run lists all of them.
4// The page renders the brief's markdown in the browser from a CDN, so the mod ships no packages.
5
6export type Header = { from?: string; to?: string; chain?: string; depth?: string; tokens?: string; at?: string; cwd?: string; viewer?: string }
7export type Entry = { id: string; header: Header; body: string }
8
9const HEADER_KEYS = ['from', 'to', 'chain', 'depth', 'tokens', 'at', 'cwd', 'viewer'] as const
10
11/** Splits a brief into its header and body. A brief with no header is a chain of one. */
12export function parseBrief(text: string): { header: Header; body: string } {
13 const m = /^---\n([\s\S]*?)\n---\n?/.exec(text)
14 if (!m) return { header: {}, body: text }
15 const header: Header = {}
16 for (const line of m[1]!.split('\n')) {
17 const kv = /^(\w+):\s*(.*)$/.exec(line)
18 if (kv && (HEADER_KEYS as readonly string[]).includes(kv[1]!)) header[kv[1] as keyof Header] = kv[2]!.trim()
19 }
20 return { header, body: text.slice(m[0].length) }
21}
22
23export function withHeader(header: Header, body: string): string {
24 const lines = HEADER_KEYS.filter(k => header[k] !== undefined).map(k => `${k}: ${header[k]}`)
25 return `---\n${lines.join('\n')}\n---\n${body}`
26}
27
28/** Every brief linked to id, oldest first. Briefs join through a shared chain id or a from/to
29 * link, so a run whose chain id broke partway (a hot reload once dropped it) still reads as one. */
30export function chainOf(entries: readonly Entry[], id: string): Entry[] {
31 const parent = new Map<string, string>()
32 const find = (x: string): string => {
33 let r = x
34 while (parent.has(r) && parent.get(r) !== r) r = parent.get(r)!
35 parent.set(x, r)
36 return r
37 }
38 const join = (a: string, b: string) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb) }
39 for (const e of entries) {
40 join(e.id, `chain:${e.header.chain || e.id}`)
41 if (e.header.from) join(e.id, e.header.from)
42 if (e.header.to) join(e.id, e.header.to)
43 }
44 const root = find(id)
45 return entries.filter(e => find(e.id) === root).sort((a, b) => (a.header.at ?? '').localeCompare(b.header.at ?? ''))
46}
47
48/** The page's address: served when the mod runs a server, else the local file. The served link
49 * uses the session id's first 8 characters, short enough to stay on one line on a phone. */
50export function viewerLink(serve: { host: string; port: string } | undefined, pagesDir: string, sessionId: string): string {
51 if (serve) return `http://${serve.host}:${serve.port}/${sessionId.slice(0, 8)}`
52 // A Windows path (C:\Users\x) becomes file:///C:/Users/x; a POSIX path passes through.
53 const dir = pagesDir.replace(/\\/g, '/').replace(/^(?=[A-Za-z]:)/, '/')
54 return `file://${dir}/${sessionId}.html`
55}
56
57// Sections meant for the fresh session, not for a person reading the page.
58const HIDDEN = new Set(['Instructions', 'How to Use This Brief'])
59
60/** The brief's "## " sections, minus the ones written for the model. */
61export function sections(body: string): string[] {
62 return body.split(/^(?=## )/m).map(s => s.trim()).filter(s => s && !HIDDEN.has(/^## (.+)$/m.exec(s)?.[1]?.trim() ?? ''))
63}
64
65function oneLiner(body: string): string {
66 const m = /## Work in Progress\s*\n+([\s\S]+?)(?:\n\n|\n##|$)/.exec(body)
67 if (!m) return ''
68 const s = m[1]!.replace(/\s+/g, ' ').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^\d+\.\s*|^-\s*/, '').trim()
69 return s.length > 120 ? `${s.slice(0, 117)}…` : s
70}
71
72const esc = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
73const short = (id: string) => id.slice(0, 8)
74const when = (iso?: string) => {
75 if (!iso) return ''
76 const d = new Date(iso)
77 return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' ') + ' UTC'
78}
79const tokens = (t?: string) => (t && Number(t) ? ` · ${Math.round(Number(t) / 1000)}k` : '')
80const chip = (id: string, current: boolean) => `<span class="chip${current ? ' on' : ''}" title="${esc(id)}">${esc(short(id))}</span>`
81
82/** The page for one brief. chain is every brief in its chain, oldest first (the brief itself included). */
83export function renderPage(entry: Entry, chain: readonly Entry[]): string {
84 const { header } = entry
85 // Previous and next are the neighbours in the chain, not the header's from/to: a header can
86 // miss a link the chain recovered. The last brief's session has no page until it hands off.
87 const at = chain.findIndex(e => e.id === entry.id)
88 const prev = chain[at - 1]
89 const next = at >= 0 ? chain[at + 1] : undefined
90 const meta = [
91 chip(entry.id, false),
92 `<span>${esc(when(header.at))}${esc(tokens(header.tokens))}</span>`,
93 header.cwd ? `<span>${esc(header.cwd)}</span>` : '',
94 prev ? `<a href="${esc(prev.id)}.html">← previous</a>` : '',
95 next ? `<a href="${esc(next.id)}.html">next →</a>`
96 : header.to ? `<span title="${esc(header.to)}">next: ${esc(short(header.to))}, still running</span>` : '',
97 ].filter(Boolean).join('')
98 const rows = chain.length > 1 ? chain.map((e, i) => {
99 const current = e.id === entry.id
100 const c = current ? chip(e.id, true) : `<a href="${esc(e.id)}.html">${chip(e.id, false)}</a>`
101 const latest = i === chain.length - 1 ? '<span class="latest">latest</span>' : ''
102 const wip = oneLiner(e.body)
103 return `<div class="row"><div>${c}</div><div class="rowtext"><div><span class="when">${i + 1}. ${esc(when(e.header.at))}${esc(tokens(e.header.tokens))}</span>${latest}</div>${wip ? `<div class="wip">${esc(wip)}</div>` : ''}</div></div>`
104 }).join('') : ''
105 const history = rows ? `<div class="section"><h2>Handoff Chain</h2>${rows}</div>` : ''
106 // JSON in a script tag: escape "<" so a brief can never close the tag.
107 const data = JSON.stringify(sections(entry.body)).replace(/</g, '\\u003c')
108 return `<!DOCTYPE html>
109<html lang="en">
110<head>
111<meta charset="utf-8">
112<meta name="viewport" content="width=device-width,initial-scale=1">
113<title>Session Context ${esc(short(entry.id))}</title>
114<link rel="preconnect" href="https://fonts.googleapis.com">
115<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
116<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;700&family=Fraunces:opsz,wght@9..144,400;9..144,700;9..144,900&display=swap" rel="stylesheet">
117<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
118<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"></script>
119<style>
120 :root { color-scheme: light; --paper: #F7F3ED; --ink: #1C1C1C; --red: #E63946; --blue: #457B9D; --green: #2A9D8F;
121 --font-display: 'Fraunces', serif; --font-body: 'DM Sans', sans-serif; --font-mono: 'DM Mono', monospace; }
122 * { margin: 0; padding: 0; box-sizing: border-box; }
123 body { font-family: var(--font-body); background: var(--ink); color: var(--ink); font-size: 16px; line-height: 1.7; -webkit-font-smoothing: antialiased; min-height: 100vh; }
124 .card { max-width: 760px; margin: 0 auto; padding-bottom: 48px; }
125 .header { background: var(--ink); padding: 28px 16px 24px; }
126 .header h1 { font-family: var(--font-display); font-size: 1.5rem; font-weight: 900; color: var(--paper); margin: 0 0 8px; }
127 .meta { font-family: var(--font-mono); font-size: 12px; color: rgba(247,243,237,0.5); display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; }
128 .meta a { color: rgba(247,243,237,0.75); text-decoration: none; }
129 .meta a:hover { color: var(--paper); }
130 .meta .chip { background: rgba(247,243,237,0.1); color: rgba(247,243,237,0.7); }
131 .chip { font-family: var(--font-mono); font-size: 11px; background: rgba(28,28,28,0.08); color: var(--ink); padding: 2px 7px; border-radius: 6px; white-space: nowrap; }
132 .chip.on { background: var(--ink); color: var(--paper); }
133 .section { background: var(--paper); padding: 24px 16px; border-bottom: 1px solid rgba(28,28,28,0.1); overflow-wrap: break-word; }
134 @media (min-width: 600px) { .header, .section { padding-left: 32px; padding-right: 32px; } }
135 .row { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid rgba(28,28,28,0.08); }
136 .row a { text-decoration: none; }
137 .rowtext { min-width: 0; flex: 1; }
138 .when { font-size: 12px; color: #777; font-family: var(--font-mono); margin-right: 6px; }
139 .latest { font-size: 10px; font-family: var(--font-mono); background: var(--green); color: #fff; padding: 1px 6px; border-radius: 4px; }
140 .wip { font-size: 13px; color: #555; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
141 h2 { font-family: var(--font-display); font-weight: 700; font-size: 1.35rem; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--ink); line-height: 1.2; }
142 h3 { font-family: var(--font-display); font-weight: 700; font-size: 1.05rem; color: var(--blue); margin: 16px 0 6px; }
143 p { margin: 0 0 12px; } p:last-child { margin-bottom: 0; }
144 code { font-family: var(--font-mono); background: rgba(28,28,28,0.07); padding: 1px 5px; border-radius: 3px; font-size: 0.85em; word-break: break-all; }
145 pre { background: var(--ink); color: var(--paper); padding: 14px 18px; margin: 12px 0; font-size: 0.85em; white-space: pre-wrap; word-break: break-all; box-shadow: 4px 4px 0 var(--blue); }
146 pre code { background: none; padding: 0; color: inherit; }
147 ul, ol { padding-left: 20px; margin: 0 0 12px; } li { margin-bottom: 5px; line-height: 1.55; }
148 li::marker { color: var(--red); } ol li::marker { color: var(--ink); }
149 a { color: var(--blue); text-underline-offset: 2px; } a:hover { color: var(--red); }
150 strong { font-weight: 600; }
151</style>
152</head>
153<body>
154<div class="card">
155 <div class="header"><h1>↕ Session Context</h1><div class="meta">${meta}</div></div>
156 ${history}
157 <div id="brief"></div>
158</div>
159<script id="data" type="application/json">${data}</script>
160<script>
161 var parts = JSON.parse(document.getElementById('data').textContent), out = document.getElementById('brief');
162 parts.forEach(function (md) {
163 var div = document.createElement('div');
164 div.className = 'section';
165 if (window.marked && window.DOMPurify) div.innerHTML = DOMPurify.sanitize(marked.parse(md));
166 else { var pre = document.createElement('pre'); pre.textContent = md; div.appendChild(pre); }
167 out.appendChild(div);
168 });
169</script>
170</body>
171</html>
172`
173}
174hooks/server.ts 33 lines1// The static server for the viewer pages, run as `node -e SERVER_JS <dir> <host> <port>`. It
2// serves only <name>.html files from <dir>, and /<first 8 characters of a session id> as that
3// session's page, so the link fits one line on a phone. register.tsx starts it (the loader keeps $ in that file).
4
5export const SERVER_JS = `
6const http = require('http'), fs = require('fs'), path = require('path')
7const [dir, host, port] = process.argv.slice(1)
8http.createServer((req, res) => {
9 let name = decodeURIComponent(new URL(req.url, 'http://x').pathname.slice(1))
10 if (req.method === 'GET' && /^[0-9a-f]{8}$/.test(name)) {
11 let files = []
12 try { files = fs.readdirSync(dir) } catch {}
13 name = files.filter(f => f.startsWith(name + '-') && f.endsWith('.html')).sort()[0] || ''
14 }
15 if (req.method !== 'GET' || !/^[\\w-]+\\.html$/.test(name)) { res.writeHead(404); return res.end('Not found') }
16 fs.readFile(path.join(dir, name), (err, data) => {
17 if (err) { res.writeHead(404); return res.end('Not found') }
18 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' })
19 res.end(data)
20 })
21}).on('error', (err) => {
22 // Another session already serves the folder on this port: nothing to do, and no stack trace.
23 if (err.code === 'EADDRINUSE') { console.log('viewer port ' + port + ' already served'); process.exit(0) }
24 throw err
25}).listen(Number(port), host, () => console.log('viewer serving http://' + host + ':' + port))
26`
27
28/** "host:port", or undefined when the value is not one. The host "tailscale" is resolved by the caller. */
29export function parseAddress(v: string): { host: string; port: string } | undefined {
30 const m = /^([\w.-]+):(\d{2,5})$/.exec(v.trim())
31 return m ? { host: m[1]!, port: m[2]! } : undefined
32}
33hooks/panel.tsx 46 lines1// The band above the prompt is the mod's whole UI: a header line and the steps under it, with
2// a braille spinner on whatever is still running. A toast cannot animate or carry a link, so the
3// panel replaced them. This file draws; register.tsx holds the panel and its timers, since the
4// host never follows $ across an import.
5
6export type Mark = 'spin' | 'done' | 'warn' | 'fail'
7export type Line = { mark: Mark; text: string }
8// sticky: stays until dismissed (a failure, the pause, a warning); otherwise a panel with nothing
9// spinning collapses on its own.
10export type Panel = { header: Line; steps: Line[]; link?: string; sticky?: boolean }
11
12export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
13const GLYPH: Record<Exclude<Mark, 'spin'>, string> = { done: '✓', warn: '⚠', fail: '✗' }
14const COLOR: Record<Mark, string> = { spin: 'yellow', done: 'green', warn: 'yellow', fail: 'red' }
15
16export const isSpinning = (p: Panel) => p.header.mark === 'spin' || p.steps.some((l) => l.mark === 'spin')
17
18// The plain text of the panel, one line per row, for logs and tests.
19export const panelText = (p: Panel, frame = 0) =>
20 [p.header, ...p.steps].map((l, i) => `${i ? ' ' : ''}${glyph(l.mark, frame)} ${l.text}`).join('\n')
21
22const glyph = (m: Mark, frame: number) => m === 'spin' ? SPINNER[frame % SPINNER.length] : GLYPH[m]
23
24// The elements come from $.ui.resolve in the render hook.
25type Elements = { Box: any; Text: any; Markdown: any; Button: any }
26
27export function panelTree({ Box, Text, Markdown, Button }: Elements, p: Panel, frame: number, onDismiss: () => void) {
28 return (
29 <Box flexDirection="column">
30 <Box>
31 <Text color={COLOR[p.header.mark]}>{glyph(p.header.mark, frame)} </Text>
32 <Text bold>{p.header.text}</Text>
33 </Box>
34 {p.steps.map((l, i) => (
35 <Box key={`step-${i}`}>
36 <Text> </Text>
37 <Text color={COLOR[l.mark]}>{glyph(l.mark, frame)} </Text>
38 <Text dimColor={l.mark === 'done'}>{l.text}</Text>
39 </Box>
40 ))}
41 {p.link ? <Markdown text={` [open brief](${p.link})`} /> : null}
42 {p.sticky ? <Button key="dismiss" label="Dismiss" onPress={onDismiss} /> : null}
43 </Box>
44 )
45}
46hooks/config.ts 64 lines1// Settings from /config and the constants built on them. Engine-free: the host follows $ only
2// into functions declared in register.tsx, so anything taking $ stays there.
3
4export const BRIEF_DIR = '.claude/state/auto-handoff'
5// The two numbers are userConfig fields (plugin.json), set in /config. Defaults match the manifest.
6// threshold: matches HANDOFF_ARM_TOKENS in context-warning.ts.
7// MIN_HEADROOM and maxUnattended are loop guards. A seeded session must grow MIN_HEADROOM past its
8// first-turn size (its floor) before it can hand off again, and at most maxUnattended handoffs may
9// run before the user types a prompt. MIN_HEADROOM is fixed: seeded sessions start near 45k, so at
10// the default threshold it never moves the line; it only matters when the threshold is set low.
11// 40k is the empirical line where a seeded session can read its brief and still do real work. The
12// 2026-10-03 live run (threshold 20k, seeded sessions start at ~31k) chained six times with no
13// guard; the 2026-10-04 run (AUTO_HANDOFF_TOKENS=80000 left in a shell, floor ~45k) chained eight
14// times with a guard of a quarter of the threshold, because max(80k, 45k + 20k) is still 80k, and
15// 35k of room goes in reading the brief. Progress, not time: a 15-minute chain cap could block a
16// real session that fills fast.
17// The rest shape the brief: two template files and a pattern for files
18// that never count as edits.
19// viewer: where the mod serves the brief pages, "host:port"; "tailscale" as the host means this
20// machine's Tailscale IP, or 127.0.0.1 without Tailscale. Blank: no server, and the link is the local file.
21export type Config = { threshold: number; maxUnattended: number; briefTemplate: string; instructionsTemplate: string; ignoreFiles?: RegExp; viewer: string }
22export const DEFAULTS: Config = { threshold: 160_000, maxUnattended: 2, briefTemplate: '~/.claude/auto-handoff/brief.md', instructionsTemplate: '~/.claude/auto-handoff/instructions.md', viewer: 'tailscale:3846' }
23export const MIN_HEADROOM = 40_000
24// Each template's default, a file in the mod's templates/ folder.
25export const TEMPLATES = [['briefTemplate', 'brief.md'], ['instructionsTemplate', 'instructions.md']] as const
26export type TemplateKey = typeof TEMPLATES[number][0]
27// Used only when the shipped default is unreadable too, so the fresh session still knows what to do.
28export const LAST_RESORT_INSTRUCTIONS = '## Instructions\n\nThis turn was triggered by the system, not by a user. Read this brief and continue the work it describes.'
29
30export const k = (n: number) => `${Math.round(n / 1000)}k`
31export const short = (sessionId: string) => sessionId.slice(0, 8)
32export const expand = (path: string, home: string) => path.replace(/^~(?=\/|$)/, home)
33
34// A non-positive or non-numeric value falls back to the default rather than handing off at 0.
35const num = (v: unknown, fallback: number) => typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback
36const str = (v: unknown, fallback: string) => typeof v === 'string' && v.trim() ? v.trim() : fallback
37// A bad pattern is dropped, not fatal: a typo in /config should not stop handoffs.
38function pattern(v: unknown): RegExp | undefined {
39 if (typeof v !== 'string' || !v.trim()) return undefined
40 try { return new RegExp(v) } catch { return undefined }
41}
42
43/** The /config values, each checked, with the manifest's defaults for anything missing or bad. */
44export function parseConfig(options: Record<string, unknown>): Config {
45 return {
46 threshold: num(options.threshold, DEFAULTS.threshold),
47 maxUnattended: num(options.maxConsecutiveHandoffs, DEFAULTS.maxUnattended),
48 briefTemplate: str(options.briefTemplate, DEFAULTS.briefTemplate),
49 instructionsTemplate: str(options.instructionsTemplate, DEFAULTS.instructionsTemplate),
50 ignoreFiles: pattern(options.ignoreFiles),
51 viewer: typeof options.viewer === 'string' ? options.viewer.trim() : DEFAULTS.viewer,
52 }
53}
54
55// The seed prompt's first characters; the render hook knows the seed row by them.
56export const SEED_PREFIX = '[auto-handoff] ↪ Handoff from session'
57// The seed text with its brief path and viewer URL as markdown links. The path becomes a
58// file: link labelled by its file name; the URL links to itself. Exported for the test.
59export function linkify(text: string): string {
60 return text
61 .replace(/(?<=\bat )(\/\S+\.md)(?=[\s)]|$)/, (p) => `[${p.slice(p.lastIndexOf('/') + 1)}](file://${p})`)
62 .replace(/(https?:\/\/[^\s)]+)/, (u) => `[${u}](${u})`)
63}
64hooks/templates.ts 18 lines1// Helpers for the two templates that shape a handoff. The templates themselves are markdown
2// files: the defaults ship in the mod's templates/ folder, and on a session's start the mod
3// copies each one to its configured path if no file is there yet. Every handoff reads the
4// copy, so editing it changes the brief. Delete it to get the current default back.
5
6/** Fills {{#name}}...{{/name}} (shown when set) and {{^name}}...{{/name}} (shown when not). */
7export function renderTemplate(template: string, flags: Record<string, boolean>): string {
8 return template
9 .replace(/\{\{([#^])(\w+)\}\}([\s\S]*?)\{\{\/\2\}\}/g, (_m, kind: string, name: string, body: string) => (kind === '#') === Boolean(flags[name]) ? body : '')
10 .replace(/\n{3,}/g, '\n\n')
11 .trim()
12}
13
14/** The "## " headings a template asks for, as Haiku should write them. */
15export function sectionHeadings(template: string): string[] {
16 return [...template.matchAll(/^## (.+)$/gm)].map(m => `## ${m[1]!.trim()}`)
17}
18