SLOPSHOPPER

modtest

Sandbox for Claude Code mods experiments (not published)

newpanerowscommandtoaststatus
★ 20v0.1.0MITupdated 2026-10-06digital-stoic-org/agent-skills/modtest
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · modtest
│ ┃ self-relay ✕ › fix the failing auth test and add an audit log call │ ┃ relay packet: 0 chars │ ┃ ● modtest: self-relay: ctx pct=49% tokens=97400 window=200000 wall=no │ ┃ [ clear & relay ] [ cancel ] ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /self-relay │ ⎿ modtest: self-relay: no stream for this session. Type /self-rela │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · self-relay
relay packet: 0 chars [ clear & relay ] [ cancel ]
README

modtest

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.

Load

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.

Mods

modfilecommand
self-relayhooks/self-relay.tsx/self-relay

self-relay (v2)

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:

filecontentwritten by
journal/<stream>.mdone entry per <!-- ckpt ... --> trailer of a main-loop answer: ### cNN <ISO> <session-id> + the trailer verbatim; append-only, never rewritten, pruned by handcode, on every completed turn (subagent and aborted turns skipped)
relay-<stream>-llm.mdstate: 7 header fields + 10 body fields, at most 8,000 chars; lines from trailers end with (cNN); journal_cursor = last id integratedcode, 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.

context zones (T19, T21, T22)

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.

  • fill = 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.
  • wall = 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.
  • seam strength, by code from the last main-turn answer's ckpt trailer (no clause added to any checkpoint menu): strong = a 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.
  • advice: strong seam and fill >= 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 (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).
  • status precedence: an active flow (saving, writing state, review, armed, the hard pre-save) owns the line; when it clears, the advice line is drawn again if still valid. The advice goes on /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.
  • messages (toast and line): 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>.
  • every measure logs one line to the debug sink (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]].

Source 2 files
hooks/register.ts 8 lines
1import type { Register } from 'claude-code'
2
3import { registerSelfRelay } from './self-relay.tsx'
4
5export const register: Register = on => {
6  registerSelfRelay(on)
7}
8
hooks/self-relay.tsx 1122 lines
1import 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