@sayansr26/agent-os 0.6.1 → 0.6.3
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.
- package/.claude-plugin/marketplace.json +13 -1
- package/CHANGELOG.md +44 -0
- package/README.md +36 -2
- package/cursor/hooks/git-guard.mjs +40 -0
- package/cursor/skills/agent-os-init/SKILL.md +116 -0
- package/package.json +4 -4
- package/plugins/agent-os/.claude-plugin/plugin.json +3 -3
- package/plugins/agent-os/hooks/pre-edit.mjs +36 -7
- package/plugins/agent-os/hooks/session-resume.mjs +29 -15
- package/plugins/auto-handoff/.claude-plugin/plugin.json +49 -0
- package/plugins/auto-handoff/LICENSE +21 -0
- package/plugins/auto-handoff/README.md +131 -0
- package/plugins/auto-handoff/UPSTREAM.md +22 -0
- package/plugins/auto-handoff/hooks/brief.ts +190 -0
- package/plugins/auto-handoff/hooks/config.ts +64 -0
- package/plugins/auto-handoff/hooks/hooks.json +1 -0
- package/plugins/auto-handoff/hooks/panel.tsx +45 -0
- package/plugins/auto-handoff/hooks/register.tsx +615 -0
- package/plugins/auto-handoff/hooks/server.ts +32 -0
- package/plugins/auto-handoff/hooks/templates.ts +17 -0
- package/plugins/auto-handoff/hooks/viewer.ts +173 -0
- package/plugins/auto-handoff/statusline/handoff-link.sh +15 -0
- package/plugins/auto-handoff/templates/brief.md +34 -0
- package/plugins/auto-handoff/templates/instructions.md +10 -0
- package/plugins/auto-handoff/tests/auto-handoff.test.tsx +931 -0
- package/plugins/auto-handoff/tests/seed-row.test.tsx +58 -0
- package/plugins/auto-handoff/tests/viewer.test.ts +111 -0
- package/plugins/auto-handoff/tsconfig.json +4 -0
- package/src/cli.mjs +57 -12
- package/src/cursor.mjs +167 -0
- package/src/plugin.mjs +5 -4
- package/src/selftest.mjs +65 -1
- package/src/targets.mjs +18 -11
|
@@ -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
|
+
}
|