Sandbox for Claude Code mods experiments (not published)

Sandbox plugin for Claude Code mods (function hooks) experiments. Not published: absent from .claude-plugin/marketplace.json.
⚠️ Mods are not sandboxed: a hooks module runs with the user's permissions. Read the code before loading it.
claude --plugin-dir /repos/agent-skills/modtest
claude --plugin-dir /repos/agent-skills/modtest --debug-file /praxis/.tmp/self-relay/debug.log # with debug log
Check and test:
claude plugin validate /repos/agent-skills/modtest --strict
claude plugin test /repos/agent-skills/modtest
Tested with Claude Code 2.1.287.
| mod | file | command |
|---|---|---|
| self-relay | hooks/self-relay.tsx | /self-relay |
One command, three verbs. A stream is a named line of work; the mod keeps its state and its journal on disk, in the session's working directory.
/self-relay save [stream] fork -> relay-<stream>-llm.md; returns at once, the outcome is a toast, no /clear
/self-relay [stream] [--yes|-y] same state build (awaited), then review pane [c]/[x], or --yes arm-only; you type /clear; packet re-injected
/self-relay load <stream> relay-<stream>-llm.md -> first message of this session (state + journal rule + regime)
save and load are reserved words, never stream names. Stream = explicit argument (^[a-zA-Z0-9_-]{1,50}$, binds for the session) > the session title set by /rename (slugified when it is not a valid name) > refused with a message. The binding survives /clear.
Files written, nothing else, both built from the validated stream name:
| file | content | written by |
|---|---|---|
journal/<stream>.md | one entry per <!-- ckpt ... --> trailer of a main-loop answer: ### cNN <ISO> <session-id> + the trailer verbatim; append-only, never rewritten, pruned by hand | code, on every completed turn (subagent and aborted turns skipped) |
relay-<stream>-llm.md | state: 7 header fields + 10 body fields, at most 8,000 chars; lines from trailers end with (cNN); journal_cursor = last id integrated | code, from the fork's routing + the trailer clauses copied verbatim |
Trailers captured before a stream is bound wait in $.store (self-relay:buffer:<session-id>) and are flushed to the journal when a stream gets bound. A journal write that fails twice (size changed between read and write) keeps the entries in that buffer and toasts.
Routing of trailer clauses into the state: decision, constraint -> decisions; learning, definition -> learnings; rejected -> discarded; open -> next; assumption -> unknowns; refs -> read_if_needed; reasoning, pivot stay in the journal. The fork only designates (ids answered or reversed) and writes the fields no trailer covers. Over 8,000 chars the code cuts read_if_needed, then stale, then in_progress; if it still does not fit, the save is blocked and nothing is written.
On screen the <!-- ckpt ... --> trailer is hidden from assistant replies (a ui.render hook on AssistantMessage, drawing only). A reply with a closed trailer is drawn as a tree: the reply text without the trailer, then a marker ▸ ckpt: <clauses>. Click the marker to expand it in place (▾ ckpt: <clauses> and the full trailer, dim); click again to collapse. Each reply toggles on its own; the state is lost when the mod reloads (all collapsed again). Clicks are reported by the terminal in fullscreen (alternate-screen) mode only: on the main screen the collapsed marker is drawn but cannot be pressed. A trailer still streaming (unclosed) is cut from the drawing without marker. A reply text over 10,000 chars falls back to a plain _ckpt: <clauses>_ line (no click). The stored message is untouched: the journal capture (from the stored answer on turn.complete) keeps the trailer verbatim.
Journal rule, appended to every relay and load packet: never read journal/<stream>.md whole; to see an entry, grep -A8 '^### cNN' journal/<stream>.md.
--yes skips the pane, for sessions driven over Remote Control. The packet is armed and you type /clear yourself.
The regime lines are a copy of [[/repos/agent-skills/team/skills/relay/SKILL.md]] §3 (md5 615b560cfe2e88c19763e668bf89570a at copy time), with the orchestrator line replaced by a solo line and a read_first line added. The copy may drift from its source until the mod is promoted to team.
A session.measure hook (observe only, always next(e)) watches the context fill after each main-thread turn and advises a fresh start. Never clears, never arms: --yes stays the human gate.
tokens / window (e.context.window, 200k or 1M): every floor and every percentage is relative to the model's context window, the same figure as the status line. The percentage shown is e.context.percent when present, else round(tokens / window * 100). No tokens (fresh session, just compacted) or context not in changed -> nothing.autoCompactThreshold of $.session.usage({ breakdown: 'summary' }), else rawMaxTokens; it only feeds the hard cap. The usage call is made once tokens / window >= 0.35, then the wall is cached until /clear or a new session. Unavailable -> the hard zone uses HARD * window alone.pivot: clause, or a clause naming a task id T<n> with done / closed / closes / a check mark (T19 done, T21 ✅, closes T4); simple = a decision: clause.STRONG (0.30), or simple seam and fill >= SOFT (0.50). One toast per cycle, plus a persistent line under the prompt ($.ui.status, the plugin's own line, not the statusLine script). A stronger seam later upgrades the line without a second toast; the percentage on the line follows the fill.HARD = 0.70): tokens >= min(HARD * window, 0.95 * wall) -> the same save as /self-relay save, once, not awaited, skipped while a save or relay is running. The cap makes sure the pre-save happens before auto-compaction (the fork is refused at the wall): window 1M and wall 400k give a hard start at 380k, not 700k. After a hard save the zones re-arm when the fill falls under the lower of SOFT and 90% of the hard start. Outcome = toast + persistent line (saved, save failed with a short reason, or no stream set so nothing was saved)./clear or any new session, when a relay is armed (--yes or clear & relay), after a manual save, and when the fill drops below its floor (STRONG, SOFT, or after the hard zone the lower of SOFT and 90% of the hard start). A cancelled or blocked relay does not clear it.Good moment for a fresh start: T19 just closed (context 41%). Type /self-relay --yes (also the direction just changed, a decision just landed); hard: Context almost full (87%): automatically saving your progress..., Progress saved automatically (context 87%). Type /self-relay --yes to continue in a fresh session, Context almost full (87%) and the automatic save failed (<reason>). Type /self-relay --yes to continue in a fresh session, Context almost full (87%) but no stream is set, so nothing was saved. Type /self-relay save <name>.claude --debug, nothing on screen), silent ones included: self-relay: ctx pct=52% tokens=104000 window=200000 wall=160000 hard=140000 seam=strong:T19 action=advice src=measure. pct is the window-relative figure shown in the messages; hard is the token count where the hard zone starts; wall=none = no usage call yet (fill under 35%) or usage unavailable. Use these lines to calibrate STRONG, SOFT and HARD live.Plan and findings: [[/praxis/repos/agent-skills/plan-self-relay-mod-llm.md]].
hooks/register.ts 8 lines1import type { Register } from 'claude-code'
2
3import { registerSelfRelay } from './self-relay.tsx'
4
5export const register: Register = on => {
6 registerSelfRelay(on)
7}
8hooks/self-relay.tsx 1122 lines1import type { EngineInterface, On } from 'claude-code'
2
3// self-relay v2: one mod for context.
4// /self-relay save [stream] fork -> relay-<stream>-llm.md (not awaited, no clear)
5// /self-relay [stream] [--yes|-y] save (awaited) + review pane / arm + /clear + re-inject
6// /self-relay load <stream> state file -> first message of this session
7// Journal: every `<!-- ckpt ... -->` trailer of a main-loop answer is appended verbatim to journal/<stream>.md by code.
8// The fork DESIGNATES (routing ids + fork-owned fields); code COPIES trailer text and assembles the state file.
9// Plan: /praxis/repos/agent-skills/plan-self-relay-mod-llm.md
10
11const PANE = 'self-relay'
12const STORE_KEY = 'self-relay:pending'
13const STALE_MS = 10 * 60 * 1000
14const MARKDOWN_MAX = 10_000
15// Hard cap of the state file, enforced by code (the fork prompt only asks for it).
16const STATE_MAX = 8_000
17const STREAM_RE = /^[a-zA-Z0-9_-]{1,50}$/
18const RESERVED = ['save', 'load']
19const SHRINK_LINE = 80
20// Context-fill floors (T19, T21, T22), as a share of the model's context window (e.context.window), the same figure as the
21// status line. To calibrate live from the `self-relay: ctx` log lines.
22// STRONG: floor for a strong seam (a task closed, a pivot). SOFT: floor for a simple seam (a decision). HARD: auto pre-save.
23export const STRONG = 0.3
24export const SOFT = 0.5
25export const HARD = 0.7
26// Safety cap on HARD: the pre-save must happen before auto-compaction (the fork is refused at the wall), so the hard zone
27// starts at min(HARD * window, WALL_CAP * wall).
28export const WALL_CAP = 0.95
29// usage() (the wall) is only asked once tokens / window reaches this, then cached. Under it a strong seam at STRONG needs no call, and a
30// wall of at least 0.35 / WALL_CAP = 37% of the window is still seen before the cap bites.
31const USAGE_FROM = 0.35
32
33// Regime lines 1-4: /repos/agent-skills/team/skills/relay/SKILL.md §3 (md5 615b560cfe2e88c19763e668bf89570a at copy time).
34// Line 5 (orchestrator, team-only) replaced by a solo line; line 6 added for v2 (read_first after the go).
35const REGIME = `You are taking over this name. Announce [READY] on a single line and stop there.
36Do nothing without my explicit go — no file read, no command, no sub-agent, no continuation of what is in progress.
37Do not read the packet back to me. One line of acknowledgement is the whole answer.
38You emit a message only on a state transition. The rest of the time you are silent.
39Questions go to me, in this window.
40After my go, read the \`read_first\` files before acting.`
41
42const FORK_PROMPT = `You are writing the state of this work stream for yourself. After a /clear the state file is the ONLY thing the fresh context receives. Code assembles the file: you DESIGNATE, you never copy. Lines coming from ckpt trailers are routed and copied verbatim by code.
43First line, exactly one of:
44GATE: ok
45GATE: blocked - <one-line reason>
46Blocked = you are in the middle of a cross-item step (synthesis, deduplication, global arbitration across all units). Then output nothing else.
47If ok, from line 2 output ONLY the block below, keys in this order, no markdown, no prose, no blank line. Scalar = one line \`key: value\`. List = \`key:\` then one \`- item\` line per item; a missing list = empty.
48status: <one word or short phrase: building | blocked | review ...>
49goal: <1 sentence, carried from PREVIOUS STATE unless it changed>
50read_first:
51- <path — role> (closed list, read only after the human's go, never at [READY])
52read_if_needed:
53- <path or URL — when to read it> (extras only)
54deliverable: <path where the written work lives>
55decisions:
56- <choice — why> (ONLY decisions no trailer covers; the why is mandatory; do not reopen)
57learnings:
58- <non-obvious fact — command or path that proves it> (extras only)
59discarded:
60- <path tried — why rejected> (extras only)
61in_progress:
62- <half-done item — where it stands>
63next:
64- <action> (2-4 actions, extras only)
65unknowns:
66- <what is not known> (never empty)
67stale:
68- <path — replaced by X; do not reload>
69retire_answered: <ids, e.g. c03, c07>
70retire_reversed: <ids, e.g. c05>
71Rules:
72- Every list except read_if_needed, learnings, discarded, next and decisions is yours entirely, and for those five you give EXTRAS only: whatever the trailers below do not already say.
73- Return the COMPLETE current content of every list you own: carry forward the still-valid lines of PREVIOUS STATE that have no (cNN) suffix.
74- A line ending with (cNN) belongs to code: never output one.
75- retire_answered: ids of open: / assumption: items (in PREVIOUS STATE or in the entries below) that are now resolved. retire_reversed: ids of decisions that were reversed. Omit a retire key when it is empty.
76- A pointer (absolute path, path:line, command) replaces any explanation. Do not run commands; write them.
77- Total ≤ 8,000 characters.`
78
79type Phase = 'idle' | 'review' | 'armed'
80type Pending = { packet: string; createdAt: number; cwd: string }
81type Stored = Pending & { phase: Phase }
82
83// Module scope: survives /clear (module not reloaded), lost on mod reload -> $.store backup.
84let phase: Phase = 'idle'
85let pending: Pending | null = null
86// A save or a relay build is running (the fork is slow): refuse a second command meanwhile.
87let saving = false
88// Stream binding. Same session across /clear, so it survives /clear; reset on startup/resume/fork.
89let boundArg: string | null = null
90let titleStream: string | null = null
91// Journal appends of this process, one at a time.
92let journalQueue: Promise<unknown> = Promise.resolve()
93// T19 context zones. Module scope like the rest: survives /clear (reset by hand there), lost on mod reload.
94let wallCache: number | null = null
95let lastM: Measure | null = null
96let seam: Seam = { level: 'none' }
97// softDone: the advice toast was shown in this cycle. hardDone: the hard zone acted in this cycle.
98let softDone = false
99let hardDone = false
100// The persistent advice line (T21) and the line of the active save/relay flow: one `$.ui.status` per plugin, see paint().
101let advice: Advice | null = null
102let flowLine: string | undefined
103// Bumped at every re-arm: a hard save finishing in an older cycle must not set a stale advice.
104let cycle = 0
105
106// ---------- pure helpers ----------
107
108export const pad = (n: number) => String(n).padStart(2, '0')
109export const statePath = (stream: string) => `relay-${stream}-llm.md`
110export const journalPath = (stream: string) => `journal/${stream}.md`
111const bufferKey = (sid: string) => `self-relay:buffer:${sid}`
112
113export function journalRule(stream: string): string {
114 return `Journal ${journalPath(stream)}: never read it whole; to see an entry, grep by id: grep -A8 '^### cNN' ${journalPath(stream)}`
115}
116
117// A /rename title that is not a valid stream name becomes one; empty = no stream.
118export function slugify(title: string): string | null {
119 if (STREAM_RE.test(title)) return title
120 const slug = title
121 .toLowerCase()
122 .replace(/[^a-z0-9_-]+/g, '-')
123 .replace(/^-+|-+$/g, '')
124 .slice(0, 50)
125 .replace(/-+$/, '')
126 return slug === '' ? null : slug
127}
128
129export type Verb = 'save' | 'load' | 'relay'
130export type ParsedArgs = { verb: Verb; stream?: string; yes: boolean } | { error: string }
131
132export function parseArgs(args: string): ParsedArgs {
133 const tokens = args.split(/\s+/).filter(t => t !== '')
134 const yes = tokens.some(t => t === '--yes' || t === '-y')
135 const rest = tokens.filter(t => t !== '--yes' && t !== '-y')
136 const bad = rest.find(t => t.startsWith('-'))
137 if (bad) return { error: `unknown option ${bad}. Usage: /self-relay save [stream] | load <stream> | [stream] [--yes]` }
138 let verb: Verb = 'relay'
139 if (rest[0] === 'save' || rest[0] === 'load') verb = rest.shift() as Verb
140 if (yes && verb !== 'relay') return { error: `--yes only goes with the relay form (/self-relay [stream] --yes)` }
141 if (rest.length > 1) return { error: `too many arguments. Usage: /self-relay save [stream] | load <stream> | [stream] [--yes]` }
142 const stream = rest[0]
143 if (stream !== undefined) {
144 if (RESERVED.includes(stream)) return { error: `"${stream}" is a reserved word, not a stream name` }
145 if (!STREAM_RE.test(stream)) return { error: `invalid stream "${stream}": use 1-50 chars of a-z A-Z 0-9 _ -` }
146 }
147 return { verb, stream, yes }
148}
149
150export type Clause = 'decision' | 'reasoning' | 'learning' | 'pivot' | 'rejected' | 'constraint' | 'assumption' | 'open' | 'definition' | 'refs'
151export type Section = 'read_first' | 'read_if_needed' | 'decisions' | 'learnings' | 'discarded' | 'in_progress' | 'next' | 'unknowns' | 'stale'
152
153const CLAUSES: Clause[] = ['decision', 'reasoning', 'learning', 'pivot', 'rejected', 'constraint', 'assumption', 'open', 'definition', 'refs']
154// null = journal only (addressable by id, never in the state).
155export const ROUTE: Record<Clause, Section | null> = {
156 decision: 'decisions',
157 constraint: 'decisions',
158 learning: 'learnings',
159 definition: 'learnings',
160 rejected: 'discarded',
161 open: 'next',
162 assumption: 'unknowns',
163 refs: 'read_if_needed',
164 reasoning: null,
165 pivot: null,
166}
167
168// Splits a trailer body on its clause keywords; separators (` · `, `|`, `;`, newlines) are stripped.
169export function parseTrailer(trailer: string): { type: Clause; text: string }[] {
170 const body = trailer.replace(/^\s*<!--\s*ckpt/, '').replace(/-->\s*$/, '')
171 const re = new RegExp(`(?:^|[·|;\\n])\\s*(${CLAUSES.join('|')}):\\s*`, 'g')
172 const hits = [...body.matchAll(re)]
173 const out: { type: Clause; text: string }[] = []
174 hits.forEach((hit, i) => {
175 const start = (hit.index ?? 0) + hit[0].length
176 const end = i + 1 < hits.length ? (hits[i + 1].index ?? body.length) : body.length
177 const text = body.slice(start, end).replace(/[\s·|;]+$/, '').replace(/\s+/g, ' ').trim()
178 if (text) out.push({ type: hit[1] as Clause, text })
179 })
180 return out
181}
182
183export type Entry = { n: number; id: string; trailer: string }
184
185export function parseJournal(text: string): Entry[] {
186 const heads = [...text.matchAll(/^### c(\d+) .*$/gm)]
187 return heads.map((h, i) => {
188 const from = (h.index ?? 0) + h[0].length
189 const to = i + 1 < heads.length ? (heads[i + 1].index ?? text.length) : text.length
190 return { n: Number(h[1]), id: `c${h[1]}`, trailer: text.slice(from, to).trim() }
191 })
192}
193
194const maxId = (text: string) => parseJournal(text).reduce((m, e) => Math.max(m, e.n), 0)
195
196const ID_END = /\s\(c(\d+)\)\s*$/
197const idOf = (line: string): number | null => {
198 const m = line.match(ID_END)
199 return m ? Number(m[1]) : null
200}
201const stripId = (line: string) => line.replace(ID_END, '')
202
203const SCALARS_HEADER = ['stream', 'saved', 'status', 'predecessor', 'goal', 'journal', 'journal_cursor']
204const LISTS: Section[] = ['read_first', 'read_if_needed', 'decisions', 'learnings', 'discarded', 'in_progress', 'next', 'unknowns', 'stale']
205// Body order of the state file (deliverable is the only scalar among them).
206const BODY_ORDER = ['read_first', 'read_if_needed', 'deliverable', 'decisions', 'learnings', 'discarded', 'in_progress', 'next', 'unknowns', 'stale']
207
208export type Keyed = { scalars: Record<string, string>; lists: Record<string, string[]> }
209
210// `key: value` scalars and `key:` + `- item` lists; unknown lines are ignored.
211export function parseKeyed(text: string, scalarKeys: string[], listKeys: string[]): Keyed {
212 const out: Keyed = { scalars: {}, lists: {} }
213 let current: string | null = null
214 for (const raw of text.split('\n')) {
215 const line = raw.trimEnd()
216 const key = line.match(/^([a-z_]+):\s*(.*)$/)
217 if (key && scalarKeys.includes(key[1])) {
218 out.scalars[key[1]] = key[2].trim()
219 current = null
220 } else if (key && listKeys.includes(key[1])) {
221 out.lists[key[1]] = out.lists[key[1]] ?? []
222 current = key[1]
223 if (key[2].trim()) out.lists[current].push(key[2].trim())
224 } else if (current && line.startsWith('- ')) {
225 out.lists[current].push(line.slice(2).trim())
226 }
227 }
228 return out
229}
230
231const STATE_SCALARS = [...SCALARS_HEADER, 'deliverable']
232const parseState = (text: string) => parseKeyed(text, STATE_SCALARS, LISTS)
233
234const FORK_SCALARS = ['status', 'goal', 'deliverable', 'retire_answered', 'retire_reversed']
235export type ForkFields = { fields: Keyed; answered: Set<number>; reversed: Set<number> }
236export type ForkParse = { kind: 'ok'; body: string } | { kind: 'blocked'; reason: string }
237
238export function parseFork(text: string): ForkParse {
239 const lines = text.trim().split('\n')
240 const first = (lines[0] ?? '').trim()
241 if (first === 'GATE: ok') {
242 const body = lines.slice(1).join('\n').trim()
243 return body ? { kind: 'ok', body } : { kind: 'blocked', reason: 'empty fork output' }
244 }
245 const blocked = first.match(/^GATE:\s*blocked\s*[-—:]?\s*(.*)$/)
246 if (blocked) return { kind: 'blocked', reason: blocked[1] || 'no reason given' }
247 return { kind: 'blocked', reason: `malformed first line: ${first.slice(0, 80)}` }
248}
249
250export function parseForkBody(body: string): ForkFields | null {
251 const fields = parseKeyed(body, FORK_SCALARS, LISTS)
252 if (Object.keys(fields.scalars).length === 0 && Object.keys(fields.lists).length === 0) return null
253 const ids = (s: string | undefined) => new Set([...(s ?? '').matchAll(/c(\d+)/g)].map(m => Number(m[1])))
254 return { fields, answered: ids(fields.scalars.retire_answered), reversed: ids(fields.scalars.retire_reversed) }
255}
256
257const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
258const uniq = (lines: string[]) => [...new Set(lines)]
259
260export type AssembleInput = {
261 stream: string
262 saved: string
263 sid: string
264 prev: string | null
265 entries: Entry[]
266 fork: ForkFields
267}
268export type Assembled = { kind: 'ok'; state: string; cursor: string; cut: string[] } | { kind: 'blocked'; reason: string }
269
270// The core rule: the fork designated ids and wrote its own fields; every trailer-sourced line is copied by code.
271export function assemble(input: AssembleInput): Assembled {
272 const prev = input.prev ? parseState(input.prev) : null
273 const { fields, answered, reversed } = input.fork
274
275 const lists: Record<string, string[]> = {}
276 for (const sec of LISTS) lists[sec] = []
277
278 // 1. carried id'd lines of the previous state + new trailer clauses, routed by clause type.
279 const carried: Record<string, string[]> = {}
280 for (const sec of LISTS) carried[sec] = (prev?.lists[sec] ?? []).filter(l => idOf(l) !== null)
281 const routed: Record<string, string[]> = {}
282 for (const sec of LISTS) routed[sec] = []
283 for (const entry of input.entries) {
284 for (const clause of parseTrailer(entry.trailer)) {
285 const sec = ROUTE[clause.type]
286 if (sec) routed[sec].push(`${clause.text} (${entry.id})`)
287 }
288 }
289
290 // 2. retire: answered open/assumption items vanish, reversed decisions move to discarded with their id.
291 const movedToDiscarded: string[] = []
292 for (const sec of LISTS) {
293 lists[sec] = uniq([...carried[sec], ...routed[sec]]).filter(line => {
294 const id = idOf(line)
295 if (id === null) return true
296 if ((sec === 'next' || sec === 'unknowns') && answered.has(id)) return false
297 if (sec === 'decisions' && reversed.has(id)) {
298 movedToDiscarded.push(`${stripId(line)} — reversed (c${pad(id)})`)
299 return false
300 }
301 return true
302 })
303 }
304
305 // 3. fork-owned lines (id'd lines are code-owned and dropped).
306 for (const sec of LISTS) {
307 let own = (fields.lists[sec] ?? []).map(oneLine).filter(l => l !== '' && idOf(l) === null)
308 if (sec === 'decisions') own = own.map(l => (l.includes('—') ? l : `${l} — why missing`))
309 if (sec === 'read_first' || sec === 'in_progress' || sec === 'stale') lists[sec] = own
310 else lists[sec] = uniq([...lists[sec], ...own])
311 }
312 lists.discarded = uniq([...lists.discarded, ...movedToDiscarded])
313 if (lists.unknowns.length === 0) lists.unknowns = ['nothing recorded — treat every fact as unverified']
314
315 // 4. header.
316 const allNs = input.entries.map(e => e.n)
317 const prevCursor = Number((prev?.scalars.journal_cursor ?? '').replace(/^c/, '')) || 0
318 const cursor = `c${pad(Math.max(prevCursor, ...allNs))}`
319 const prevSaved = (prev?.scalars.saved ?? '').split(/\s+/)
320 const prevWriter = prevSaved[1]
321 const predecessor = prevWriter && prevWriter !== input.sid ? prevWriter : prev?.scalars.predecessor || 'none'
322 const scalars: Record<string, string> = {
323 stream: input.stream,
324 saved: `${input.saved} ${input.sid}`,
325 status: oneLine(fields.scalars.status || prev?.scalars.status || 'unknown'),
326 predecessor,
327 goal: oneLine(fields.scalars.goal || prev?.scalars.goal || 'unknown'),
328 journal: journalPath(input.stream),
329 journal_cursor: cursor,
330 deliverable: oneLine(fields.scalars.deliverable || prev?.scalars.deliverable || 'none'),
331 }
332
333 const render = () => {
334 const head = SCALARS_HEADER.map(k => `${k}: ${scalars[k]}`).join('\n')
335 const body = BODY_ORDER.map(k => (k === 'deliverable' ? `deliverable: ${scalars.deliverable}` : `${k}:${lists[k].map(l => `\n- ${l}`).join('')}`)).join('\n')
336 return `${head}\n\n${body}\n`
337 }
338
339 // 5. hard cap, cut order: read_if_needed (compress), stale, in_progress. Never decisions, discarded, next, unknowns.
340 const cut: string[] = []
341 let text = render()
342 const over = () => text.length > STATE_MAX
343 for (const sec of ['read_if_needed', 'stale', 'in_progress'] as const) {
344 if (!over()) break
345 if (sec === 'read_if_needed') {
346 lists[sec] = lists[sec].map(l => {
347 if (l.length <= SHRINK_LINE) return l
348 const id = l.match(ID_END)
349 const head = stripId(l).slice(0, SHRINK_LINE - 1) + '…'
350 return id ? `${head} (c${id[1]})` : head
351 })
352 text = render()
353 cut.push('read_if_needed compressed')
354 }
355 let dropped = 0
356 while (over() && lists[sec].length > 0) {
357 lists[sec].shift()
358 dropped++
359 text = render()
360 }
361 if (dropped) cut.push(`${sec} -${dropped}`)
362 }
363 if (over()) return { kind: 'blocked', reason: `state ${text.length} chars > ${STATE_MAX} after cutting ${cut.join(', ') || 'nothing cuttable'}; trim decisions/discarded/next/unknowns or prune the journal by hand. Nothing written.` }
364 return { kind: 'ok', state: text, cursor, cut }
365}
366
367// ---------- journal (all disk access of the mod goes through these three functions) ----------
368
369type Item = { trailer: string; iso: string; sid: string }
370
371// One write = read + size check + write(old + entries). No append in $.fs, so a size change between read and write
372// (another session wrote) re-reads and retries once; still failing -> null and the caller keeps the items in the store.
373async function appendEntries($: EngineInterface, stream: string, items: Item[]): Promise<string[] | null> {
374 const path = journalPath(stream)
375 for (let attempt = 0; attempt < 2; attempt++) {
376 try {
377 const before = await $.fs.stat(path).catch(() => null)
378 const old = before ? await $.fs.read(path) : ''
379 const after = await $.fs.stat(path).catch(() => null)
380 if ((before?.size ?? -1) !== (after?.size ?? -1)) continue
381 let n = maxId(old)
382 const ids: string[] = []
383 let add = ''
384 for (const item of items) {
385 n++
386 const id = `c${pad(n)}`
387 ids.push(id)
388 add += `### ${id} ${item.iso} ${item.sid}\n${item.trailer}\n\n`
389 }
390 await $.fs.write(path, `${old}${old === '' || old.endsWith('\n') ? '' : '\n'}${add}`)
391 return ids
392 } catch {
393 // retry once, then give up
394 }
395 }
396 return null
397}
398
399async function readOptional($: EngineInterface, path: string): Promise<string | null> {
400 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
401}
402
403function enqueue<T>(job: () => Promise<T>): Promise<T> {
404 const run = journalQueue.then(job, job)
405 journalQueue = run.catch(() => undefined)
406 return run
407}
408
409// ---------- stream binding + capture ----------
410
411const current = () => boundArg ?? titleStream
412
413// Buffered items (no stream yet, or a failed append) + new ones: appended in order once a stream is bound.
414async function ingest($: EngineInterface, fresh: Item[]) {
415 const sid = await $.session.id()
416 const key = bufferKey(sid)
417 const buffered = ((await $.store.get(key)) as Item[] | undefined) ?? []
418 const items = [...buffered, ...fresh]
419 if (items.length === 0) return
420 const stream = current()
421 if (!stream) {
422 await $.store.set(key, items)
423 return
424 }
425 const ids = await enqueue(() => appendEntries($, stream, items))
426 if (ids) {
427 if (buffered.length > 0) await $.store.delete(key)
428 } else {
429 await $.store.set(key, items)
430 $.ui.toast(`Could not write the journal of stream "${stream}"; ${items.length} ${items.length === 1 ? 'entry' : 'entries'} kept for later`)
431 }
432}
433
434async function bind($: EngineInterface, apply: () => void) {
435 const before = current()
436 apply()
437 if (!before && current()) await ingest($, [])
438}
439
440async function noteTitle($: EngineInterface, title: string | undefined) {
441 if (title === undefined) return
442 const slug = slugify(title)
443 if (slug === null) return
444 await bind($, () => {
445 titleStream = slug
446 })
447}
448
449// ---------- on-screen trailer hiding (drawing only; the stored message and the journal capture keep the trailer) ----------
450
451const TRAILER_OPEN = '<!-- ckpt'
452
453// Closed trailers cut out of one AssistantMessage block's text, the text kept byte for byte up to them.
454// `unclosed`: a trailer is still streaming (no `-->` yet); its tail is cut too.
455export function splitTrailer(text: string): { body: string; trailers: string[]; kinds: string[]; unclosed: boolean } {
456 const kinds: string[] = []
457 const trailers: string[] = []
458 let body = text.replace(/\s*<!-- ckpt[\s\S]*?-->/g, raw => {
459 const trailer = raw.trim()
460 trailers.push(trailer)
461 for (const c of parseTrailer(trailer)) if (!kinds.includes(c.type)) kinds.push(c.type)
462 return ''
463 })
464 const open = body.indexOf(TRAILER_OPEN)
465 const unclosed = open >= 0
466 if (unclosed) body = body.slice(0, open)
467 return { body: body.trimEnd(), trailers, kinds, unclosed }
468}
469
470const clauseList = (kinds: string[]) => (kinds.length ? `: ${kinds.join(', ')}` : '')
471
472// Pure text rewrite: closed trailers and an unclosed streaming tail are cut; a closed one leaves one italic marker naming the clauses.
473// Used while a trailer streams (no marker) and as the fallback when the tree cannot be drawn (text too long).
474export function hideTrailer(text: string): string {
475 if (!text.includes(TRAILER_OPEN)) return text
476 const { body, kinds, unclosed } = splitTrailer(text)
477 if (unclosed) return body
478 const marker = `_ckpt${clauseList(kinds)}_`
479 return body ? `${body}\n\n${marker}` : marker
480}
481
482// Message ids (e.requestId) whose trailer is expanded on screen. Module scope: a press flips it and redraws; lost on mod reload (all collapsed again).
483const expanded = new Set<string>()
484
485// A seam = the answer holds a ckpt trailer with a `decision` clause.
486export function hasDecisionSeam(answer: string): boolean {
487 const trailers = answer.match(/<!-- ckpt[\s\S]*?-->/g)
488 return !!trailers && trailers.some(t => parseTrailer(t).some(c => c.type === 'decision'))
489}
490
491// Seam strength of the answer, by code from its ckpt trailers. strong = a `pivot` clause, or a clause naming a task id T<n>
492// together with done / closed / closes / a check mark. simple = a `decision` clause. A closed task wins over a pivot (more specific message).
493export type Seam = { level: 'none' } | { level: 'simple' } | { level: 'strong'; task?: string }
494
495const DONE_RE = /\b(?:done|clos(?:e|es|ed|ing))\b|\u2705/gi
496
497// The task id nearest to a done word in one clause text, or null.
498function closedTask(text: string): string | null {
499 const ids = [...text.matchAll(/\bT(\d+)\b/g)]
500 const dones = [...text.matchAll(DONE_RE)]
501 if (ids.length === 0 || dones.length === 0) return null
502 let best: { n: string; d: number } | null = null
503 for (const id of ids) for (const done of dones) {
504 const d = Math.abs((id.index ?? 0) - (done.index ?? 0))
505 if (!best || d < best.d) best = { n: id[1] ?? '', d }
506 }
507 return best ? `T${best.n}` : null
508}
509
510export function seamOf(answer: string): Seam {
511 const trailers = answer.match(/<!-- ckpt[\s\S]*?-->/g) ?? []
512 let pivot = false
513 let simple = false
514 let task: string | null = null
515 for (const t of trailers) {
516 for (const c of parseTrailer(t)) {
517 if (c.type === 'pivot') pivot = true
518 if (c.type === 'decision') simple = true
519 task = closedTask(c.text) ?? task
520 }
521 }
522 if (task) return { level: 'strong', task }
523 if (pivot) return { level: 'strong' }
524 return simple ? { level: 'simple' } : { level: 'none' }
525}
526
527async function capture($: EngineInterface, answer: string) {
528 const trailers = answer.match(/<!-- ckpt[\s\S]*?-->/g)
529 if (!trailers) return
530 const iso = new Date(await $.clock.now()).toISOString()
531 const sid = await $.session.id()
532 await ingest($, trailers.map(trailer => ({ trailer, iso, sid })))
533}
534
535// ---------- state build (fork + assembly + write) ----------
536
537type Built = { kind: 'ok'; state: string; cursor: string; cut: string[] } | { kind: 'blocked'; reason: string }
538
539function forkPrompt(prev: string | null, entries: Entry[]): string {
540 const news = entries.length ? entries.map(e => `### ${e.id}\n${e.trailer}`).join('\n\n') : '(none)'
541 return `${FORK_PROMPT}\n\nPREVIOUS STATE:\n${prev ?? '(none)'}\n\nJOURNAL ENTRIES TO ROUTE (after the cursor):\n${news}`
542}
543
544async function buildState($: EngineInterface, stream: string): Promise<Built> {
545 try {
546 const sid = await $.session.id()
547 const prev = await readOptional($, statePath(stream))
548 const journal = await readOptional($, journalPath(stream))
549 const cursor = prev ? Number((parseState(prev).scalars.journal_cursor ?? '').replace(/^c/, '')) || 0 : 0
550 const entries = parseJournal(journal ?? '').filter(e => e.n > cursor)
551
552 const reply = await $.model.fork({ prompt: forkPrompt(prev, entries) })
553 if (!reply.isAnswered) return { kind: 'blocked', reason: `fork failed (${reply.reason})` }
554 const parsed = parseFork(reply.text)
555 if (parsed.kind === 'blocked') return parsed
556 const fork = parseForkBody(parsed.body)
557 if (!fork) return { kind: 'blocked', reason: 'unparseable fork output' }
558
559 const saved = new Date(await $.clock.now()).toISOString()
560 const result = assemble({ stream, saved, sid, prev, entries, fork })
561 if (result.kind === 'blocked') return result
562 await $.fs.write(statePath(stream), result.state)
563 return result
564 } catch (err) {
565 return { kind: 'blocked', reason: `error: ${String(err)}` }
566 }
567}
568
569// `/self-relay save`: the command already returned; the outcome is a toast.
570// `auto` = the hard zone (T19): the outcome becomes the persistent advice line + a toast, and the zone flags are not re-armed
571// (the fill is still high: re-arming would save again at every measure).
572async function saveInBackground($: EngineInterface, stream: string, auto?: { pct: number; cycle: number }) {
573 let failure: string | null = null
574 try {
575 const built = await buildState($, stream)
576 if (auto) {
577 failure = built.kind === 'ok' ? null : built.reason
578 } else {
579 $.ui.toast(
580 built.kind === 'ok'
581 ? `Stream "${stream}" saved (${built.state.length.toLocaleString('en-US')} chars${built.cut.length ? `, trimmed: ${built.cut.join(', ')}` : ''})`
582 : `Could not save stream "${stream}": ${built.reason}`,
583 )
584 }
585 } catch (err) {
586 if (auto) failure = `error: ${String(err)}`
587 else $.ui.toast(`Saving stream "${stream}" failed: ${String(err)}. Try /self-relay save again`)
588 } finally {
589 saving = false
590 if (!auto) rearmZones()
591 if (auto) hardOutcome($, auto, failure)
592 else flowStatus($, undefined)
593 }
594}
595
596// ---------- context zones (T19, T21) ----------
597
598// Re-arm: the toast may fire again, the advice line goes (callers repaint).
599function rearmZones() {
600 softDone = false
601 hardDone = false
602 advice = null
603 cycle++
604}
605
606// Full session reset (/clear, new/resumed/forked session): flags, seam, last fill and the cached wall.
607function resetZones() {
608 rearmZones()
609 seam = { level: 'none' }
610 lastM = null
611 wallCache = null
612}
613
614// ---------- the one status line ----------
615// A plugin has ONE `$.ui.status`. Precedence: an active save/relay flow (flowLine) wins; when it clears, the advice line
616// (if still valid) is drawn again. Every status of the mod goes through flowStatus() / paint(), never `$.ui.status` directly.
617
618function paint($: EngineInterface) {
619 $.ui.status(flowLine ?? (advice ? adviceText(advice) : undefined))
620}
621
622function flowStatus($: EngineInterface, text: string | undefined) {
623 flowLine = text
624 paint($)
625}
626
627// ---------- advice wording (plain words, each says what happened and what to type) ----------
628
629export type Advice = {
630 kind: 'task' | 'pivot' | 'decision' | 'saved' | 'failed' | 'nostream'
631 // 1 simple seam, 2 strong seam, 3 hard zone: a higher rank replaces a lower one, never the other way round.
632 rank: 1 | 2 | 3
633 // The advice is dropped when the fill falls under this.
634 floor: number
635 pct: number
636 task?: string
637 reason?: string
638}
639
640const SHORT_REASON = 80
641
642const shortReason = (reason: string) => {
643 const one = reason.replace(/\s+/g, ' ').trim()
644 return one.length > SHORT_REASON ? `${one.slice(0, SHORT_REASON - 1)}…` : one
645}
646
647export function adviceText(a: Advice): string {
648 switch (a.kind) {
649 case 'task':
650 return `Good moment for a fresh start: ${a.task} just closed (context ${a.pct}%). Type /self-relay --yes`
651 case 'pivot':
652 return `Good moment for a fresh start: the direction just changed (context ${a.pct}%). Type /self-relay --yes`
653 case 'decision':
654 return `Good moment for a fresh start: a decision just landed (context ${a.pct}%). Type /self-relay --yes`
655 case 'saved':
656 return `Progress saved automatically (context ${a.pct}%). Type /self-relay --yes to continue in a fresh session`
657 case 'failed':
658 return `Context almost full (${a.pct}%) and the automatic save failed (${shortReason(a.reason ?? 'unknown')}). Type /self-relay --yes to continue in a fresh session`
659 case 'nostream':
660 return `Context almost full (${a.pct}%) but no stream is set, so nothing was saved. Type /self-relay save <name>`
661 }
662}
663
664const savingText = (pct: number) => `Context almost full (${pct}%): automatically saving your progress...`
665
666const seamName = (s: Seam) => (s.level === 'strong' ? `strong:${s.task ?? 'pivot'}` : s.level)
667
668// fill = tokens / window; pct = the figure shown (status-line percent when present, else the rounded fill); hardAt = tokens at which the hard zone starts.
669type Measure = { fill: number; pct: number; tokens: number; window: number; wall: number | null; hardAt: number }
670
671// One line per measure, silent ones included, to the debug log (nothing on screen).
672function ctxLog($: EngineInterface, src: 'measure' | 'turn', m: Measure, action: string) {
673 $.ui.log(
674 `self-relay: ctx pct=${m.pct}% tokens=${m.tokens} window=${m.window} wall=${m.wall ?? 'none'} hard=${m.hardAt} seam=${seamName(seam)} action=${action} src=${src}`,
675 { to: 'debug' },
676 )
677}
678
679// The wall = the token count where auto-compaction fires: auto-compact threshold, else the compaction window, else unknown (null).
680async function wallOf($: EngineInterface): Promise<number | null> {
681 if (wallCache !== null) return wallCache
682 try {
683 const breakdown = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
684 const wall = breakdown?.autoCompactThreshold ?? breakdown?.rawMaxTokens
685 if (wall !== undefined && wall > 0) {
686 wallCache = wall
687 return wall
688 }
689 } catch (err) {
690 $.ui.log(`self-relay: ctx usage unavailable, hard = HARD * window: ${String(err)}`, { to: 'debug' })
691 }
692 return null
693}
694
695// Tokens at which the hard pre-save starts: HARD * window, capped at WALL_CAP * wall when the wall is known.
696export function hardAtOf(window: number, wall: number | null): number {
697 const byWindow = HARD * window
698 return Math.round(wall === null ? byWindow : Math.min(byWindow, WALL_CAP * wall))
699}
700
701// Seam advice. Runs from session.measure (fill moved) and from turn.complete (seam moved), so the order of the two events does not matter.
702// strong seam + fill >= STRONG, or simple seam + fill >= SOFT: the advice line is drawn and the toast shown once per cycle.
703// A stronger seam later upgrades the line without a second toast; the percentage on the line follows the fill.
704// Returns the action for the log line.
705function adviceStep($: EngineInterface, fill: number, pct: number): string {
706 if (phase === 'armed') return 'skip-armed'
707 const eligible = seam.level === 'strong' ? fill >= STRONG : seam.level === 'simple' ? fill >= SOFT : false
708 if (!eligible) {
709 if (!advice) return seam.level === 'none' ? 'no-seam' : 'below-floor'
710 advice.pct = pct
711 paint($)
712 return 'advice-keep'
713 }
714 const next: Advice =
715 seam.level === 'strong'
716 ? seam.task
717 ? { kind: 'task', rank: 2, floor: STRONG, pct, task: seam.task }
718 : { kind: 'pivot', rank: 2, floor: STRONG, pct }
719 : { kind: 'decision', rank: 1, floor: SOFT, pct }
720 if (!advice) {
721 advice = next
722 paint($)
723 if (!softDone) {
724 softDone = true
725 $.ui.toast(adviceText(next))
726 }
727 return 'advice'
728 }
729 if (next.rank > advice.rank) {
730 advice = next
731 paint($)
732 return 'advice-upgrade'
733 }
734 advice.pct = pct
735 paint($)
736 return 'advice-keep'
737}
738
739// Hard: the existing save path, once per cycle, never a clear, never an arm. Returns the action for the log line.
740function hardSave($: EngineInterface, pct: number): string {
741 if (hardDone) {
742 if (advice) {
743 advice.pct = pct
744 paint($)
745 }
746 return 'hard-done'
747 }
748 hardDone = true
749 softDone = true
750 const stream = current()
751 if (!stream) {
752 advice = { kind: 'nostream', rank: 3, floor: SOFT, pct }
753 paint($)
754 $.ui.toast(adviceText(advice))
755 return 'hard-nostream'
756 }
757 if (saving || phase !== 'idle') return saving ? 'hard-skip-saving' : `hard-skip-${phase}`
758 saving = true
759 flowStatus($, savingText(pct))
760 saveInBackground($, stream, { pct, cycle }).catch(() => {
761 saving = false
762 })
763 return 'hard-save'
764}
765
766// The hard save finished: its outcome is the advice line (the flow line goes) and one toast. Stale (re-armed meanwhile): log only.
767function hardOutcome($: EngineInterface, auto: { pct: number; cycle: number }, failure: string | null) {
768 flowLine = undefined
769 if (auto.cycle !== cycle) {
770 paint($)
771 return
772 }
773 const pct = lastM ? lastM.pct : auto.pct
774 advice = failure === null ? { kind: 'saved', rank: 3, floor: SOFT, pct } : { kind: 'failed', rank: 3, floor: SOFT, pct, reason: failure }
775 paint($)
776 $.ui.toast(adviceText(advice))
777}
778
779async function onMeasure($: EngineInterface, tokens: number, window: number, percent: number | undefined) {
780 const fill = tokens / window
781 const pct = percent !== undefined ? Math.round(percent) : Math.round(fill * 100)
782 // The wall only matters for the hard cap: not asked far below the window, then cached.
783 const wall = wallCache !== null || fill >= USAGE_FROM ? await wallOf($) : null
784 const hardAt = hardAtOf(window, wall)
785 const m: Measure = { fill, pct, tokens, window, wall, hardAt }
786 lastM = m
787 let action: string
788 // The floor the current advice holds to: its own, or after the hard zone the lower of SOFT and 90% of where it started
789 // (a low cap must not re-arm at once and save again), else the lowest one.
790 const floor = hardDone ? Math.min(SOFT, (0.9 * hardAt) / window) : (advice?.floor ?? STRONG)
791 if ((advice || softDone || hardDone) && fill < floor) {
792 rearmZones()
793 paint($)
794 action = 'rearm'
795 } else if (tokens >= hardAt) action = hardSave($, pct)
796 else action = adviceStep($, fill, pct)
797 ctxLog($, 'measure', m, action)
798}
799
800// ---------- relay flow (v1 unchanged from the packet on) ----------
801
802async function reset($: EngineInterface) {
803 phase = 'idle'
804 pending = null
805 await $.store.delete(STORE_KEY)
806 flowStatus($, undefined)
807}
808
809async function persist($: EngineInterface) {
810 if (pending) await $.store.set(STORE_KEY, { ...pending, phase } satisfies Stored)
811}
812
813async function cancel($: EngineInterface) {
814 await reset($)
815 await $.ui.close({ id: PANE })
816 $.ui.toast('Fresh start cancelled')
817}
818
819async function clearAndRelay($: EngineInterface) {
820 phase = 'armed'
821 // Armed: the advice has done its job. Re-arm the zones (advice line off) before the flow line is drawn.
822 rearmZones()
823 await persist($)
824 await $.ui.close({ id: PANE })
825 flowStatus($, 'Starting a fresh session...')
826 $.command.run({ command: 'clear' }).catch(() => {
827 flowStatus($, 'Ready: type /clear to continue in a fresh session')
828 })
829}
830
831// The packet to re-inject after /clear: module var first, else a fresh store entry from this cwd.
832async function armedPacket($: EngineInterface): Promise<string | null> {
833 if (phase === 'armed' && pending) return pending.packet
834 const stored = (await $.store.get(STORE_KEY)) as Stored | undefined
835 if (!stored || stored.phase !== 'armed') return null
836 const now = await $.clock.now()
837 if (now - stored.createdAt >= STALE_MS) return null
838 if (stored.cwd !== (await $.session.cwd())) return null
839 return stored.packet
840}
841
842async function relay($: EngineInterface, stream: string, yes: boolean) {
843 saving = true
844 flowStatus($, `Saving stream "${stream}" before the fresh start...`)
845 let built: Built
846 try {
847 built = await buildState($, stream)
848 } finally {
849 saving = false
850 }
851 if (built.kind === 'blocked') {
852 // Nothing armed: the advice (if still valid) comes back with the flow line gone.
853 flowStatus($, undefined)
854 $.ui.toast(`Fresh start cancelled: ${built.reason}`)
855 return { text: `self-relay: fresh start cancelled: ${built.reason}` }
856 }
857
858 const packet = `${built.state.trimEnd()}\n\n${journalRule(stream)}`
859 pending = { packet, createdAt: await $.clock.now(), cwd: await $.session.cwd() }
860
861 // --yes: no pane, so every outcome goes back as {text} (visible over Remote Control, unlike status/pane).
862 // The 8,000-char cap is already enforced on the state by assemble().
863 if (yes) {
864 // $.command.run rejects inside the hook the run waits on (d.ts: command.run), so the human types /clear.
865 phase = 'armed'
866 rearmZones()
867 await persist($)
868 flowStatus($, 'Ready: type /clear to continue in a fresh session')
869 return { text: `self-relay: stream "${stream}" saved. Type /clear to continue in a fresh session.` }
870 }
871
872 phase = 'review'
873 await persist($)
874 flowStatus($, 'Check the summary, then confirm or cancel')
875 await $.ui.open({ id: PANE, title: 'self-relay', focus: true, closeOnEscape: true })
876 return {}
877}
878
879// ---------- load ----------
880
881// Injection = $.prompt.submit({ asUser: true }): it starts a turn (so REGIME can hold the model at [READY]), while a
882// {text} / {context} answer of command.run only records a transcript line and starts no turn.
883// The host REFUSES prompt.submit called from inside the command.run hook ("it would wait on the turn this hook is
884// holding; submit from a later event"), so the submit runs from a $.clock.after dispatch, after the hook returned.
885async function load($: EngineInterface, stream: string) {
886 const path = statePath(stream)
887 let state: string | null
888 try {
889 state = await readOptional($, path)
890 } catch (err) {
891 return { text: `self-relay: cannot read the saved stream "${stream}": ${String(err)}` }
892 }
893 if (state === null) return { text: `self-relay: no saved stream "${stream}" in this folder. Nothing loaded.` }
894
895 const text = `${state.trimEnd()}\n\n${journalRule(stream)}\n\n${REGIME}`
896 $.clock.after(1, () => {
897 $.prompt.submit({ text, asUser: true }).catch(err => {
898 $.ui.toast(`Loading stream "${stream}" failed: ${String(err)}. Try /self-relay load ${stream} again`)
899 })
900 })
901 return { text: `self-relay: loading stream "${stream}" as the first message.` }
902}
903
904// ---------- hooks ----------
905
906export function registerSelfRelay(on: On) {
907 on('session.start', async ($, e, next) => {
908 try {
909 await $.command.register({
910 name: 'self-relay',
911 description: 'Save the progress of a stream, continue it in a fresh session after /clear, or load it in a new session',
912 argumentHint: 'save [stream] | load <stream> | [stream] [--yes]',
913 })
914 } catch (err) {
915 $.ui.log(`self-relay: command not registered: ${String(err)}`)
916 }
917 return next(e)
918 })
919
920 on('command.run', { command: 'self-relay' }, async ($, e) => {
921 const args = parseArgs(e.args)
922 if ('error' in args) return { text: `self-relay: ${args.error}` }
923 if (phase !== 'idle' || saving) return {
924 text: saving
925 ? 'self-relay: already saving, wait a few seconds'
926 : phase === 'review'
927 ? 'self-relay: a summary is waiting for your review, confirm or cancel it first'
928 : 'self-relay: ready for a fresh start, type /clear',
929 }
930
931 // stream = explicit arg (sticky binding) > last session_title seen > refuse
932 const arg = args.stream
933 if (arg !== undefined) {
934 try {
935 await bind($, () => {
936 boundArg = arg
937 })
938 } catch (err) {
939 $.ui.log(`self-relay: buffer flush failed: ${String(err)}`)
940 }
941 }
942 const stream = current()
943 if (!stream) {
944 return { text: 'self-relay: no stream for this session. Type /self-relay save <name>, or /rename the session first.' }
945 }
946
947 if (args.verb === 'load') return load($, stream)
948
949 if (args.verb === 'save') {
950 saving = true
951 flowStatus($, `Saving stream "${stream}"...`)
952 // Not awaited: the hook returns at once. Whether the fork outlives the hook is proven live (plan T14).
953 saveInBackground($, stream).catch(() => {
954 saving = false
955 })
956 return { text: `self-relay: saving stream "${stream}"...` }
957 }
958
959 return relay($, stream, args.yes)
960 })
961
962 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
963 const { Box, Text, Markdown, Button } = $.ui.resolve(e)
964 const packet = pending?.packet ?? ''
965 const isCut = packet.length > MARKDOWN_MAX
966 const shown = isCut ? packet.slice(0, MARKDOWN_MAX - 200) : packet
967
968 return (
969 <Box flexDirection="column">
970 <Text dimColor>
971 relay packet: {packet.length} chars{isCut ? ' (view truncated, full packet kept)' : ''}
972 </Text>
973 <Markdown text={shown} />
974 <Box flexDirection="row" gap={2}>
975 <Button key="relay" hotkey="c" variant="primary" onPress={() => clearAndRelay($)}>
976 clear & relay
977 </Button>
978 <Button key="cancel" hotkey="x" role="dismiss" onPress={() => cancel($)}>
979 cancel
980 </Button>
981 </Box>
982 </Box>
983 )
984 })
985
986 // Trailer on screen. Closed: a tree in place of the row (reply without trailer + a pressable marker, trailer dim under it when expanded).
987 // Streaming (unclosed): cheap text rewrite. Else untouched. Runs per distinct text while a reply streams: one includes() first.
988 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
989 try {
990 const text = e.props.text
991 if (typeof text !== 'string' || !text.includes(TRAILER_OPEN)) return next(e)
992 const { body, trailers, kinds, unclosed } = splitTrailer(text)
993 if (unclosed) return next({ ...e, props: { ...e.props, text: hideTrailer(text) } })
994 if (trailers.length === 0) return next(e)
995 // Markdown holds 10,000 chars at most; a longer body would be refused whole and draw the trailer: rewrite as text instead.
996 if (body.length > MARKDOWN_MAX) return next({ ...e, props: { ...e.props, text: hideTrailer(text) } })
997
998 const id = e.requestId
999 const isOpen = expanded.has(id)
1000 const full = trailers.join('\n')
1001 const { Box, Text, Markdown, Button } = $.ui.resolve(e)
1002 return (
1003 <Box flexDirection="column">
1004 {body === '' ? null : <Markdown text={body} />}
1005 <Button
1006 key="ckpt-toggle"
1007 plain
1008 dimColor
1009 onPress={() => {
1010 if (expanded.has(id)) expanded.delete(id)
1011 else expanded.add(id)
1012 $.ui.invalidate('ui.render')
1013 }}
1014 >
1015 {`${isOpen ? '\u25BE' : '\u25B8'} ckpt${clauseList(kinds)}`}
1016 </Button>
1017 {isOpen ? <Text dimColor>{full.length > MARKDOWN_MAX ? `${full.slice(0, MARKDOWN_MAX - 1)}\u2026` : full}</Text> : null}
1018 </Box>
1019 )
1020 } catch {
1021 return next(e)
1022 }
1023 })
1024
1025 // Esc / close mark while reviewing = cancel.
1026 on('ui.close', async ($, e, next) => {
1027 if (e.id === PANE && e.origin.kind === 'person' && phase === 'review') await reset($)
1028 return next(e)
1029 })
1030
1031 on('classic.UserPromptSubmit', async ($, e, next) => {
1032 try {
1033 await noteTitle($, e.session_title)
1034 } catch (err) {
1035 $.ui.log(`self-relay: title not read: ${String(err)}`)
1036 }
1037 return next(e)
1038 })
1039
1040 on('classic.SessionStart', async ($, e, next) => {
1041 // Any source: the wall may differ (model), the fill starts over; seam, flags and the advice line re-arm.
1042 resetZones()
1043 if (e.source !== 'clear') {
1044 // A new, resumed or forked session is another session: unbind and drop any armed packet.
1045 if (e.source === 'startup' || e.source === 'resume' || e.source === 'fork') {
1046 boundArg = null
1047 titleStream = null
1048 phase = 'idle'
1049 pending = null
1050 saving = false
1051 flowLine = undefined
1052 }
1053 paint($)
1054 try {
1055 await noteTitle($, e.session_title)
1056 } catch (err) {
1057 $.ui.log(`self-relay: title not read: ${String(err)}`)
1058 }
1059 return next(e)
1060 }
1061
1062 try {
1063 await noteTitle($, e.session_title)
1064 } catch {
1065 // the binding stays as it was
1066 }
1067 const packet = await armedPacket($)
1068 if (phase === 'review') await $.ui.close({ id: PANE })
1069 await reset($)
1070 if (packet === null) return next(e)
1071
1072 const restored = current()
1073 $.prompt.submit({ text: `${packet}\n\n${REGIME}`, asUser: true }).catch(err => {
1074 $.ui.toast(`Could not load the saved notes: ${String(err)}. Type /self-relay load ${restored ?? '<stream>'}`)
1075 })
1076 $.ui.toast(restored ? `Fresh session started with the saved notes of stream "${restored}"` : 'Fresh session started with the saved notes')
1077 return next(e)
1078 })
1079
1080 // Context zones (T19): observe only. Acts when the context fill moved and a token count exists (absent right after /clear or a compact).
1081 on('session.measure', async ($, e, next) => {
1082 try {
1083 const tokens = e.context.tokens
1084 if (e.changed.includes('context') && tokens !== undefined && e.context.window > 0) await onMeasure($, tokens, e.context.window, e.context.percent)
1085 } catch (err) {
1086 try {
1087 $.ui.log(`self-relay: ctx measure failed: ${String(err)}`, { to: 'debug' })
1088 } catch {
1089 // nothing left to do
1090 }
1091 }
1092 return next(e)
1093 })
1094
1095 // Journal capture: main loop only, completed turns only. Never throws into the engine.
1096 on('turn.complete', async ($, e, next) => {
1097 if (e.agentId === undefined && !e.isAborted) {
1098 // Seam of the last main turn, for the advice. The d.ts does not order turn.complete against session.measure:
1099 // re-check the soft advice here with the last known fill, so either order works.
1100 try {
1101 seam = seamOf(e.answer)
1102 if (lastM !== null && lastM.tokens < lastM.hardAt) {
1103 const action = adviceStep($, lastM.fill, lastM.pct)
1104 if (action.startsWith('advice')) ctxLog($, 'turn', lastM, action)
1105 }
1106 } catch {
1107 // observe only
1108 }
1109 try {
1110 await capture($, e.answer)
1111 } catch (err) {
1112 try {
1113 $.ui.toast(`Could not record this turn's checkpoint: ${String(err)}`)
1114 } catch {
1115 // nothing left to do
1116 }
1117 }
1118 }
1119 return next(e)
1120 })
1121}
1122