@sayansr26/agent-os 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,615 @@
1
+ import type { EngineInterface, Register, Timer } from 'claude-code'
2
+ import { assembleBrief, briefPrompt, extractFacts, isValidBrief, markUnverifiedFigures } from './brief.ts'
3
+ import { chainOf, parseBrief, renderPage, viewerLink, withHeader } from './viewer.ts'
4
+ import type { Entry } from './viewer.ts'
5
+ import { SERVER_JS, parseAddress } from './server.ts'
6
+ import { isSpinning, panelTree } from './panel.tsx'
7
+ import type { Line, Panel } from './panel.tsx'
8
+ import { BRIEF_DIR, DEFAULTS, LAST_RESORT_INSTRUCTIONS, MIN_HEADROOM, SEED_PREFIX, TEMPLATES, expand, k, linkify, parseConfig, short } from './config.ts'
9
+ import 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
+
15
+ let cfg: Config = DEFAULTS
16
+ // Origins the engine stamps on a prompt the person sent; the seed arrives as { kind: 'plugin' }.
17
+ const USER_ORIGINS = new Set(['composer', 'bridge'])
18
+ // Rough chars-per-token for tool output, used to project the next request's size.
19
+ const 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.
22
+ const LOG = `~/${BRIEF_DIR}/auto-handoff.log`
23
+
24
+ // problem: why the brief is facts only, when Haiku's summary was unusable.
25
+ type Pending = { oldSession: string; briefPath: string; tokens: number; chain: string; link: string; problem?: string }
26
+
27
+
28
+
29
+ // Module variables survive /clear; $.state does not.
30
+ let pending: Pending | undefined
31
+ let inFlight = false
32
+ let seededSession: string | undefined
33
+ let floor: number | undefined
34
+ let unattended = 0 // handoffs since the user last typed a prompt
35
+ let pausedSession: string | undefined
36
+ // The handoff the fresh session came from, until its first request is measured and shown on the panel.
37
+ let 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.
41
+ type Lineage = { from: string; chain: string; depth?: number }
42
+ let 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.
46
+ let 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.
51
+ let gated: string | undefined
52
+ // From the latest SessionStart; /clear starts a new transcript file.
53
+ let 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.
58
+ async function homeDir($: EngineInterface): Promise<string | undefined> {
59
+ return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
60
+ }
61
+
62
+ async 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
+
82
+ const 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
+ }
89
+ const 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 ''.
92
+ async 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.
98
+ async 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.
111
+ async 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
+
124
+ let 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.
130
+ async 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.
146
+ async 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. */
155
+ async 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.
175
+ async 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
+
188
+ async 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
+
197
+ async 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.
260
+ async 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.
300
+ async 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.
309
+ async 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.
317
+ const FRAME_MS = 100 // the host redraws the band ten times a second at most
318
+ const DONE_MS = 10_000
319
+ let shown: Panel | undefined
320
+ let frame = 0
321
+ let spin: Timer | undefined
322
+ let 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.
327
+ const BAND_SURFACES = new Set(['terminal', 'desktop'])
328
+ async 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
+
336
+ function 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
+
354
+ function 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.
363
+ function 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.
368
+ function 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.
374
+ const briefStep = (problem?: string): Line => problem
375
+ ? { mark: 'warn', text: `brief is facts only: the summary failed (${problem})` }
376
+ : { mark: 'done', text: 'brief written' }
377
+
378
+ function 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.
385
+ function 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.
395
+ async 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.
401
+ async 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`.
409
+ let warnedSession: string | undefined
410
+ async 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
+
422
+ export 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
+ }
@@ -0,0 +1,32 @@
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
+
5
+ export const SERVER_JS = `
6
+ const http = require('http'), fs = require('fs'), path = require('path')
7
+ const [dir, host, port] = process.argv.slice(1)
8
+ http.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. */
29
+ export 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
+ }
@@ -0,0 +1,17 @@
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). */
7
+ export 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. */
15
+ export function sectionHeadings(template: string): string[] {
16
+ return [...template.matchAll(/^## (.+)$/gm)].map(m => `## ${m[1]!.trim()}`)
17
+ }