SLOPSHOPPER

auto-handoff

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.

newbandrowsguardtoastprompt
★ 58v0.8.6MITupdated 2026-10-04alexknowshtml/claude-auto-handoff
A shopper browsing a rack in a slop shop
README

claude-auto-handoff

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.

auto-handoff in a live session: the tool gate stops a read at the threshold, the panel walks through the brief and /clear, and the fresh session picks the work back up

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)

Why not auto-compact?

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.

What happens

  1. Threshold. The mod checks the context size after each turn and before each model request, including tool output that hasn't been measured yet. Once it's past the threshold, the mod refuses new tool calls, so one burst of reads can't overflow the window. Unmeasured tool output is an estimate that can run high, so the real size sometimes comes in under the threshold. A refused call still ends in a handoff, at the next request or the end of the turn.
  2. Brief. Haiku writes the brief from the transcript. If Haiku fails, a facts-only brief stands in. Briefs go to ~/.claude/state/auto-handoff/<session-id>.md.
  3. Clear and seed. The mod runs /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.
  4. A panel above the prompt. It shows each step with a braille spinner on the one still running: writing the brief, clearing, starting the fresh session. Once the new session is measured it reads ✓ 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.
  5. Viewer. Each brief also gets a readable page in ~/.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.
  6. Status line link (optional). 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.

Install

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" } }

Configure

Every setting is a row in /config under auto-handoff. They're stored in ~/.claude/settings.json under pluginConfigs.

SettingDefaultWhat it does
threshold160000Context 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
maxConsecutiveHandoffs2Handoffs allowed before you type a prompt; past this, the mod pauses until you do
briefTemplate~/.claude/auto-handoff/brief.mdYour copy of the sections Haiku writes
instructionsTemplate~/.claude/auto-handoff/instructions.mdYour copy of what the fresh session is told to do
ignoreFilesblankRegex for edited files to leave out of the brief, such as caches or synced state
viewertailscale:3846Where 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.

Change the brief's structure and rules

The brief is shaped by two markdown files. The defaults live in this repo's templates/ folder:

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.

Editing brief.md

Add, 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.

Editing instructions.md

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

Logs

Everything the mod does is logged to ~/.claude/state/auto-handoff/auto-handoff.log.

Develop

claude plugin validate .
claude plugin test .

The mod hot-reloads when you save while it's loaded with --plugin-dir.

Changelog

  • 0.8.6 On a machine without 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.
  • 0.8.5 Windows support, from @davidboomcycle (#3). The mod falls back to 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.
  • 0.8.4 Any token figure in Haiku's brief that isn't in Handoff Numbers is marked [unverified: not in Handoff Numbers] and logged. The figure is marked, not removed.
  • 0.8.3 The brief gets the real numbers: tokens at handoff, the threshold and where it came from, the session's starting size, and how many handoffs ran with no message from you. Haiku must copy them or write "unknown", so a brief can no longer invent a figure like "burned its 200k budget".
  • 0.8.2 A refused tool call always ends in a handoff, even when the real size measures under the threshold. Your own /clear closes the panel. A second session that finds the viewer port taken exits quietly instead of logging a stack trace.
  • 0.8.1 On the mobile app and in VS Code, which don't draw the panel, the threshold, the result and anything that stays up also arrive as toasts.
  • 0.8.0 A panel above the prompt replaces the toasts, with a spinner on each step and an open brief link.
  • 0.7.0 A seeded session hands off no sooner than 40k past its starting size. When the threshold is set tighter than that, the panel says so and names the setting. The viewer serves on 127.0.0.1 when Tailscale isn't available.
  • 0.6.0 A viewer page for each brief, served on your Tailscale IP with short links. The pages in a chain link to each other. The seed row's links are clickable, and the status line script adds a handoff link.
  • 0.5.0 First release: a Haiku brief, /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.

License

MIT

Source 7 files
hooks/register.tsx 616 lines
1import 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}
616
hooks/brief.ts 191 lines
1import 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}
191
hooks/viewer.ts 174 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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}
174
hooks/server.ts 33 lines
1// 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}
33
hooks/panel.tsx 46 lines
1// 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}
46
hooks/config.ts 64 lines
1// 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}
64
hooks/templates.ts 18 lines
1// 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