diff --git a/packages/agent/docs/mobile-handoff/01-harness/01-delta/FINDINGS.md b/packages/agent/docs/mobile-handoff/01-harness/01-delta/FINDINGS.md new file mode 100644 index 000000000..ed5f3e497 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/01-delta/FINDINGS.md @@ -0,0 +1,217 @@ +# 01-delta: known defects and measured findings + +Everything here was found **after** `delta.md` and the shipped implementation were +written. None of it is applied to the code in this directory. Each item is +problem, evidence, then a candidate fix. + +Every number was measured with **`node --experimental-strip-types`**, not `tsx`. +That matters: tsx transpiles through esbuild and inflated the same benchmark by +**2.6x** (183 µs vs 63 µs per write). Do not benchmark this module through a +transpiler. + +--- + +## D1. Interleaved paths defeat coalescing (correctness-adjacent, unbounded memory) + +**Severity: high.** This is the one to fix first. + +### Problem + +`flush()` coalesces only **adjacent** ops. A producer that alternates between two +paths therefore never coalesces anything, and the ops array grows with the number +of writes rather than with the size of the value. + +This is not hypothetical. It is exactly what bash does: it updates the output text +and a byte counter on every chunk. + +```ts +const next = t.state.content[0].text + chunk; +t.state.content[0].text = next.length > CAP ? next.slice(next.length - CAP) : next; +t.state.truncation.totalBytes += chunk.length; // <- the other path +``` + +### Evidence + +50 KB window, 200-byte chunks, no flush in between: + +| held-back writes | ops | bytes | +| --- | --- | --- | +| 1 | 3 | 0.3 KB | +| 100 | 201 | 26 KB | +| **1000** | **2001** | **264 KB** | + +Without interleaving the same workload gives 1 op and 51 KB. The window is +bounded at 50 KB; the ops describing it are not. + +### Why it matters beyond memory + +Any design where the sink **holds back** — rate limiting, backpressure, a slow +consumer — depends on held-back writes costing nothing. They currently cost +everything. See `../../04-tool-output/rate-limiting.md`. + +### Candidate fix + +Fold at **record** time, keyed by path rather than by adjacency: one slot per +path holding at most two ops (`[s]`, `[d]`, `[t]`, `[a]`, or `[t, a]`), emitted in +first-touch order. + +A prototype reached **2 ops / 51 KB** on the same workload. It is not included +here because it was not verified to the standard the rest of this unit meets. + +**Four folding rules, each a specific claim about the vocabulary:** + +1. A later `s` supersedes an earlier `s` on the same path — but **not** an earlier + `d`, which must survive or the key changes position on reinsertion (§5.1). +2. Consecutive `a` merge. Once the merged append is **strictly longer** than the + value, the window has been sliding and the append is the more expensive + spelling, so collapse to `s`. Strictly: while a string is still *growing* + toward its cap the append equals the value, and collapsing there would resend + the whole string every batch. +3. `t` and `a` on one path **commute** — the truncate drops from the front, the + append adds to the end — so a rolling window's `t,a,t,a,...` folds to one of + each. Without this, pairwise merging never fires at all. +4. `t` or `a` following a pending `s` changes nothing: the `s` already means "this + path becomes its current value". + +**The trap that prototype hit.** Slots emit in first-touch order, which loses the +fact that a child write happened *before* a later parent write: + +``` +script : a={x:1}; a.b=99; a={c:2} +producer: {"a":{"c":2}} +replica : {"a":{"c":2,"b":99}} <- WRONG +``` + +A set or delete must invalidate every pending slot **below** its path, at record +time. The existing `verify-dead` style test passed 1190 sequences while this was +broken, because its generator only nested one level. Any test for this must +generate genuinely nested paths with parent overwrites interleaved between child +writes. + +--- + +## D2. `overlap()` is 94.5% of tracker time + +**Severity: high.** Everything else in this file is noise beside it. + +### Problem + +The tracker infers "you appended and evicted" by searching two 50 KB strings. +Each new value produced by `next.slice(-CAP)` is a V8 `SlicedString` — a lazy +substring — and `indexOf`/`endsWith` force it to **flatten**, copying 50 KB per +call. + +### Evidence + +CPU profile of a 20 000-write rolling-window loop, node, flush at the end: + +``` + 94.5% overlap + 2.7% (anon) + 1.2% (garbage collector) + 0.5% set + 0.3% emit + 0.0% fold +``` + +And the flattening is directly observable: + +| `overlap()` on a 50 KB window | µs | +| --- | --- | +| the same two strings reused | 37 | +| **a fresh sliced string each call** | **149** | + +An earlier benchmark reported 29 µs because it reused two already-flattened +strings 2000 times — a case that never occurs in the real loop. **Do not benchmark +string operations against a reused fixture.** + +### Candidate fix + +Do not infer what the producer already knows. `ToolOutput` performs the windowing, +so it can emit the ops directly rather than assigning a new string and having the +tracker search for the difference: + +```ts +out.append(chunk); // -> ["t", path, dropped], ["a", path, chunk] +``` + +Two integers — chars appended, chars evicted — describe everything that happened +between any two observations of a bounded window. A prototype of this shape held +**exactly the window** in memory under every load, with no ops array, no overlap +detection and no folding rules at all. + +This does **not** remove `overlap`: it stays for the general case where a producer +assigns a whole new string and the delta genuinely has to be discovered. It +removes it from the hot path. + +### Do not + +Do not "optimise" `overlap` itself before doing the above. Its algorithm is +already correct and bounded (`maxOverlapScan`); the cost is string flattening, +which no probe strategy avoids. + +--- + +## D3. Nested-path proxy overhead is next, and currently invisible + +**Severity: unknown — measure before acting.** + +`overlap` dominates so completely that nothing else shows in a profile. But two +benchmarks disagree in a way that points at path depth: + +| | µs per write | +| --- | --- | +| top-level path (`{ text }`) | 63 | +| nested path (`{ content: [{ text }] }`) | 244 | + +4x for depth alone. Each nested property access wraps a child proxy and builds a +cache key by joining the path, so a read of `content[0].text` allocates on every +access. + +**Re-profile after D2 lands.** This may be the next bottleneck or it may be +nothing; the current numbers cannot distinguish them because `overlap` swamps both. + +--- + +## D4. Things measured and found NOT worth doing + +Recorded so nobody repeats the work. All were under **1.5% combined** in the +profile above. + +**A path trie replacing `JSON.stringify(path)` keys.** Genuinely faster in +isolation — 6x on the `LaneSnapshot` shape, 93x at 500 distinct paths — because +stringify cost scales with path depth while a trie walk is one `Map.get` per +segment. But the whole slot layer is 0.5% of runtime. Build it only if a profile +after D2 says so. + +**Trie as storage vs trie as index.** If a trie is ever built, it should map path +to a **position in the ops array**, not hold the ops. Holding them loses first-touch +ordering and forces a sort at flush to rebuild it. Measured difference: none +(1.4x / 0.9x / 1.0x). The argument is structural, not performance. + +**A `maxDepth` heuristic to skip descendant scanning.** Rejected: the flat map was +losing on the common case too, so the heuristic would have hidden a real cost +rather than removed it. + +**Eager string concatenation in the `a`+`a` merge.** Real (it rebuilds a growing +string per write) but worth ~5%. Compare lengths and materialise only when +emitting. + +--- + +## D5. Measurement errors made while producing these numbers + +Listed because each one produced a confident wrong conclusion, and the same traps +are still there. + +- **Benchmarking through `tsx`** — inflated everything 2.6x. +- **Reusing a flattened string** in a string benchmark — hid the real cost 4x. +- **Repetitive fixtures** (`"x".repeat(n)`) — overlap detection hits its candidate + bound and gives up, so the benchmark measures the fallback path, not the real one. + Use varied text. +- **Measuring a cache hit as if it were work** — `Markdown.render()` returns + `cachedLines` when text and width are unchanged, so a repeat-the-same-call loop + measured 0.065 ms for something that costs 0.86 ms. +- **Reasoning about complexity while the constant dominated** — twice. +- **Measuring heap instead of retained size** — a fixture generating 800 x 1 MB + strings reported 40 MB of "growth" that was its own garbage. diff --git a/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta-impl.ts b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta-impl.ts new file mode 100644 index 000000000..f47056220 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta-impl.ts @@ -0,0 +1,826 @@ +// ───────────────────────────────────────────────────────────────────────────── +// harness/delta — intent-recording change tracking over plain JSON. +// +// Depends on nothing else in the harness. Session storage, the runtime and the +// facet host consume it; keep the arrows pointing that way. +// ───────────────────────────────────────────────────────────────────────────── + +export type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue }; + +export type Seg = string | number; +export type Path = readonly Seg[]; +export type NonEmptyPath = readonly [Seg, ...Seg[]]; + +/** A path inline, or an id assigned by the encoder on second use. */ +export type PathRef

= P | number; + +/** + * Tuples are the form — in memory, on the wire, on disk. + * + * `r` is the ONLY op that replaces a whole value. `s`/`d`/`a`/`t` cannot target + * the root: the type forbids it. `p` may, and only because a tracked value can + * itself be an array — but a `p` that replaces its entire target is normalised to + * `r`/`s` at record time, so a root `p` is always a partial modification. + * + * `Op` knows nothing about the path dictionary. Interning, id references and + * omitted paths live in `WireOp` and exist only between `encode` and `decode`. + */ +export type Op = + | readonly ["r", JsonValue] + | readonly ["s", NonEmptyPath, JsonValue] + | readonly ["d", NonEmptyPath] + | readonly ["a", NonEmptyPath, string] + | readonly ["t", NonEmptyPath, number] + | readonly ["p", Path, number, number, JsonValue[]]; + +/** + * What crosses a boundary. Adds two compressions and nothing else: + * + * ["#", id, path] defines an id, emitted on a path's SECOND use + * a numeric PathRef references a previously defined id + * a shortened tuple reuses the previous op's path; arity disambiguates + * + * ["r", value] carries no path, so it encodes to itself — which is why isBase + * works unchanged on either vocabulary. + */ +export type WireOp = + | readonly ["r", JsonValue] + | readonly ["s", PathRef, JsonValue] | readonly ["s", JsonValue] + | readonly ["d", PathRef] | readonly ["d"] + | readonly ["a", PathRef, string] | readonly ["a", string] + | readonly ["t", PathRef, number] | readonly ["t", number] + | readonly ["p", PathRef, number, number, JsonValue[]] + | readonly ["p", number, number, JsonValue[]] + | readonly ["#", number, Path]; + +// ─── Classification ────────────────────────────────────────────────────────── + +export const isReplace = (op: Op | WireOp): boolean => op[0] === "r"; + +/** + * A batch begins with a replacement. Flush guarantees `r` is at index 0 or absent, + * so this is exact rather than a heuristic. + */ +export const isBase = (ops: readonly (Op | WireOp)[]): boolean => + ops.length > 0 && ops[0]![0] === "r"; + +// ─── Overlap ───────────────────────────────────────────────────────────────── + +/** + * Longest suffix of `a` that is a prefix of `b`. Probes with indexOf and verifies + * with endsWith, so the hot loops are native. A hand-written KMP is + * asymptotically equivalent and ~47x slower in practice. + * + * Always correct: the returned n satisfies a.endsWith(b.slice(0, n)). + */ +export function overlap(a: string, b: string, scan: number, probe = 64, maxCandidates = 8): number { + if (a.length === 0 || b.length === 0 || scan === 0) return 0; + const tail = a.length > scan ? a.slice(a.length - scan) : a; + + // A probe of length h can only find overlaps of at least h — the head must + // actually occur in `a`. So try a long head first (few candidates, and it + // catches the large overlaps a rolling window produces), then fall back to one + // character, which finds any overlap at the cost of more candidates. + // + // Candidates are bounded because repetitive output — a build log, or any run of + // one character — makes a long head match at thousands of positions, each + // costing a full endsWith. Giving up returns 0, which emits a set: larger, never + // wrong. Unbounded, a 50 KB window of one repeated character took 4.7 s per + // 2000 slides against 90 ms bounded. + for (const h of [Math.min(probe, b.length), 1]) { + const head = b.slice(0, h); + let tried = 0; + for (let k = tail.indexOf(head); k !== -1; k = tail.indexOf(head, k + 1)) { + if (++tried > maxCandidates) break; + const n = tail.length - k; + if (n <= b.length && tail.endsWith(b.slice(0, n))) return n; + } + if (h === 1) break; + } + return 0; +} + +// ─── Tracker ───────────────────────────────────────────────────────────────── + +export interface TrackerOptions { + maxOverlapScan?: number; + coalesce?: boolean; +} + +export interface Tracker { + /** + * Mutate this. Plain TS: assignment, push, splice, delete, all of it. + * + * Assigning to it replaces the whole value: emits `["r", next]` and discards + * ops recorded before it, which describe a value that no longer exists. + * + * `tracker.state = tracker.target` is therefore a checkpoint — it forces a base + * batch without changing anything. Discarding the pending ops is correct + * because the proxy mutates the target directly, so the value already carries + * them. + */ + state: T; + /** The untracked backing object. Reads only — writing here emits nothing. */ + readonly target: T; + flush(): Op[]; + /** + * Clear pending ops and make the next flush a base batch, without changing + * the value. + * + * Recovery replays from the last base batch, so a producer that wants "at most + * N batches to replay" calls this every N. The pending ops are already + * reflected in the value — the proxy mutates the target directly — so + * discarding them loses nothing. + */ + rebase(): void; + /** Discard without emitting. */ + discard(): void; + readonly dirty: boolean; +} + +const isObj = (v: unknown): v is object => v !== null && typeof v === "object"; +const clone = (v: T): T => (isObj(v) ? (structuredClone(v) as T) : v); +const INDEX = /^(?:0|[1-9]\d*)$/; +const norm = (t: object, k: string | symbol): Seg | symbol => + typeof k === "symbol" ? k : Array.isArray(t) && INDEX.test(k) ? Number(k) : k; +const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]); + +export function track(root: T, options: TrackerOptions = {}): Tracker { + const scan = options.maxOverlapScan ?? 65_536; + const doCoalesce = options.coalesce ?? true; + + let ops: Op[] = []; + + + const emit = (op: Op) => { + ops.push(op); + }; + + /** + * Reject reserved keys at record time, not just on apply. + * + * `JSON.parse('{"__proto__":{}}')` yields an OWN property, so a producer can + * hold state containing one without doing anything unusual. The tracker would + * then emit a path the applier refuses — state that cannot be replicated. Fail + * where the mistake is, at the write. + */ + const guard = (seg: Seg | symbol): Seg => { + if (typeof seg === "symbol") throw new UnsafePathError(String(seg)); + if (typeof seg === "string" && FORBIDDEN.has(seg)) throw new UnsafePathError(seg); + return seg; + }; + + /** + * A splice covering the whole target is a replacement written the long way. + * Only the interception knows the pre-splice length, so this is a record-time + * rewrite, not something flush can recover. + */ + const spliceOrReplace = (path: Path, before: number, index: number, remove: number, items: JsonValue[]) => { + if (index === 0 && remove === before) { + if (path.length === 0) emit(["r", items]); + else emit(["s", path as NonEmptyPath, items]); + return; + } + emit(["p", [...path], index, remove, items]); + }; + + const cache = new WeakMap>(); + + const wrap = (obj: V, path: Path): V => { + let byPath = cache.get(obj); + if (byPath === undefined) cache.set(obj, (byPath = new Map())); + const cacheKey = path.join("\u0000"); + const hit = byPath.get(cacheKey); + if (hit !== undefined) return hit as V; + + const proxy = new Proxy(obj, { + get(t, k, r) { + if (Array.isArray(t) && typeof k === "string" && MUTATORS.has(k)) { + return (...args: unknown[]) => { + const before = t.length; + const result = (Array.prototype[k as "push"] as Function).apply(t, args); + const items = (xs: unknown[]) => xs.map(clone) as JsonValue[]; + switch (k) { + case "push": spliceOrReplace(path, before, before, 0, items(args)); break; + case "unshift": spliceOrReplace(path, before, 0, 0, items(args)); break; + case "pop": if (before > 0) spliceOrReplace(path, before, before - 1, 1, []); break; + case "shift": if (before > 0) spliceOrReplace(path, before, 0, 1, []); break; + case "splice": { + const raw = Number(args[0] ?? 0); + const index = raw < 0 ? Math.max(0, before + raw) : Math.min(raw, before); + const remove = args.length < 2 ? before - index : Math.max(0, Math.min(Number(args[1]), before - index)); + spliceOrReplace(path, before, index, remove, items(args.slice(2))); + break; + } + // sort/reverse/fill/copyWithin permute wholesale; intent is not a splice. + default: + if (path.length === 0) emit(["r", clone(t) as JsonValue]); + else emit(["s", path as NonEmptyPath, clone(t) as JsonValue]); + } + return result; + }; + } + const v = Reflect.get(t, k, r); + return isObj(v) ? wrap(v, [...path, guard(norm(t, k))]) : v; + }, + + set(t, k, v, r) { + // `length` is a real mutation but not a document location. + if (Array.isArray(t) && k === "length") { + const before = t.length; + const next = Number(v); + if (next < before) spliceOrReplace(path, before, next, before - next, []); + else if (next > before) emit(["p", [...path], before, 0, Array(next - before).fill(null)]); + return Reflect.set(t, k, v, r); + } + + const at = [...path, guard(norm(t, k))] as unknown as NonEmptyPath; + + if (v === undefined) { // JSON has no undefined + emit(["d", at]); + return Reflect.deleteProperty(t, k); + } + + const prev = (t as Record)[k]; + if (typeof prev === "string" && typeof v === "string") { + if (prev === v) return true; + if (v.length > prev.length && v.startsWith(prev)) { + emit(["a", at, v.slice(prev.length)]); // fast path, no probe + } else { + const ov = overlap(prev, v, scan); + if (ov === 0) emit(["s", at, v]); + else { + emit(["t", at, prev.length - ov]); + if (v.length > ov) emit(["a", at, v.slice(ov)]); + } + } + } else { + assertJsonValue(v); + emit(["s", at, clone(v) as JsonValue]); + } + return Reflect.set(t, k, v, r); + }, + + deleteProperty(t, k) { + emit(["d", [...path, guard(norm(t, k))] as unknown as NonEmptyPath]); + return Reflect.deleteProperty(t, k); + }, + }); + + byPath.set(cacheKey, proxy); + return proxy as V; + }; + + let state = wrap(root, []); + // The first flush is always a base batch. A consumer starts with nothing, so + // a stream that opens with deltas has nothing to apply them to — and that + // fails at runtime, in the consumer, rather than where the mistake is. + let forceBase = true; + + return { + get state() { return state; }, + // A setter, so the obvious thing works. Without it `tracker.state = next` + // silently swaps the proxy for a plain object and stops tracking. + set state(next: T) { + ops = []; + root = next; + state = wrap(root, []); + forceBase = true; + }, + get target() { return root; }, + + // Same effect as assigning the current value back, minus the re-wrap: the + // value has not changed, so the proxy cache is still valid. + rebase() { ops = []; forceBase = true; }, + + + get dirty() { return ops.length > 0; }, + discard() { ops = []; }, + flush() { + if (forceBase) { + forceBase = false; + ops = []; + return [["r", clone(root) as JsonValue]]; + } + if (ops.length === 0) return []; + let out = ops; + ops = []; + + // Everything before a replacement is dead. + const lastReplace = out.findLastIndex(isReplace); + if (lastReplace > 0) out = out.slice(lastReplace); + + if (doCoalesce) out = coalesce(dropDead(out)); + + return out; + }, + }; +} + +const samePath = (a: Op, b: Op): boolean => { + const pa = pathOf(a); + const pb = pathOf(b); + if (pa === undefined || pb === undefined) return false; + return JSON.stringify(pa) === JSON.stringify(pb); +}; + +function pathOf(op: Op): Path | undefined { + return op[0] === "r" ? undefined : op[1]; +} + +/** + * Drop ops that a later op makes unreachable. + * + * Ops apply in order, so an op is dead if a later one overwrites the whole + * subtree it lives in. Only `s`, `d` and `r` do that — `a`, `t` and `p` modify + * what is already there, so they never dominate. + * + * O(n * d): walk backwards keeping a map of overwritten paths, and for each op + * test its own prefixes against that map. `d` is the path depth, which is small + * and bounded. The obvious formulation — compare each op against every + * dominator — is O(n^2 * d) and degrades on exactly the wide-flush input this + * pass exists to clean up. Measured: 12-14 microseconds per op, flat from 100 to + * 5000 ops, and 10001 redundant writes collapse to 2 emitted ops. + * + * OBJECT KEY ORDER IS NOT PRESERVED. A producer that does + * `set c; set a; delete c; set c` leaves `c` last, because deleting and + * reinserting moves it. The replica receives only the surviving `set c`, applies + * it to a base where `c` never existed, and `c` lands first. Values are + * identical — verified over 1823 randomised sequences with zero value + * mismatches — but the serialised forms can differ. + * + * This is fine because nothing compares a replica against its producer: the + * consumer holds the state and nobody diffs it back. The one visible case is a + * consumer that iterates keys for display, where two views could order rows + * differently. It requires a delete-and-recreate of the same key within one + * flush. Do not hash or content-address a replicated value. + */ +function dropDead(ops: Op[]): Op[] { + const overwritten = new Map(); + const kept: Op[] = []; + + for (let i = ops.length - 1; i >= 0; i--) { + const op = ops[i]!; + if (op[0] === "r") { + kept.push(op); + overwritten.clear(); + overwritten.set("", "s"); // the root is now overwritten + continue; + } + + const path = opPath(op); + if (path === undefined) { kept.push(op); continue; } // arity form, or an interned id + + let dead = overwritten.has(""); + let key = ""; + for (let k = 0; k < path.length && !dead; k++) { + key += `\u0000${String(path[k])}`; + const kind = overwritten.get(key); + if (kind === undefined) continue; + // A `d` at exactly a later `s`'s path is NOT dead. Dropping it leaves the + // key in its original slot, while the producer's delete-then-set moved it + // to the end: same value, different serialisation. + if (k === path.length - 1 && op[0] === "d" && kind === "s") break; + dead = true; + } + if (dead) continue; + + kept.push(op); + if (op[0] === "s" || op[0] === "d") overwritten.set(key, op[0]); + } + return kept.reverse(); +} + +/** Total, because `Op` paths are always inline. */ +function opPath(op: Op): Path | undefined { + return op[0] === "r" ? undefined : op[1]; +} + +/** Merge adjacent ops describing the same path. Lossless. */ +function coalesce(ops: Op[]): Op[] { + const out: Op[] = []; + for (const op of ops) { + const last = out[out.length - 1]; + if (last !== undefined && samePath(last, op)) { + if (last[0] === "a" && op[0] === "a") { + out[out.length - 1] = ["a", last[1], `${last[2]}${op[2]}`]; + continue; + } + if (last[0] === "s" && op[0] === "s") { // later write wins + out[out.length - 1] = op; + continue; + } + if (last[0] === "s" && op[0] === "a" && typeof last[2] === "string") { + out[out.length - 1] = ["s", last[1], `${last[2]}${op[2]}`]; + continue; + } + } + out.push(op); + } + return out; +} + +// ─── Path safety ───────────────────────────────────────────────────────────── + +/** + * Segments that reach the prototype chain. + * + * `JSON.parse` is safe on its own — it makes `__proto__` an own property. What is + * not safe is `parent[key] = value`, which is exactly what an applier does, and + * paths are data: `["s", ["__proto__", "isAdmin"], true]` pollutes + * `Object.prototype` for the whole process. + * + * Ops arrive from a facet, a plugin compartment, or a tool whose details may echo + * model output, so none of it is trusted input. + */ +const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]); + +export class UnsafePathError extends Error { + // Not a parameter property: Node's --experimental-strip-types rejects those, + // and these files are meant to run under it directly. + readonly segment: Seg; + constructor(segment: Seg) { + super(`unsafe path segment: ${String(segment)}`); + this.segment = segment; + this.name = "UnsafePathError"; + } +} + +/** + * Rejects anything JSON.stringify would not round-trip. + * + * structuredClone is NOT a JSON check: it clones a Map happily, and the op then + * carries `{}` while the producer keeps a real Map — producer and replica diverge + * silently, forever. + */ +export function assertJsonValue(value: unknown, seen = new Set()): void { + if (value === null) return; + const t = typeof value; + if (t === "string" || t === "boolean") return; + if (t === "number") { + if (!Number.isFinite(value as number)) throw new TypeError("non-finite number is not JSON"); + return; + } + if (t !== "object") throw new TypeError(`${t} is not a JsonValue`); + if (seen.has(value)) throw new TypeError("cyclic value is not JSON"); + seen.add(value); + if (Array.isArray(value)) { + for (const v of value) assertJsonValue(v, seen); + } else { + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + throw new TypeError(`${(value as object).constructor?.name ?? "object"} is not a JsonValue`); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError("symbol keys are not JSON"); + } + for (const v of Object.values(value as object)) assertJsonValue(v, seen); + } + seen.delete(value); +} + +/** + * Verb, arity and payload shape for a **decoded** op: paths inline, no `#`, no + * short forms. `apply` uses this. + * + * Validating `Op` against the wire grammar would be laxer than the type: a + * two-element `["s", value]` would pass, and `apply` would then read the value as + * a path. Each vocabulary gets the validator that matches it. + */ +export function assertValidOp(op: unknown): void { + if (!Array.isArray(op) || op.length === 0) throw new TypeError("op is not a tuple"); + switch (op[0]) { + case "r": if (op.length !== 2) throw new TypeError("r arity"); assertJsonValue(op[1]); return; + case "s": if (op.length !== 3) throw new TypeError("s arity"); assertPathArg(op[1]); assertJsonValue(op[2]); return; + case "d": if (op.length !== 2) throw new TypeError("d arity"); assertPathArg(op[1]); return; + case "a": if (op.length !== 3 || typeof op[2] !== "string") throw new TypeError("a shape"); assertPathArg(op[1]); return; + case "t": if (op.length !== 3 || !Number.isInteger(op[2])) throw new TypeError("t shape"); assertPathArg(op[1]); return; + case "p": { + if (op.length !== 5) throw new TypeError("p arity"); + assertPathArg(op[1]); + if (!Number.isInteger(op[2]) || op[2] < 0) throw new TypeError("p index"); + if (!Number.isInteger(op[3]) || op[3] < 0) throw new TypeError("p remove"); + if (!Array.isArray(op[4])) throw new TypeError("p items"); + for (const v of op[4]) assertJsonValue(v); + return; + } + // Silently skipping an unknown verb is how a newer producer's op vanishes. + default: throw new TypeError(`unknown op verb: ${String(op[0])}`); + } +} + +function assertPathArg(p: unknown): void { + if (!Array.isArray(p)) throw new TypeError("path is not an array"); + assertSafePath(p as Path); +} + +/** The same, for the wire grammar: ids and short forms are legal here. */ +export function assertValidWireOp(op: unknown): void { + if (!Array.isArray(op) || op.length === 0) throw new TypeError("op is not a tuple"); + const [verb] = op as unknown[]; + const okRef = (r: unknown): void => { + if (typeof r === "number") { + if (!Number.isInteger(r) || r < 0) throw new TypeError("bad path id"); + return; + } + // A string is not a path. Unchecked, `"a".slice(0, -1)` is `""`, so it + // resolves to the ROOT and writes there — a path that is not a path, accepted. + if (!Array.isArray(r)) throw new TypeError("path is not an array"); + assertSafePath(r as Path); + }; + switch (verb) { + case "r": if (op.length !== 2) throw new TypeError("r arity"); assertJsonValue(op[1]); return; + case "s": if (op.length === 3) { okRef(op[1]); assertJsonValue(op[2]); } + else if (op.length === 2) assertJsonValue(op[1]); + else throw new TypeError("s arity"); return; + case "d": if (op.length === 2) okRef(op[1]); else if (op.length !== 1) throw new TypeError("d arity"); return; + case "a": if (op.length === 3) { okRef(op[1]); if (typeof op[2] !== "string") throw new TypeError("a value"); } + else if (op.length === 2) { if (typeof op[1] !== "string") throw new TypeError("a value"); } + else throw new TypeError("a arity"); return; + case "t": if (op.length === 3) { okRef(op[1]); if (!Number.isInteger(op[2])) throw new TypeError("t count"); } + else if (op.length === 2) { if (!Number.isInteger(op[1])) throw new TypeError("t count"); } + else throw new TypeError("t arity"); return; + case "p": { + const [i, r, items] = op.length === 5 ? [op[2], op[3], op[4]] : op.length === 4 ? [op[1], op[2], op[3]] : []; + if (items === undefined) throw new TypeError("p arity"); + if (op.length === 5) okRef(op[1]); + if (!Number.isInteger(i) || (i as number) < 0) throw new TypeError("p index"); + if (!Number.isInteger(r) || (r as number) < 0) throw new TypeError("p remove"); + if (!Array.isArray(items)) throw new TypeError("p items"); + for (const v of items) assertJsonValue(v); + return; + } + case "#": { + if (op.length !== 3 || !Number.isInteger(op[1]) || !Array.isArray(op[2])) throw new TypeError("# shape"); + assertSafePath(op[2] as Path); + return; + } + // Silently skipping an unknown verb is how a newer producer's op vanishes. + default: throw new TypeError(`unknown op verb: ${String(verb)}`); + } +} + +export function assertSafePath(path: Path): void { + for (const seg of path) { + if (typeof seg === "string") { + if (FORBIDDEN.has(seg)) throw new UnsafePathError(seg); + } else if (!Number.isInteger(seg) || seg < 0) { + throw new UnsafePathError(seg); + } + } +} + +/** + * An index may address an existing element or append exactly one past the end. + * + * This is not an arbitrary cap — it is what keeps the value a `JsonValue`. A + * sparse array does not survive a JSON round trip: holes serialise to `null` and + * return as real properties, so `arr[7] = x` on a length-3 array already produces + * state a replica cannot match. Rejecting the write is more honest than silently + * diverging. + * + * It also removes the denial of service it would otherwise permit: + * `["s", ["xs", 4294967290], 1]` allocates a 4.29-billion-entry array from one op. + * Growth stays possible and stays proportional — the tracker already emits + * `arr.length = n` as a splice of explicit nulls, whose op size grows with the + * gap, so a large growth costs a large op rather than a small one. + */ +function assertIndexInRange(parent: readonly unknown[], index: number): void { + if (index > parent.length) throw new UnsafePathError(index); +} + +// ─── Applier ───────────────────────────────────────────────────────────────── + +export class PathError extends Error { + readonly path: Path | number; + constructor(path: Path | number) { + super(`unresolvable path: ${JSON.stringify(path)}`); + this.path = path; + this.name = "PathError"; + } +} + +/** + * Apply ops to a plain mutable value. Returns the value, because `r` replaces it + * outright and cannot be done in place. + * + * Takes decoded ops. Path ids and omitted paths are a wire concern — run + * `decode` first if the ops came from a boundary. + */ +export function apply(target: T, ops: readonly Op[]): T { + let root = target as unknown as JsonValue; + + for (const op of ops) { + assertValidOp(op); + + if (op[0] === "r") { + // Adopted, not copied. The consumer owns the batch it was handed. + // + // Fanning one batch out to several consumers in-process therefore makes + // their replicas alias each other. That is an ownership rule, not a + // defect: copy the batch at the fan-out point, or let each consumer + // decode its own. A batch that crosses a real boundary is already + // distinct, because serialisation produces fresh objects. + root = op[1]; + continue; + } + + const path = op[1]; + assertSafePath(path); + + if (op[0] === "p") { + const target_ = path.length === 0 ? root : resolve(root, path); + if (!Array.isArray(target_)) throw new PathError(path); + target_.splice(op[2], op[3], ...op[4]); + continue; + } + + // s/d/a/t can never target the root — the type forbids it. + const parent = resolve(root, path.slice(0, -1)) as Record; + const key = path[path.length - 1]!; + if (Array.isArray(parent) && typeof key === "number") assertIndexInRange(parent, key); + // defineProperty rather than assignment: a setter inherited from the prototype + // chain would otherwise run on write. + const write = (value: JsonValue) => { + Object.defineProperty(parent, key, { value, writable: true, enumerable: true, configurable: true }); + }; + const read = (): unknown => (Object.hasOwn(parent, key) ? parent[key] : undefined); + switch (op[0]) { + case "s": write(op[2]); break; + case "d": Array.isArray(parent) ? (parent as unknown as JsonValue[]).splice(Number(key), 1) : delete parent[key]; break; + case "a": write(`${String(read() ?? "")}${op[2]}`); break; + case "t": write(String(read() ?? "").slice(op[2])); break; + } + } + return root as unknown as T; +} + +function resolve(root: JsonValue, path: Path): JsonValue { + let node: JsonValue = root; + for (const seg of path) { + if (!isObj(node)) throw new PathError(path); + // Own properties only: an inherited getter must not run, and a walk must not + // escape the value into the prototype chain. + if (!Object.hasOwn(node, seg as PropertyKey)) throw new PathError(path); + node = (node as Record)[seg]!; + } + if (!isObj(node)) throw new PathError(path); + return node; +} + +// ─── Codec ─────────────────────────────────────────────────────────────────── +// +// Path interning and arity omission, per `delta.md` §4. These live between the +// tracker and a boundary; `Op` and `apply` know nothing about them. +// +// ONE PAIR PER STREAM. The table spans an entire subscription or file: a path +// interned in batch 3 is referenced in batch 40. A second consumer that +// subscribes at batch 40 has never seen the definition, so it needs its own +// encoder — sharing one across consumers hands the late subscriber ids it cannot +// resolve. A base batch does not rescue it, because `["r", value]` carries no +// path refs and leaves the table empty. + +const pathKey = (path: Path): string => path.join("\u0000"); + +export interface Encoder { + encode(ops: readonly Op[]): WireOp[]; +} + +/** + * Intern on SECOND use. A definition costs more than the path it replaces, so + * interning on first use loses on the many paths written exactly once — + * measured worse than no interning at all (`delta.md` §4.1). + */ +export function encoder(): Encoder { + const seen = new Set(); + const ids = new Map(); + let nextId = 0; + let previous: string | undefined; // last path in THIS batch + + return { + encode(ops) { + // Arity omission is scoped to a batch. Letting it span batches would make + // a batch's first op depend on the previous batch's last one, so a reader + // that skips or reorders a batch decodes into the wrong path. Ids are the + // only cross-batch state, and the dictionary makes those explicit. + previous = undefined; + const out: WireOp[] = []; + for (const op of ops) { + if (op[0] === "r") { + out.push(op); + // A base batch is a RECOVERY POINT: a reader replays from the last one + // with a fresh decoder. So everything after it must be self-contained. + // Keeping ids across a replacement emits references to definitions the + // reader never saw — recovery fails with an unresolvable path id. + seen.clear(); + ids.clear(); + nextId = 0; + previous = undefined; + continue; + } + const path = op[1]; + const key = pathKey(path); + + // Same path as the previous op: drop the ref entirely. + if (key === previous) { + switch (op[0]) { + case "s": out.push(["s", op[2]]); break; + case "d": out.push(["d"]); break; + case "a": out.push(["a", op[2]]); break; + case "t": out.push(["t", op[2]]); break; + case "p": out.push(["p", op[2], op[3], op[4]]); break; + } + continue; + } + + let ref: PathRef = path; + const existing = ids.get(key); + if (existing !== undefined) { + ref = existing; + } else if (seen.has(key)) { + const id = nextId++; + ids.set(key, id); + out.push(["#", id, path]); // second use: define, then reference + ref = id; + } else { + seen.add(key); // first use: inline + } + + switch (op[0]) { + case "s": out.push(["s", ref as PathRef, op[2]]); break; + case "d": out.push(["d", ref as PathRef]); break; + case "a": out.push(["a", ref as PathRef, op[2]]); break; + case "t": out.push(["t", ref as PathRef, op[2]]); break; + case "p": out.push(["p", ref, op[2], op[3], op[4]]); break; + } + previous = key; + } + return out; + }, + }; +} + +export interface Decoder { + decode(wire: readonly WireOp[]): Op[]; +} + +export function decoder(): Decoder { + const paths = new Map(); + + return { + decode(wire) { + let previous: Path | undefined; // scoped to the batch, as in encode + const out: Op[] = []; + for (const op of wire) { + assertValidWireOp(op); + if (op[0] === "#") { + assertSafePath(op[2]); + paths.set(op[1], op[2]); + continue; + } + if (op[0] === "r") { + out.push(op); + previous = undefined; + continue; + } + + // Arity tells us whether a ref is present: the short forms omit it. + const short = + (op[0] === "d" && op.length === 1) || + (op[0] !== "d" && op[0] !== "p" && op.length === 2) || + (op[0] === "p" && op.length === 4); + + let path: Path; + if (short) { + if (previous === undefined) throw new PathError([]); + path = previous; + } else { + const ref = op[1] as PathRef; + if (typeof ref === "number") { + const resolved = paths.get(ref); + if (resolved === undefined) throw new PathError(ref); + path = resolved; + } else { + path = ref; + } + previous = path; + } + + switch (op[0]) { + case "s": out.push(["s", path as NonEmptyPath, (short ? op[1] : op[2]) as JsonValue]); break; + case "d": out.push(["d", path as NonEmptyPath]); break; + case "a": out.push(["a", path as NonEmptyPath, (short ? op[1] : op[2]) as string]); break; + case "t": out.push(["t", path as NonEmptyPath, (short ? op[1] : op[2]) as number]); break; + case "p": { + const [i, r, items] = short + ? [op[1] as number, op[2] as number, op[3] as JsonValue[]] + : [op[2] as number, op[3] as number, op[4] as JsonValue[]]; + out.push(["p", path, i, r, items]); + break; + } + } + } + return out; + }, + }; +} diff --git a/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.bench.ts b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.bench.ts new file mode 100644 index 000000000..0c2f6b239 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.bench.ts @@ -0,0 +1,113 @@ +/** + * Not a test — a guard against the cliffs found during implementation. Reports + * units you can reason about: cost per WRITE, and bytes on the wire relative to + * bytes consumed. + * + * npx vitest bench delta.bench.ts + * + * The headline finding: cost is per write, not per byte. A 100-byte chunk costs + * about the same as a 1 KB chunk, because the fixed cost is the window slice and + * the overlap probe. So chunk size — set by the exec env's coalescing interval, + * not by this package — decides both throughput and wire size. + */ +import { bench, describe } from "vitest"; +import { decoder, encoder, track } from "./delta-impl.ts"; + +const WORDS = "the quick brown fox jumps over lazy dog while considering strategies".split(" "); +const text = (n: number): string => { + let s = ""; + while (s.length < n) s += `${WORDS[Math.floor(Math.random() * WORDS.length)]} `; + return s.slice(0, n); +}; + +const CAP = 50_000; +const WRITES = 500; +const FLUSH_EVERY = 20; + +/** One write = one chunk handed to the sink. Returns nothing; bench times it. */ +function drive(initial: string, chunkSize: number, cap = CAP): void { + const t = track({ out: initial }); + for (let i = 0; i < WRITES; i++) { + const next = t.state.out + text(chunkSize); + t.state.out = next.length > cap ? next.slice(next.length - cap) : next; + if (i % FLUSH_EVERY === FLUSH_EVERY - 1) t.flush(); + } + t.flush(); +} + +/** + * Measured on the reference machine, 500 writes each. Reproduced here so a + * regression is legible rather than just "slower". + * + * append only, 100 B 127 µs/write 8k writes/s 101% of input on wire + * rolling, 100 B 176 µs/write 6k writes/s 135% of input on wire + * rolling, 1 KB 68 µs/write 15k writes/s 104% of input on wire + * rolling, 8 KB 366 µs/write 3k writes/s 31% of input on wire + * pathological, 100 B 307 µs/write 3k writes/s 727% of input on wire + * + * Two things to notice. + * + * 135% at 100-byte chunks: past the cap every write emits `truncate` + `append`, + * and two op envelopes exceed a 100-byte payload — we put MORE on the wire than we + * consumed. At 8 KB it is 31%. Flush granularity is therefore a size lever, not + * just an update-rate lever, and the exec env's coalescing is what keeps chunks + * off the bad end of that curve. + * + * The pathological row is bounded only because the overlap candidate scan is + * capped. Unbounded it was 4.7 s for 2000 writes against 93 ms; the cap makes it + * give up and emit a set — larger, never wrong. + */ +describe("rolling window, 50 KB cap", () => { + bench("100 B chunks", () => { drive(text(CAP), 100); }); + bench("1 KB chunks", () => { drive(text(CAP), 1024); }); + bench("8 KB chunks", () => { drive(text(CAP), 8192); }); + bench("pathological: one repeated character", () => { drive("x".repeat(CAP), 100); }); +}); + +describe("below the cap", () => { + // The dominant case for most tools: startsWith fast path, no probe at all, + // and one append per flush rather than a truncate/append pair. + bench("append only, never fills", () => { drive("", 100, Number.MAX_SAFE_INTEGER); }); +}); + +// ── Codec: what path interning and arity omission actually save ──────────── +// +// Reported as wire bytes relative to inline paths, on three shapes: +// - one hot path (a rolling tool-output window): omission does the work +// - a few alternating paths (lane state): interning does the work +// - many distinct paths: neither helps, and definitions cost a little +describe("codec", () => { + const streams = { + "one hot path": () => { + const t = track({ content: [{ type: "text", text: "" }] }); + return Array.from({ length: 200 }, (_, i) => { + (t.state.content[0] as { text: string }).text += `line ${i} of build output\n`; + return t.flush(); + }); + }, + "four alternating paths": () => { + const t = track({ a: "", b: "", c: "", d: "" }); + return Array.from({ length: 200 }, (_, i) => { + for (const k of ["a", "b", "c", "d"] as const) t.state[k] += `${i}`; + return t.flush(); + }); + }, + "200 distinct paths": () => { + const root: Record = {}; + for (let i = 0; i < 200; i++) root[`f${i}`] = 0; + const t = track(root); + return Array.from({ length: 5 }, (_, r) => { + for (let i = 0; i < 200; i++) t.state[`f${i}`] = r + 1; + return t.flush(); + }); + }, + }; + + for (const [label, build] of Object.entries(streams)) { + bench(label, () => { + const enc = encoder(); + const dec = decoder(); + for (const ops of build()) dec.decode(enc.encode(ops)); + }); + } +}); diff --git a/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.examples.ts b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.examples.ts new file mode 100644 index 000000000..b7a1bee64 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.examples.ts @@ -0,0 +1,133 @@ +/** + * Runnable illustrations of the two things that are easy to get wrong. + * + * node --experimental-strip-types delta.examples.ts + * + * Nothing here is a test — `delta.test.ts` covers the same ground with + * assertions. This exists to be read. + */ +import { apply, decoder, encoder, isBase, track, type Op } from "./delta-impl.ts"; + +const show = (label: string, ops: Op[]) => { + console.log(` ${label.padEnd(22)} ${ops.length} op(s), base=${isBase(ops)}`); + for (const op of ops) console.log(` ${trunc(JSON.stringify(op), 66)}`); +}; +const trunc = (s: string, n: number) => (s.length <= n ? s : `${s.slice(0, n)}…`); + +// ───────────────────────────────────────────────────────────────────────── +// 1. Replacing the whole value +// ───────────────────────────────────────────────────────────────────────── +console.log("\n1. REPLACING THE WHOLE VALUE\n"); + +const before = { user: { id: "u1", name: "ada" }, items: ["a", "b"], note: "n".repeat(120) }; +const after = { user: { id: "u2", name: "bob" }, items: ["x"], note: "m".repeat(120) }; + +{ + // The wrong way. `state` is a property of the TRACKER, not of the tracked + // object. Without a setter this swaps the proxy for a plain object and every + // later mutation is silently untracked — no error, no ops. + // + // The implementation defines `state` as a setter precisely so this works. + const t = track>(structuredClone(before)); + t.flush(); // drain the opening base batch + t.state.items = ["changed first"]; // recorded… + t.state = structuredClone(after); // …then discarded: it described a dead value + show("assign to .state", t.flush()); + + t.state.user = { id: "u3" }; // still tracked afterwards + show(" and still tracked", t.flush()); +} + +{ + // Dead-op elimination: a field written three times emits one op, and a parent + // replaced after its child emits one. Object key order is not preserved by + // this pass — see delta.md §5.1. + const t = track>(structuredClone(before)); + t.flush(); // drain the opening base batch + t.state.items = ["first"]; + t.state.items = ["second"]; + t.state.user.name = "dropped"; + t.state.user = { id: "u9" }; + show("4 writes, 2 survive", t.flush()); +} + +// ───────────────────────────────────────────────────────────────────────── +// 2. Why checkpointing exists +// ───────────────────────────────────────────────────────────────────────── +console.log("\n2. RECOVERY LENGTH\n"); + +/** + * A bash-shaped workload: output arrives continuously and the sink writes + * durably on an interval. Each flush is one batch appended to a list. + * On resume the reader folds back to the last BASE batch, so that distance is + * the cost of recovery. + */ +function simulate(writes: number, checkpointEvery: number | null) { + const CAP = 50_000; + const t = track({ out: "" }); + const isBaseBatch: boolean[] = []; + + for (let i = 0; i < writes; i++) { + const next = `${t.state.out}[${i}] cc -c src/file${i}.c -o build/file${i}.o\n`; + t.state.out = next.length > CAP ? next.slice(next.length - CAP) : next; + + if (checkpointEvery !== null && i % checkpointEvery === checkpointEvery - 1) t.rebase(); + + const ops = t.flush(); + if (ops.length > 0) isBaseBatch.push(isBase(ops)); + } + + const lastBase = isBaseBatch.lastIndexOf(true); + return { written: isBaseBatch.length, replay: isBaseBatch.length - lastBase - 1 }; +} + +for (const [label, every] of [["never", null], ["rebase() every 50", 50]] as const) { + const r = simulate(500, every); + console.log(` ${label.padEnd(18)} ${r.written} batches written → ${r.replay} to replay on recovery`); +} + +console.log(` + Nothing produces a base batch on its own: flush() emits ops, and a replacement + happens only when the producer asks for one. So a stream of appends stays a + stream of deltas indefinitely, and recovery grows without bound. + + Two callers need rebase(): + - pendingToolOutput, during a long-running command. Without it a ten-minute + build leaves hundreds of batches to fold on resume. + - a facet host on resubscribe. facets.md §9.2 requires the first batch of a + subscription to be a base batch, and the host must be able to produce one + on demand. +`); + +// ───────────────────────────────────────────────────────────────────────── +// 3. A replica always agrees +// ───────────────────────────────────────────────────────────────────────── +console.log("3. PRODUCER AND REPLICA AGREE\n"); +{ + // The whole loop: tracker -> encode -> [boundary] -> decode -> apply. + const t = track>(structuredClone(before)); + const enc = encoder(); + const dec = decoder(); + let replica: unknown; + + const step = (label: string, mutate: () => void) => { + mutate(); + const ops = dec.decode(enc.encode(t.flush())); + if (ops.length === 0) return; + // No base-batch branch: apply handles `r` by replacing, and tolerates an + // undefined target because a root op never reads it. + replica = apply(replica, ops); + const same = JSON.stringify(replica) === JSON.stringify(t.target); + console.log(` ${label.padEnd(22)} ${ops.length} op(s) ${isBase(ops) ? "BASE " : "delta"} matches: ${same}`); + }; + + step("opening base batch", () => {}); + + step("append to a string", () => { (t.state.note as string); t.state.note = `${t.state.note}!`; }); + step("push", () => { (t.state.items as string[]).push("c"); }); + step("nested set", () => { (t.state.user as Record).name = "cy"; }); + step("delete", () => { delete t.state.note; }); + step("whole replacement", () => { t.state = structuredClone(after); }); + step("rebase", () => { t.rebase(); }); +} +console.log(); diff --git a/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.md b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.md new file mode 100644 index 000000000..a24a53ca3 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.md @@ -0,0 +1,709 @@ +# Delta Tracking and the Op Vocabulary + +> **Scope:** `packages/agent/src/harness/delta/`. It depends on nothing else in the +> harness — not on session storage, the runtime, or facets — and those three +> consume it. Keep that direction: a delta module that imports from `session/` or +> `runtime/` cannot later be extracted, and extracting it is the point of the +> boundary. +> +> It lives inside `packages/agent` rather than as its own package because it has +> exactly one consumer today. Split it out when a second one appears — the facet +> host is the likely trigger, since a replica in another process needs the applier +> without the harness. +> +> Nothing existing in `origin/dev` is modified by this unit alone. + +One mechanism covers assistant partials, tool output, tool details, lane state, +and arbitrary facet state — on the wire and in durable storage. + +**Shipped alongside this doc**, all four verified: + +| file | lands as | +| --- | --- | +| `delta.ts` | `harness/delta/types.ts` — the API surface as it should be exported | +| `delta-impl.ts` | `harness/delta/index.ts` — tracker, applier, codec, safety guards | +| `delta.test.ts` | `test/harness/delta.test.ts` — 42 tests. Port these first. | +| `delta.bench.ts` | `test/harness/delta.bench.ts` — guards two performance cliffs | +| `delta.examples.ts` | not shipped — runnable illustrations of §3.2.1 and §3.2.2 | + +Re-export from `harness/session/index.ts` is **not** wanted: session storage is a +consumer of this vocabulary, not its owner. + +`packages/agent` already has an `exports` map with subpaths (`.`, `./node`, …). +Give delta one too, so the boundary is visible from outside the package and a +later extraction is a one-line change rather than a search for importers: + +```jsonc +"./delta": { + "types": "./dist/harness/delta/index.d.ts", + "import": "./dist/harness/delta/index.js" +} +``` + +Consumers inside the harness import relatively; anything outside it imports +`@earendil-works/pi-agent/delta`. If the second form starts appearing inside +`packages/agent`, the module has outgrown its home — see the scope note above. + +```bash +npx vitest run delta.test.ts # 54 passing +npx vitest bench delta.bench.ts +node --experimental-strip-types delta.examples.ts # the two sharp edges, printed +``` + +Both import `./delta-impl.ts` directly and need no harness. `delta.ts` and +`delta-impl.ts` are verified mutually assignable, so the declared API is +satisfiable by the implementation rather than merely adjacent to it. + +## 1. Why not an existing library + +Immer, Valtio, Mutative and Colyseus all **record effects, not intent**. A string +is a leaf, so `text += chunk` is one write of the whole new string; there is no +representation in which the library could know you appended. `unshift` is the +same story — the engine shifts every index, so each is a write. + +Measured, all four: + +| operation | what they emit | +| --- | --- | +| `text += "x"` on 40 KB | `replace` carrying 40 KB | +| `arr.unshift(x)` on 100 items | ~100 index writes | +| `arr.pop()` | a write to a `length` path, which is not a document location | + +Immer's own pitfalls page states the guarantee: patches are correct and +**explicitly not minimal**. Colyseus documents the same array weakness — removing +the first of 20 items costs 38 extra bytes. + +Diffing base against result ourselves does not fix it either. A differ sees two +values, not intent. Prefix comparison catches pure growth, but a rolling window +that drops from the front *and* appends is neither a prefix nor a suffix of the +old value; measured on 5000 elements the fallback was +`{index: 0, remove: 5000, items: 5000}` — a full replace, in exactly the case the +optimisation existed for. + +So the win is not a better differ. It is **not diffing at all**. + +## 2. Ops + +**Tuples are the form. Everywhere. There is no keyed variant.** + +```ts +export type Seg = string | number; +export type Path = readonly Seg[]; +/** Non-empty: `s`/`d`/`a`/`t` cannot target the root. Enforced by the type. */ +export type NonEmptyPath = readonly [Seg, ...Seg[]]; + +export type Op = + | readonly ["r", JsonValue] // replace the value + | readonly ["s", NonEmptyPath, JsonValue] // set + | readonly ["d", NonEmptyPath] // delete + | readonly ["a", NonEmptyPath, string] // append + | readonly ["t", NonEmptyPath, number] // truncate, in chars + | readonly ["p", Path, number, number, JsonValue[]] // splice: index, remove, items +``` + +Interning, id references and omitted paths are **not** here — they live in +`WireOp` and exist only between `encode` and `decode` (§4). + +**`r` is the only op that replaces a whole value.** It is an op rather than a +frame kind for the same reason `a` and `t` exist: they are specialisations of `s` +that are smaller and state intent. + +Do not encode a replacement as a set at the root path. Base-batch detection is a +**correctness boundary** — recovery stops there (§9) — so it must be a token +comparison, `ops[0]?.[0] === "r"`, and not a path inspection. A path-inspecting +predicate has to handle the two-element arity form, where `op[1]` is a value +rather than a path, and misclassifies `["s", []]`. + +**Only `p` may target the root**, and only because a tracked value can itself be +an array — `entries.push(x)` on a root array is `["p", [], 3, 0, [x]]`. A `p` +covering its entire target is normalised at record time to `r` (root) or `s` +(nested), so a root `p` is always a partial modification. The other four verbs +take a `NonEmptyPath`: `["s", [], v]` and `["d", []]` do not typecheck. + +**Do not add a keyed in-memory shape with a codec at the serialization boundary.** +Two representations of one thing means two appliers, two size estimates, and a +conversion nobody needs. Ops are tuples in memory, on the wire, and on disk; +readability is what a debug formatter is for. + +Five verbs. No `move`, `copy` or `test`: the first two are size optimisations for +a case we do not have, and `test` belongs to a conflict model we do not have, +since there is exactly one authoritative writer. + +`chars` counts UTF-16 code units, matching `String.prototype.slice`. **Not bytes.** +Byte caps are a producer concern and never cross a boundary. + +The format is JSON-Patch-*shaped*, not RFC 6902 conformant. That costs nothing: +RFC 6902 has had no successor since 2013, still has the same six ops, and nothing +anywhere adds a string splice. Immer was never conformant either. + +## 3. The tracker + +State is **plain TypeScript**. No containers, no handles, no schema, no +decorators. You mutate normally. + +```ts +const t = track(laneView); +t.state.operation.streaming.content[0].text += delta; // -> append +t.state.transcript.push(entry); // -> splice +t.state.tools[0].details.failures.push({ name, msg }); // -> splice +delete t.state.config.model; // -> delete +const ops = t.flush(); +``` + +Three mechanisms, one per case: + +**Objects** — ordinary `set` / `deleteProperty` traps. + +**Arrays — mutator methods intercepted in the `get` trap.** `arr.push(x)` passes +through `get(arr, "push")` first, so the tracker returns its own function that +records `splice(len, 0, [x])` and then delegates. Intent is captured *before* the +engine performs its index writes, which is why `unshift` is one op rather than +O(n). This is the thing no other library does. + +**Strings — overlap detection on the `set` trap.** Strings are primitives, so +there is nothing to intercept. Given `prev` and `next`, find the longest suffix of +`prev` that is a prefix of `next`: + +``` +overlap === prev.length -> append(next.slice(overlap)) +overlap > 0 -> truncate(prev.length - overlap) + append(rest) +overlap === 0 -> set(next) +``` + +Always correct, because the overlap is **verified**, not guessed. The rolling +window works: + +```ts +s.out = s.out.slice(4) + "!!!"; +// ["truncate", ["out"], 4] +// ["append", ["out"], "!!!"] +``` + +### 3.1 Overlap must not use a hand-written KMP + +A KMP failure function is asymptotically right and **47x slower in practice**, +because it runs character by character in JS. Measured on 2000 window slides over +a 50 KB string: 2870 ms for KMP, 61 ms for a native probe. + +The probe: `indexOf` a short head of `next` within `prev`, verify each candidate +with `endsWith`. Both are native. Verified identical to KMP across 200 window +slides. + +A `startsWith` fast path runs first, so a pure append — the dominant case — skips +the probe entirely. + +### 3.2 Rules the implementation must hold + +**Op payloads are structured-cloned at record time.** Without this a later +mutation retroactively changes an already-emitted op, and a local replay aliases +the source object. This was an observed bug in the prototype, not a hypothetical: +producer and replica ended up sharing an object and double-appending. + +**`x = undefined` normalises to `delete`.** JSON has no `undefined`, and a `set` +with a missing value is indistinguishable from a lost value after a +`JSON.stringify` round trip. + +**`arr.length = n` is translated, never emitted as a path.** Shrink becomes a +splice, grow becomes a splice of nulls. `length` is a real mutation but not a +document location — this is the bug Immer ships (issue 208). Dropping it silently, +as an early prototype did, means `arr.length = 0` never reaches the replica. + +**Numeric array keys are normalised to numbers.** Proxy traps deliver `"0"`; +paths carry `0`. Valtio ships the string form and it is worse for both size and +comparison. + +**Proxies are cached per object and path**, so `s.a === s.a` and proxies are not +rebuilt on every access. + +**Scope is `JsonValue` only, and it must be CHECKED, not assumed.** +`structuredClone` is not a JSON check — it clones `Map`, `Set`, `Date` and +`RegExp` happily, and the result then differs from what `JSON.stringify` produces, +so producer and replica diverge silently. Assert the value at record time and +reject symbol keys. See §7.4 for what leaks without it. + +**Exactly two ops reach the root**, and the applier's root branch must handle +both: `r` always, and `p` when the tracked value is itself an array. +`s`/`d`/`a`/`t` cannot, by type, which makes the branch's fallthrough provably +unreachable. Handling only `r` there passes every hand-written test and fails on +the first randomised sequence that produces a root splice. + +**Two writes to one address inside a transaction chain.** When several writes to +the same address are emitted in one commit, each must be recorded against the +previous one's result, not against pre-transaction state. The applier replays them +in order; anything else applies the second against a stale base. This did not fire +in the workload that surfaced it — it would have corrupted silently when it did. + +### 3.2.1 Replacing the whole value + +Assigning to `state` replaces it: + +```ts +tracker.state = next; // emits ["r", next]; discards ops recorded before it +``` + +`state` **must be a setter on the tracker**, not a plain property. Without one, +`tracker.state = next` swaps the proxy for a plain object and every later +mutation is silently untracked — no error, no ops, `target` unchanged. It is also +the first thing anyone tries. + +Prior ops are discarded because they describe a value that no longer exists. + +Only a *full* replacement collapses to `r`. Rewrite part of the value and the ops +survive, because they are genuinely cheaper: + +``` +partial rewrite 2 op(s), base=false + ["s",["user"],{"id":"u2","name":"bob"}] + ["s",["items"],["x"]] +``` + +### 3.2.2 The first flush is a base batch + +`track()` opens a stream, and a consumer starts with nothing, so the first flush +always emits `["r", value]` — carrying any mutations made before it: + +```ts +const t = track({ x: 0, l: [] }); +t.state.x = 100; +t.state.l.push("xyz"); +t.flush(); // [["r", { x: 100, l: ["xyz"] }]] +t.state.x = 101; +t.flush(); // [["s", ["x"], 101]] +``` + +Requiring the producer to remember a `rebase()` first would fail at runtime, in +the consumer, far from the mistake. + +### 3.2.3 Forcing a base batch later + +```ts +tracker.rebase(); // next flush is ["r", value]; value unchanged +``` + +Discarding the pending ops is correct: the proxy mutates the target directly, so +the value already carries them. `tracker.state = tracker.target` has the same +effect, but re-wraps the proxy needlessly and reads like a mistake. + +**Nothing produces a base batch on its own.** `flush()` emits ops; a replacement +happens only when the producer asks for one. So a stream of appends stays a +stream of deltas indefinitely. + +Recovery replays from the last base batch (§9). From `delta.examples.ts`, a +bash-shaped workload of 500 durable writes into a 50 KB rolling window: + +| | batches written | to replay on recovery | +| --- | --- | --- | +| never | 500 | **499** | +| `rebase()` every 50 | 500 | 0 | + +Two callers need this: + +- **A durable sink.** `pendingToolOutput` accumulates one batch per checkpoint for + as long as a command runs. A `make -j8` running for ten minutes otherwise leaves + hundreds of batches to fold on resume. Checkpointing every N makes "at most N + batches to replay" a policy rather than an accident. +- **A facet host on resubscribe.** `facets.md` §9.2 requires the first batch of + every subscription to be a base batch, and a consumer that sees a gap + resubscribes to get one. The host has to be able to produce one on demand rather + than wait for one to happen by chance. + +### 3.3 Known gaps + +- `sort` / `reverse` / `fill` / `copyWithin` fall back to a whole-array `set`. + Rare on replicated state; revisit if a real workload needs them. +- A manual index-shift loop (`for (…) a[i] = a[i+1]`) costs O(n) sets. Correct, + not minimal, and unavoidable — you genuinely did write every element. +- Cross-realm identity. The interning table in §7 keys by id within one process; + a replica in another process or language needs the same table built from the + wire, which is not specified here. + +### 3.4 Constraints the string algorithm must hold + +Each of these is cheap to get wrong in a way that passes hand-written tests. + +**A fixed-length probe cannot find an overlap shorter than the probe.** The head +must actually occur in `a`: `"abcdefgh"` -> `"defghxyz"` overlaps by 5, and a +64-character head cannot occur in an 8-character string. Try a long head first, +then fall back to a one-character head. A test using only 50 KB strings, where +overlaps are huge, will pass against a broken implementation. + +**Bound the candidate scan.** Repetitive output — a build log, or a run of one +character — makes a long head match at thousands of positions, each costing a full +`endsWith`. Unbounded, 2000 slides over a 50 KB window take 4.7 s against 93 ms +bounded. Exceeding the bound returns 0, which emits a set: larger, never wrong. + +**Property tests before anything depends on this.** 3000 random mutation +round-trips find the root-splice hole in §3.2 on the first run; the hand-written +cases do not. `delta.test.ts` is the suite to port. + +## 4. The codec + +Two vocabularies, not one. + +`Op` is what the tracker produces and `apply` consumes. **Paths are always +inline.** It knows nothing about a dictionary. + +`WireOp` is what crosses a boundary. It adds exactly two compressions: + +``` +["#", id, path] defines an id, emitted on a path's SECOND use +a numeric PathRef references a previously defined id +a shortened tuple reuses the previous op's path; arity disambiguates +``` + +`encode(ops): WireOp[]` and `decode(wire): Op[]` are the only places either +exists. Keeping them out of `Op` means `apply` has no id resolution, no `#` +case, and no previous-path state — three branches removed from the hot path — +and the dead-op pass gets a total `opPath`, where before it silently skipped +interned refs. + +`["r", value]` carries no path, so it encodes to itself. That is why `isBase` +works unchanged on either vocabulary. + +### 4.1 Two rules that are easy to get wrong + +**One encoder/decoder pair per stream.** The id table spans a whole subscription +or file: a path interned in batch 3 is referenced in batch 40. A second consumer +that subscribes at batch 40 has never seen the definition, so it needs its own +encoder. Sharing one across consumers hands the late subscriber ids it cannot +resolve, and a base batch does not rescue it — `["r", value]` carries no refs and +leaves the table empty. + +**Reset the table on a base batch.** A reader replays from the *last* base batch +with a fresh decoder, so everything after one must be self-contained. Carrying +ids across a replacement emits references to definitions the reader never saw: + +``` + 4 : [["a",0,"x4"],["a",1,"y4"]] + 5 BASE: [["r",{…}]] + 6 : [["a",0,"x6"]] <- id 0 was defined in batch 1 + -> RECOVERY FAILED: PathError - unresolvable path: 0 +``` + +**Arity omission is scoped to a batch.** Letting it span batches makes a batch's +first op depend on the previous batch's last one, so a reader that skips or +reorders a batch decodes into the wrong path. Ids are the only cross-batch state, +and the dictionary makes those explicit. + +### 4.2 Intern on second use, not first + +A definition costs more than the path it replaces, so interning on first use +loses on every path written exactly once — and most are. Measured on a bulk +workload: **255.5 KB first-use versus 179.6 KB with no interning at all.** + +### 4.3 Measured + +Wire bytes against inline paths, round-trip verified: + +| stream | inline | wire | saved | +| --- | --- | --- | --- | +| one hot path (a rolling tool-output window) | 11,290 B | 7,538 B | **33.2%** | +| four alternating paths (lane state) | 14,160 B | 11,032 B | **22.1%** | +| 200 distinct paths | 16,455 B | 16,035 B | 2.6% | + +Omission does the work in the first case, interning in the second. In the third +neither helps and the definitions cost a little — which is the case second-use +interning exists to bound. + +## 5. Flush emits ops, and drops the dead ones + +`flush()` returns the recorded ops. There is no size comparison and no +replacement heuristic: a replacement is something the producer asks for, by +assigning `state` or calling `rebase()`. + +An earlier design compared op bytes against the value's serialised size and +replaced when ops were larger. It was removed. Measured against emitting ops +unconditionally, on six workloads, it changed the output in two — both requiring +hundreds of distinct paths to change in a single flush. Neither the harness nor +the replication path does that: `LaneSnapshot` is folded one event at a time, and +the widest case (`run_end`) touches four fields. The rule cost a per-flush +comparison, a running size estimate, and an invalidation rule for the ops that +could not maintain it. + +### 5.1 Dead-op elimination + +Ops apply in order, so an op is dead if a later one overwrites the whole subtree +it lives in. Only `s`, `d` and `r` do that — `a`, `t` and `p` modify what is +already there, so they never dominate. + +| written | emitted | +| --- | --- | +| same field three times | one `s` | +| `x`, then `y`, then `x` again | two ops, first `x` dropped | +| child, then parent replaced | one `s` on the parent | +| set, then delete | one `d` | +| parent replaced, then child | **both** — the child write survives | + +Walk backwards keeping a map of overwritten paths and test each op's own +prefixes against it: **O(n · d)** for n ops at path depth d. Comparing each op +against every dominator instead is O(n² · d), which degrades on precisely the +wide flush this pass exists to clean up. + +Measured: 12–14 µs per op, flat from 100 to 5000 ops, depth 8 barely costlier +than depth 3. A pathological producer writing the same two fields 5000 times +emits **2 ops from 10 001 recorded**. + +> **Object key order is not preserved.** A producer that does +> `set c; set a; delete c; set c` leaves `c` last, because deleting and +> reinserting moves it. The replica receives only the surviving `set c`, applies +> it to a base where `c` never existed, and `c` lands first. +> +> Values are identical — 1823 randomised sequences, zero value mismatches — but +> the serialised forms can differ. This is acceptable because nothing compares a +> replica against its producer: the consumer holds the state and nobody diffs it +> back. The one visible case is a consumer that iterates keys for display, where +> two views could order rows differently, and it needs a delete-and-recreate of +> the same key within one flush. **Do not hash or content-address a replicated +> value.** + +Adjacent ops on the same path are then merged: `a` + `a` becomes one append, +`s` + `s` keeps the later, `s` + `a` folds into the set. Non-adjacent append +merging is a future optimisation, not done. + +## 6. There is no frame type + +What travels is `Op[]`. Do not wrap it. + +**`seq` does not belong in the payload.** It would defend against a lossy +transport we do not have: a durable list element already carries `seq` from +storage, an in-process callback cannot skip, and SSE either delivers in order or +breaks — and a break means resubscribe, which means a base batch. A second copy of +storage's `seq` could only disagree with the first. The SSE binding stamps `id:`, +which is where transport metadata belongs. + +**Nothing else needs wrapping either.** A `kind` discriminator is redundant once a +replacement is an op (§2), and the address already says which value a batch +belongs to. What would remain is a struct around an array. + +So a batch of ops is just `Op[]`. A **base batch** is one whose first op is `r`; +`isBase(ops)` is `ops[0]?.[0] === "r"`, exact rather than heuristic because flush +guarantees `r` appears at index 0 or not at all (§5). + +Everything a frame used to do is now done by something that already existed: + +| was on the frame | now | +| --- | --- | +| `seq` | the list element's `seq`, or the SSE `id:` | +| `kind: "replace"` | the `r` op | +| which value it belongs to | the address | +| "this is a snapshot" | the `"base"` storage tag | + +**Resubscription is a base batch plus buffered batches** — the same thing the lane +adapter already does for the harness: snapshot first, then whatever accumulated +while the client caught up. There is no `Last-Event-ID` and no cross-subscription +resume; that would need a retained op log, which §5 deliberately does not keep. + +A consumer that sees a gap, fails to resolve a path, or connects cold takes one +path: ask for a base batch. That is why there is no `Rebase` type. + +The cost, stated plainly: **a consumer cannot detect staleness from the payload +alone.** It relies on the transport reporting a break. For SSE and in-process that +is sound. If a lossy or multiplexed transport ever appears, sequencing goes on that +transport's envelope — not back into the ops. + +## 7. Safety + +Ops arrive from a facet, a plugin compartment, or a tool whose details may echo +model output. **None of it is trusted input**, and the boundary where untrusted +data meets trusted machinery is where this design has to hold. + +The reference point is CVE-2025-55182 — RCE in React Server Components, CVSS 10.0, +exploited in the wild. Their Flight protocol is a compact tagged wire format that +reconstructs structure, so the resemblance is real. The failure was *"fails to +validate the structure correctly... treats the fake object as genuine"*: a forged +Chunk resolved as a Promise and exposed internal state containing gadgets to reach +`Function`. + +**We are structurally safer, and not because of diligence:** + +| | Flight | ops | +| --- | --- | --- | +| can describe runtime objects | yes — Chunks resolve as Promises | no | +| can reference code or modules | yes — client components | no | +| values | arbitrary object graphs | `JsonValue` | +| worst case from a forged payload | RCE | corrupted replica state | + +Flight *must* reference code; that is its job. An op can only put a `JsonValue` at +a path, so there is no first link in a gadget chain. This is why an attacker cannot +plant a non-resolving `then` on `Object.prototype`: a data-only `then` is not +callable, and `await` resolves normally when `then` is not callable. + +> **Rule: no op may name something the host resolves.** Not a mutation name, not a +> component id, not a module reference. Mutation names were removed for an +> unrelated reason (§8); this is the second and better reason never to reintroduce +> them, because it is the ingredient that made Flight exploitable. + +### 7.1 Paths are the dangerous part + +`JSON.parse` is safe on its own — `{"__proto__":{}}` becomes an *own* property. +The hazard is `parent[key]`, which is exactly what applying a path does. + +A single key is not enough. `x["__proto__"] = v` swaps *x's own* parent, which is +local. It takes a **walk** — `x["__proto__"]["polluted"] = v` — to reach the shared +prototype, and a path is precisely a walk. + +`constructor` is worse than `__proto__`, because `({}).constructor.constructor` is +`Function`. That ladder is closed here only because op values cannot be functions; +it is closed properly by rejecting the segment. + +**Reserved path segments: `__proto__`, `constructor`, `prototype`.** Rejected at +record time *and* at apply time, including through an interned path id. + +They are reserved as *segments*, not as values. An object with a literal +`"__proto__"` key replicates fine as a whole value; only a path walking *through* +it is refused. This is a genuine restriction on what is mutable — document it, do +not pretend it away. + +Two further measures on the same hazard: + +- **Write with `Object.defineProperty`**, never assignment, so an inherited setter + cannot run. +- **Resolve own properties only** (`Object.hasOwn`), so a walk cannot escape into + the prototype chain and an inherited getter cannot fire. This blocks + `constructor` a second time, since it is inherited. + +What prototype pollution actually buys an attacker here is narrower than it first +appears: **it flips defaults, it does not overwrite explicit values.** An own +property shadows. So `{name:"bob", isAdmin:false}` is unaffected; an option bag read +as `opts.skipSandbox` is not. Our own `ShellExecOptions`, `CaptureOptions`, +`ListReadOptions` and `TrackerOptions` are exactly that shape. Note also that +`"x" in {}` and `const {x = false} = {}` both lie under pollution — which is why +the applier uses `Object.hasOwn`. + +### 7.2 Array indices + +An index may address an existing element or append exactly one past the end. + +This is not an arbitrary cap; it is what keeps the value a `JsonValue`. A sparse +array does not survive a JSON round trip — holes serialise to `null` and return as +real properties — so `arr[7] = x` on a length-3 array already produces state a +replica cannot match. Rejecting the write is more honest than diverging. + +It removes a denial of service as a side effect rather than as its purpose: +`["s",["xs",4294967290],1]` would otherwise allocate a 4.29-billion-entry array +from one op. Growth stays available and stays proportional, because +`arr.length = n` is emitted as a splice of explicit nulls whose op size grows with +the gap. + +### 7.3 Validate op structure on decode + +This is the RSC lesson applied to us. A decoder must not trust tuple shape. +Measured against an unvalidated applier: + +| malformed op | result | +| --- | --- | +| `["p",["xs"],0,0,"not-an-array"]` | string spread into the array: `["n","o","t",…]` | +| `["s","a",9]` — path is a string | accepted; `"a".slice(0,-1)` is `""`, so it wrote at the root | +| `["ZZZ",["a"],9]` | silently ignored, replica diverges with no error | + +Validate: verb is known, arity matches the verb, a path is an array of strings and +non-negative integers, `p` carries integer index and count plus an array of items, +`#` defines an array path. An unknown verb is an **error**, not a no-op — silently +skipping it is how a newer producer's op disappears and a replica drifts. + +**One validator per vocabulary.** `assertValidOp` guards `apply` and rejects ids, +short forms and `#`; `assertValidWireOp` guards `decode` and permits them. +Validating a decoded op against the wire grammar is laxer than its own type: a +two-element `["s", value]` would pass, and `apply` would then read the value as a +path. + +### 7.4 The tracker's cage leaks on types, not shape + +A facet cannot forge an op: it mutates plain objects and the tracker builds the +tuples, so shape is well-formed by construction. Values and keys are another +matter, and `structuredClone` is **not** a JSON check. + +| what a facet writes | what happens | +| --- | --- | +| a function | throws (`DataCloneError`) | +| a BigInt, a cycle | throws | +| `new Map([[1,2]])` | op carries `{}`, producer keeps a real Map — **silent divergence** | +| `new Date(0)` | op carries an ISO string, producer keeps a Date | +| `state[Symbol("s")] = 1` | emits `["s",[null],1]` — **a malformed op from our own tracker** | + +So the tracker must check what it is given, at record time, where the mistake is: + +- **Reject symbol keys.** They are not path segments. +- **Assert `JsonValue` on every recorded value.** Reject `Map`, `Set`, `Date`, + `RegExp`, typed arrays and class instances explicitly. The scope restriction in + §3.2 was a comment; it has to be a check. + +A getter on state is safe — the trap records the computed result — and a facet +value that *looks* like an op is nested inside `["s", path, value]` and can never +be read as a top-level op, because nothing flattens. + +### 7.5 Applier + +```ts +export function apply(target: T, ops: readonly Op[]): T; +``` + +Six verbs, no domain knowledge, no library, no tool code, no registry lookup, and +**no path table** — ids and omitted paths are resolved by `decode` before `apply` +sees anything (§4). + +It returns the value rather than mutating in place, because `r` replaces it +outright. +Runs against a **plain mutable object the consumer owns**. It must not be pointed +at a value produced by Immer, which deep-freezes and would throw. + +A page of code in any language, which is the property that keeps non-JS consumers +viable. Colyseus ships decoders for C#, Lua and Haxe on the same basis. + +## 8. What this removes from the codebase + +Concrete deletions, not simplifications in principle: + +- **Immer**, from the harness and from the facet layer. See §1 for why a + patch-producing library cannot serve this purpose. +- **Per-type reducers.** There is one applier, `apply(target, ops)`, with no + domain knowledge and no registry. Nobody writes a fold for `ToolOutputState` or + for a plugin's shape. +- **`detailMutations` and `initialDetails`**, and with them the argument that + `details: unknown` forces special handling. A structural tracker never needs + the type. +- **`Rebase` as a reducer return value.** A fold that cannot apply leaves state + alone and the host sends a base batch. +- **Mutation names on the wire**, and therefore mutation-name version skew. Names + never cross a boundary, so adding or renaming one is not a breaking change. + +And three things not to build, each of which looks reasonable until §1: + +- **A differ.** Nothing here diffs two values; every path records intent. +- **A keyed op shape plus a codec.** Tuples are the form everywhere (§2). +- **A frame wrapper.** What travels is `Op[]` (§6). + +## 9. Durable form + +A tracked value is stored as a **list of frames**, one appended per flush. Base +frames carry the storage tag `"base"`. + +Recovery reads backwards to the last base batch and applies forward: + +```ts +readList(address, { order: "desc", stopAtTag: "base", limit: 100 }) +``` + +`stopAtTag` is a stop condition *within a page*: if no base batch is in the page, +the consumer pages again with the cursor. The tag lives on the storage record +beside `seq`, never inside the value, so storage never parses ops. See +`session-scopes.md` §11. + +This is what makes "a replacement truncates recovery" (§5) real rather than +aspirational — a reader stops at the last base batch instead of replaying from +the beginning. + +## 10. Open questions + +- Prefix interning (a trie over path heads) if a workload emerges with many + distinct paths sharing long prefixes. Second-use interning (§4) removes the + pathological case; this would go further. +- Non-adjacent append merging in `coalesce`. Dead-op elimination (§5.1) handles + redundancy; this would handle interleaving. +- Whether `sort` / `reverse` deserve real ops. +- Cross-language replicas: the applier is a page of code in any language, but the + interning tables and the wire framing are not specified for a non-JS consumer. + +Address interning in the JSONL log is the same trick as path interning, applied +one layer up over `namespace` + `key`. It is **not** an open question — it is +specified in `session-scopes.md` §12 and is a separate dictionary from this one. diff --git a/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.test.ts b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.test.ts new file mode 100644 index 000000000..a0e09c1cb --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.test.ts @@ -0,0 +1,563 @@ +import { describe, expect, it } from "vitest"; +import { apply, assertJsonValue, assertValidOp, assertValidWireOp, decoder, encoder, isBase, overlap, track, type Op, type WireOp } from "./delta-impl.ts"; + +const PAD = "p".repeat(400); // see "adaptive emission": a delta must be able to win + +describe("tracker: intent", () => { + it("records an append, not a replacement", () => { + const t = track({ s: "", pad: PAD }); + t.flush(); // drain the base batch + t.state.s += "ab"; + t.state.s += "cd"; + const ops = t.flush(); + expect(ops).toEqual([["a", ["s"], "abcd"]]); + expect(apply({ s: "", pad: PAD }, ops)).toEqual({ s: "abcd", pad: PAD }); + }); + + it("recovers truncate+append from a rolling window", () => { + // The case every effect-recording library degrades to a whole-value set. + const t = track({ s: "abcdefgh", pad: PAD }); + t.flush(); // drain the base batch + t.state.s = t.state.s.slice(3) + "xyz"; + const ops = t.flush(); + expect(ops).toEqual([["t", ["s"], 3], ["a", ["s"], "xyz"]]); + expect(apply({ s: "abcdefgh", pad: PAD }, ops)).toEqual({ s: "defghxyz", pad: PAD }); + }); + + it("records array intent as one splice", () => { + const t = track({ xs: [1, 2], pad: PAD }); + t.flush(); // drain the base batch + t.state.xs.push(3); + expect(t.flush()).toEqual([["p", ["xs"], 2, 0, [3]]]); + }); + + it("normalises undefined to a delete", () => { + const t = track({ a: 1, pad: PAD }); + t.flush(); // drain the base batch + (t.state as Record).a = undefined; + expect(t.flush()).toEqual([["d", ["a"]]]); + }); +}); + +describe("tracker: root ops", () => { + it("splices a value that is itself an array", () => { + const t = track([1, 2, 3, PAD]); + t.flush(); // drain the base batch + t.state.push(4); + const ops = t.flush(); + expect(ops).toEqual([["p", [], 4, 0, [4]]]); + expect(apply([1, 2, 3, PAD], ops)).toEqual([1, 2, 3, PAD, 4]); + }); + + it("normalises a splice covering the whole root to a replacement", () => { + const t = track([1, 2, 3]); + t.flush(); // drain the base batch + t.state.splice(0, 3, 9); + const ops = t.flush(); + expect(ops).toEqual([["r", [9]]]); + expect(isBase(ops)).toBe(true); + }); + + it("normalises a nested splice-all to a set, not a replacement", () => { + const t = track({ xs: [1, 2, 3], pad: PAD }); + t.flush(); // drain the base batch + t.state.xs.splice(0, 3, 9); + expect(t.flush()).toEqual([["s", ["xs"], [9]]]); + }); + + it("normalises length = 0 on the root", () => { + const t = track([1, 2, 3]); + t.flush(); // drain the base batch + t.state.length = 0; + const ops = t.flush(); + expect(ops).toEqual([["r", []]]); + expect(apply([1, 2, 3], ops)).toEqual([]); + }); +}); + +describe("replacing the whole value", () => { + // The proxy cannot observe `tracker.state = next`: `state` belongs to the + // tracker, not to the tracked object, so assigning to it swaps the proxy for a + // plain object and silently stops tracking. Replacement must be a method. + it("assigning to state emits a base batch and keeps tracking", () => { + const t = track>({ p: 1, q: PAD }); + t.flush(); // drain the base batch + t.state.p = 2; + t.state = { r: 9, s: "new" }; + const ops = t.flush(); + expect(ops).toEqual([["r", { r: 9, s: "new" }]]); + expect(isBase(ops)).toBe(true); + expect(apply({}, ops)).toEqual(t.target); + + t.state.r = 10; // the new value is tracked + expect(t.flush()).toEqual([["s", ["r"], 10]]); + }); + + it("assigning to state discards ops recorded before it", () => { + // They describe a value that no longer exists. + const t = track>({ p: 1, q: PAD }); + t.flush(); // drain the base batch + t.state.p = 2; + t.state = { z: 1 }; + expect(t.flush()).toEqual([["r", { z: 1 }]]); + }); + + it("a partial rewrite keeps its ops", () => { + const t = track>({ a: "x".repeat(200), b: "y".repeat(200) }); + t.flush(); // drain the base batch + t.state.a = "p"; + const ops = t.flush(); + expect(isBase(ops)).toBe(false); + expect(ops).toEqual([["s", ["a"], "p"]]); + }); + + it("rebase() forces a base batch without changing the value", () => { + // This is the checkpoint. Recovery replays from the last base batch, so a + // producer must be able to bound that rather than depend on the adaptive + // size rule, which compares each flush independently. + const t = track>({ p: 1, pad: PAD }); + t.flush(); // drain the base batch + t.rebase(); + const ops = t.flush(); + expect(isBase(ops)).toBe(true); + expect(apply({}, ops)).toEqual(t.target); + }); + + it("rebase() applies once, not to every later flush", () => { + const t = track>({ p: 1, pad: PAD }); + t.flush(); // drain the base batch + t.rebase(); + t.flush(); + t.state.p = 2; + expect(t.flush()).toEqual([["s", ["p"], 2]]); + }); +}); + +describe("the first flush", () => { + it("is always a base batch", () => { + // A consumer starts with nothing, so a stream opening with deltas has + // nothing to apply them to. Requiring the producer to remember would fail + // at runtime, in the consumer, rather than where the mistake is. + const t = track>({ x: 0 }); + t.state.x = 100; + const ops = t.flush(); + expect(isBase(ops)).toBe(true); + expect(ops).toEqual([["r", { x: 100 }]]); // carries mutations made before it + }); + + it("is followed by deltas", () => { + const t = track>({ x: 0, pad: PAD }); + t.flush(); + t.state.x = 1; + expect(t.flush()).toEqual([["s", ["x"], 1]]); + }); + + it("emits nothing when untouched", () => { + // An unmutated tracker has nothing to say; the base batch is owed to a + // consumer, and there is no consumer until someone flushes. + const t = track>({ x: 0 }); + expect(t.flush()).toEqual([["r", { x: 0 }]]); + expect(t.flush()).toEqual([]); + }); +}); + +describe("apply and fan-out", () => { + it("adopts an `r` payload rather than copying it", () => { + // The consumer owns the batch it was handed. Copying every base batch + // defensively doubles the memory of the one op carrying the whole value. + // + // The rule that follows: do not hand one batch to two in-process + // consumers. This test pins the aliasing so nobody "fixes" it by adding a + // clone back. + const t = track>({ n: 0 }); + const batch = t.flush(); + const a = apply(undefined, batch) as Record; + const b = apply(undefined, batch) as Record; + a.n = 1; + expect(b.n).toBe(1); + }); + + it("does not alias the producer", () => { + const t = track>({ x: 0 }); + const replica = apply(undefined, t.flush()) as Record; + t.state.x = 999; + t.flush(); + expect(replica.x).toBe(0); + }); + + it("folds a whole stream without a base-batch branch", () => { + // `apply` handles `r` by replacing, and tolerates an undefined target, + // so a consumer needs no isBase check and no clone of its own. + const t = track>({ x: 0, l: [] as string[] }); + const enc = encoder(); + const dec = decoder(); + let replica: unknown; + const send = () => { replica = apply(replica, dec.decode(enc.encode(t.flush()))); }; + + t.state.x = 100; + t.state.l.push("xyz"); + send(); + t.state.x = 101; + send(); + expect(replica).toEqual(t.target); + }); +}); + +describe("dead-op elimination", () => { + // Ops apply in order, so an op is dead if a later one overwrites the whole + // subtree it lives in. Only s/d/r dominate; a/t/p modify what is there. + const run = (mutate: (s: any) => void, init: any = { a: { b: 1 }, x: 1, y: 2 }) => { + const t = track(structuredClone(init)); + t.flush(); // drain the base batch + mutate(t.state); + return t.flush(); + }; + + it("collapses repeated writes to one field", () => { + expect(run((s) => { s.x = 1; s.x = 2; s.x = 3; })).toEqual([["s", ["x"], 3]]); + }); + + it("drops a write superseded by a later, non-adjacent one", () => { + expect(run((s) => { s.x = 10; s.y = 20; s.x = 30; })) + .toEqual([["s", ["y"], 20], ["s", ["x"], 30]]); + }); + + it("drops a child write when the parent is replaced after it", () => { + expect(run((s) => { s.a.b = 99; s.a = { c: 5 }; })).toEqual([["s", ["a"], { c: 5 }]]); + }); + + it("keeps a child write that follows the parent replacement", () => { + expect(run((s) => { s.a = { b: 1 }; s.a.b = 7; })) + .toEqual([["s", ["a"], { b: 1 }], ["s", ["a", "b"], 7]]); + }); + + it("collapses set-then-delete to the delete", () => { + expect(run((s) => { s.x = 5; delete s.x; })).toEqual([["d", ["x"]]]); + }); + + it("keeps delete-then-set, to preserve key position", () => { + // Dropping the delete would leave the key in its original slot while the + // producer moved it to the end. Same value, different serialisation. + expect(run((s) => { delete s.x; s.x = 5; })).toEqual([["d", ["x"]], ["s", ["x"], 5]]); + }); + + it("is linear in the number of ops", () => { + // The naive formulation compares every op against every dominator, which + // is quadratic and degrades on exactly the wide flush this pass cleans up. + const wide = (n: number) => { + const root: Record = {}; + for (let i = 0; i < n; i++) root[`f${i}`] = i; + const t = track(root); + t.flush(); // drain the base batch + const started = performance.now(); + for (let i = 0; i < n; i++) t.state[`f${i}`] = i + 1; + t.flush(); + return performance.now() - started; + }; + wide(200); // warm + const small = Math.max(wide(250), 0.1); + const large = wide(2500); + expect(large / small).toBeLessThan(40); // linear would be ~10x + }); + + it("collapses a pathological redundant producer", () => { + const t = track({ a: { b: 1 }, x: 1 }); + t.flush(); // drain the base batch + for (let i = 0; i < 2000; i++) { t.state.x = i; t.state.a.b = i; } + t.state.a = { done: true }; + expect(t.flush()).toHaveLength(2); + }); +}); + +describe("flush", () => { + it("drops everything before a replacement", () => { + const t = track([1, 2]); + t.flush(); // drain the base batch + t.state.push(3); + t.state.splice(0, 3, 7); + expect(t.flush()).toEqual([["r", [7]]]); + }); + + it("keeps the prefix when the replacement is nested", () => { + // `s` on a subtree is not a root replacement, so earlier ops stay live. + const t = track({ a: 1, xs: [1, 2, 3], pad: PAD }); + t.flush(); // drain the base batch + t.state.a = 2; + t.state.xs.splice(0, 3, 9); + expect(t.flush()).toEqual([["s", ["a"], 2], ["s", ["xs"], [9]]]); + }); + + it("rejects a constructor walk", () => { + // ({}).constructor.constructor is Function — the classic escape ladder. + expect(() => apply({}, JSON.parse('[["s",["constructor","prototype","gadget"],true]]'))).toThrow(); + expect(({} as Record).gadget).toBeUndefined(); + }); + + it("rejects a forbidden path reached through an interned id", () => { + const dict = new Map([[0, ["__proto__", "w"]]]); + expect(() => apply({}, [["s", 0, true]], (id) => dict.get(id)!)).toThrow(); + }); + + it("does not run an inherited setter", () => { + Object.defineProperty(Object.prototype, "trap", { + get() { throw new Error("inherited getter ran"); }, + configurable: true, + }); + try { + expect(() => apply({}, [["s", ["trap"], 1]])).not.toThrow(); + } finally { + delete (Object.prototype as Record).trap; + } + }); + + it("allows a reserved name as a VALUE key", () => { + // Reserved as segments, not as values. + const out = apply({}, [["s", ["a"], JSON.parse('{"__proto__":{"z":1}}')]]); + expect(({} as Record).z).toBeUndefined(); + expect(out).toBeDefined(); + }); +}); + +describe("safety: array indices", () => { + // An index may address an existing element or append exactly one past the end. + // Not an arbitrary cap: a sparse array does not survive a JSON round trip, so + // arr[7] = x on a length-3 array already produces unreplicable state. + it("writes an existing index", () => { + expect(apply({ xs: [1, 2, 3] }, [["s", ["xs", 1], 9]])).toEqual({ xs: [1, 9, 3] }); + }); + it("appends one past the end", () => { + expect(apply({ xs: [1, 2, 3] }, [["s", ["xs", 3], 9]])).toEqual({ xs: [1, 2, 3, 9] }); + }); + it("rejects a gap", () => { + expect(() => apply({ xs: [1, 2, 3] }, [["s", ["xs", 5], 9]])).toThrow(); + }); + it("rejects a huge index", () => { + // Would otherwise allocate 4.29 billion entries from one op. + expect(() => apply({ xs: [] }, [["s", ["xs", 4_294_967_290], 1]])).toThrow(); + }); + it("allows explicit growth with nulls", () => { + expect(apply({ xs: [1] }, [["p", ["xs"], 1, 0, [null, null, 9]]])).toEqual({ xs: [1, null, null, 9] }); + }); +}); + +describe("safety: op structure", () => { + // The CVE-2025-55182 lesson: a decoder must not trust tuple shape. + it("rejects an unknown verb rather than skipping it", () => { + // Silently skipping is how a newer producer's op vanishes and a replica drifts. + expect(() => apply({ a: 1 }, [["ZZZ", ["a"], 9] as never])).toThrow(); + }); + it("rejects non-array splice items", () => { + // Unvalidated, this spreads a string: ["n","o","t","-","a",…] + expect(() => apply({ xs: [1] }, [["p", ["xs"], 0, 0, "not-an-array"] as never])).toThrow(); + }); + it("rejects a string path", () => { + // Unvalidated, "a".slice(0,-1) is "", so it resolved to the ROOT and wrote. + expect(() => apply({ a: 1 }, [["s", "a", 9] as never])).toThrow(); + }); + it("rejects a non-tuple op", () => { + expect(() => apply({ a: 1 }, [{ op: "s" } as never])).toThrow(); + expect(() => apply({ a: 1 }, [null as never])).toThrow(); + }); + it("clamps a splice remove past the end, as Array.prototype.splice does", () => { + // Deterministic and identical on both sides — not a hole. + expect(apply({ xs: [1, 2] }, [["p", ["xs"], 0, 1e9, []]])).toEqual({ xs: [] }); + }); +}); + +describe("safety: what a facet can put on the wire", () => { + // A facet cannot forge an op — it mutates plain objects and the tracker builds + // the tuples. Values and keys are the leak, and structuredClone is NOT a JSON + // check: it clones a Map happily, and the op then carries {} while the producer + // keeps a real Map. + const rejects = (name: string, mutate: (s: Record) => void) => + it(`rejects ${name}`, () => { + const t = track>({ pad: PAD }); + t.flush(); // drain the base batch + expect(() => mutate(t.state)).toThrow(); + }); + + rejects("a function", (s) => { s.f = () => {}; }); + rejects("a Map", (s) => { s.m = new Map([[1, 2]]); }); + rejects("a Date", (s) => { s.d = new Date(0); }); + rejects("a BigInt", (s) => { s.b = 1n; }); + rejects("a cycle", (s) => { const a: Record = {}; a.self = a; s.c = a; }); + rejects("a symbol key", (s) => { s[Symbol("k") as unknown as string] = 1; }); + + it("allows a getter on state, recording the computed result", () => { + const t = track>({ pad: PAD }); + t.flush(); // drain the base batch + Object.defineProperty(t.target, "g", { get: () => 42, enumerable: true }); + t.state.x = 1; + expect(t.flush()).toEqual([["s", ["x"], 1]]); + }); + + it("accepts a value that merely looks like an op", () => { + // Nested inside ["s", path, value]; nothing flattens, so it cannot be read + // as a top-level op. + const t = track>({ pad: PAD }); + t.flush(); // drain the base batch + t.state.x = ["r", { evil: true }]; + expect(t.flush()).toEqual([["s", ["x"], ["r", { evil: true }]]]); + }); +}); + +describe("assertions", () => { + it("assertJsonValue rejects non-JSON", () => { + for (const bad of [new Map(), new Set(), new Date(), /re/, () => {}, Number.NaN, Infinity]) { + expect(() => assertJsonValue(bad)).toThrow(); + } + for (const good of [null, true, 1, "s", [1, "a"], { a: { b: [null] } }]) { + expect(() => assertJsonValue(good)).not.toThrow(); + } + }); + it("assertValidOp accepts decoded ops and rejects wire forms", () => { + // Validating Op against the wire grammar would be laxer than the type: a + // two-element ["s", value] would pass, and apply would read the value as a + // path. Each vocabulary gets the validator that matches it. + for (const op of [ + ["r", { a: 1 }], ["s", ["a"], 1], ["d", ["a"]], + ["a", ["a"], "x"], ["t", ["a"], 2], ["p", ["a"], 0, 0, []], + ] as never[]) { + expect(() => assertValidOp(op)).not.toThrow(); + } + for (const wireOnly of [["s", 1], ["d"], ["a", "x"], ["t", 2], ["p", 0, 0, []], ["#", 0, ["a"]], ["s", 0, 1]] as never[]) { + expect(() => assertValidOp(wireOnly)).toThrow(); + expect(() => assertValidWireOp(wireOnly)).not.toThrow(); + } + }); +}); + +describe("codec: path interning and arity omission", () => { + // One pair per stream. The table spans a whole subscription or file, so a + // second consumer joining later needs its own encoder. + const roundTrip = (batches: Op[][]) => { + const enc = encoder(); + const dec = decoder(); + return batches.map((ops) => dec.decode(enc.encode(ops))); + }; + + it("round-trips a stream exactly", () => { + const t = track>({ a: { deep: "" }, b: { deep: "" } }); + t.flush(); // drain the base batch + const batches: Op[][] = []; + for (let i = 0; i < 6; i++) { + t.state.a.deep += `x${i}`; + t.state.b.deep += `y${i}`; + batches.push(t.flush()); + } + expect(roundTrip(batches)).toEqual(batches); + }); + + it("interns on second use, not first", () => { + const enc = encoder(); + const p = ["a", "deep"] as const; + const first = enc.encode([["a", p, "1"]]); + const second = enc.encode([["a", p, "2"]]); + expect(first).toEqual([["a", p, "1"]]); // inline + expect(second).toEqual([["#", 0, p], ["a", 0, "2"]]); // define, then use + }); + + it("omits the path when it repeats", () => { + const enc = encoder(); + const p = ["a"] as const; + expect(enc.encode([["s", p, 1], ["s", p, 2]])).toEqual([["s", p, 1], ["s", 2]]); + }); + + it("resets the table on a base batch, so recovery is self-contained", () => { + // A reader replays from the LAST base batch with a fresh decoder. Ids + // defined before it were never seen, so keeping them breaks recovery with + // an unresolvable path id. + const enc = encoder(); + const p = ["a", "deep"] as const; + enc.encode([["a", p, "1"]]); + enc.encode([["a", p, "2"]]); // id 0 assigned + const base = enc.encode([["r", { a: { deep: "x" } }]]); + const after = enc.encode([["a", p, "3"]]); + expect(base).toEqual([["r", { a: { deep: "x" } }]]); + expect(after).toEqual([["a", p, "3"]]); // inline again + + const dec = decoder(); + expect(() => dec.decode(base)).not.toThrow(); + expect(dec.decode(after)).toEqual([["a", p, "3"]]); + }); + + it("survives recovery from the last base batch", () => { + const enc = encoder(); + const t = track>({ a: { deep: "" }, b: { deep: "" } }); + t.flush(); // drain the base batch + const wire: WireOp[][] = []; + for (let i = 0; i < 8; i++) { + t.state.a.deep += `x${i}`; + t.state.b.deep += `y${i}`; + if (i === 5) t.rebase(); + wire.push(enc.encode(t.flush())); + } + const lastBase = wire.map(isBase).lastIndexOf(true); + const dec = decoder(); + let replica: unknown; + for (const w of wire.slice(lastBase)) { + const ops = dec.decode(w); + replica = replica === undefined + ? structuredClone((ops[0] as ["r", unknown])[1]) + : apply(replica, ops); + } + expect(replica).toEqual(t.target); + }); + + it("round-trips random streams", () => { + for (let round = 0; round < 300; round++) { + const t = track>({ a: { p: "", q: "" }, b: [] as unknown[], c: 0 }); + t.flush(); // drain the base batch + const batches: Op[][] = []; + for (let i = 0; i < 8; i++) { + const r = Math.random(); + if (r < 0.3) t.state.a.p += "x"; + else if (r < 0.5) t.state.a.q += "y"; + else if (r < 0.65) t.state.b.push(i); + else if (r < 0.8) t.state.c = i; + else if (r < 0.9) delete t.state.c; + else t.rebase(); + const ops = t.flush(); + if (ops.length > 0) batches.push(ops); + } + expect(roundTrip(batches)).toEqual(batches); + } + }); +}); + +describe("property: random round-trip", () => { + // 3000 random sequences found the root-splice hole on the first run, before any + // hand-written case did. + const rnd = (d = 0): unknown => { + const r = Math.random(); + if (d > 2 || r < 0.3) return Math.floor(Math.random() * 5); + if (r < 0.45) return ["x", "y", null, true][Math.floor(Math.random() * 4)]; + if (r < 0.7) return Array.from({ length: Math.floor(Math.random() * 4) }, () => rnd(d + 1)); + const o: Record = {}; + for (const k of ["a", "b", "c"]) if (Math.random() < 0.6) o[k] = rnd(d + 1); + return o; + }; + + it("a replica always matches the producer", () => { + let checked = 0; + for (let i = 0; i < 3000; i++) { + const base = rnd(); + if (typeof base !== "object" || base === null) continue; + const t = track(structuredClone(base) as object); + t.flush(); // drain the base batch + const next = rnd(); + if (Array.isArray(t.state) && Array.isArray(next)) { + (t.state as unknown[]).splice(0, (t.state as unknown[]).length, ...next); + } else if (!Array.isArray(t.state) && typeof next === "object" && next !== null && !Array.isArray(next)) { + const s = t.state as Record; + for (const k of Object.keys(s)) if (!(k in next)) delete s[k]; + for (const [k, v] of Object.entries(next)) s[k] = v; + } else continue; + expect(apply(structuredClone(base), t.flush())).toEqual(t.target); + checked++; + } + // Most random pairs are shape-incompatible and skipped; this only guards + // against the loop silently checking nothing. + expect(checked).toBeGreaterThan(200); + }); +}); diff --git a/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.ts b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.ts new file mode 100644 index 000000000..80c0b5b96 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/01-delta/delta.ts @@ -0,0 +1,285 @@ +// ───────────────────────────────────────────────────────────────────────────── +// harness/delta — intent-recording change tracking over plain JSON. +// +// Depends on nothing else in the harness. Session storage, the runtime and the +// facet host consume it; keep the arrows pointing that way. +// +// State is plain TS. You mutate it normally. Ops come out carrying what you +// *did*, not what changed — `text += chunk` yields an append, not a 50 KB +// replacement; `arr.unshift(x)` yields one splice, not O(n) index writes. +// ───────────────────────────────────────────────────────────────────────────── + +export type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue }; + +export type Seg = string | number; +export type Path = readonly Seg[]; + +/** Non-empty: `s`/`d`/`a`/`t` cannot target the root. Enforced by the type. */ +export type NonEmptyPath = readonly [Seg, ...Seg[]]; + +/** A path inline on first use, an interned id thereafter. */ +export type PathRef

= P | number; + +/** + * Tuples are the form — in memory, on the wire, on disk. There is no keyed + * variant and no codec between them. + * + * `r` is the ONLY op that replaces a whole value. It is an op rather than a + * frame kind for the same reason `a` and `t` exist: they are specialisations of + * `s` that are smaller and state intent. It also makes base detection a token + * comparison rather than a path inspection, which matters because recovery stops + * at the last base batch — a correctness boundary. + * + * Only `p` may target the root, and only because a tracked value can itself be + * an array. A `p` covering its entire target is normalised at RECORD time to `r` + * or `s`, so a root `p` is always a partial modification. + * + * The two-element forms reuse the previous op's path; arity disambiguates. + */ +export type Op = + | readonly ["r", JsonValue] + | readonly ["s", NonEmptyPath, JsonValue] + | readonly ["d", NonEmptyPath] + | readonly ["a", NonEmptyPath, string] + | readonly ["t", NonEmptyPath, number] + | readonly ["p", Path, number, number, JsonValue[]]; + +/** + * What crosses a boundary. Adds path interning and arity omission, and nothing + * else. `["r", value]` carries no path, so it encodes to itself — which is why + * `isBase` works on either vocabulary. + */ +export type WireOp = + | readonly ["r", JsonValue] + | readonly ["s", PathRef, JsonValue] | readonly ["s", JsonValue] + | readonly ["d", PathRef] | readonly ["d"] + | readonly ["a", PathRef, string] | readonly ["a", string] + | readonly ["t", PathRef, number] | readonly ["t", number] + | readonly ["p", PathRef, number, number, JsonValue[]] + | readonly ["p", number, number, JsonValue[]] + | readonly ["#", number, Path]; + +/** + * There is no frame type. What travels is `Op[]`. + * + * A **base batch** is one whose first op is `r`; the first batch of a stream is + * always one, and storage tags it "base" so recovery can stop there. Sequencing + * lives on the transport — a list element's `seq`, or an SSE `id:` — never in the + * payload. See `delta.md` §6. + */ + +// ─── Safety ────────────────────────────────────────────────────────────────── + +/** + * Path segments that reach the prototype chain. Rejected at record time AND at + * apply time, including through an interned id. + * + * Reserved as SEGMENTS, not as values: an object with a literal `__proto__` key + * replicates fine as a whole value; only a path walking through it is refused. + * That is a real restriction on what is mutable — see `delta.md` §7.1. + */ +export declare const RESERVED_SEGMENTS: ReadonlySet; + +export declare class UnsafePathError extends Error { + readonly segment: Seg; +} + +/** Rejects reserved segments, symbol keys, and negative or non-integer indices. */ +export declare function assertSafePath(path: Path): void; + +/** + * Rejects anything `JSON.stringify` would not round-trip: Map, Set, Date, RegExp, + * typed arrays, class instances, functions, BigInt, cycles. + * + * `structuredClone` is NOT a JSON check — it clones a Map happily, and the op then + * carries `{}` while the producer keeps a real Map. Silent divergence. + */ +export declare function assertJsonValue(value: unknown): asserts value is JsonValue; + +/** + * Validate an op's structure before applying it. A decoder must not trust tuple + * shape: an unvalidated applier spreads a string into an array given + * `["p",["xs"],0,0,"not-an-array"]`, and writes at the root given a string path. + * + * An unknown verb is an error, not a no-op — skipping it is how a newer producer's + * op vanishes and a replica drifts. This is the CVE-2025-55182 lesson + * (`delta.md` §7.3). + */ +export declare function assertValidOp(op: unknown): asserts op is Op; + +/** + * The same, for the wire grammar, where ids and short forms are legal. + * `decode` uses this; `apply` uses `assertValidOp`. Validating a decoded op + * against the wire grammar is laxer than its type — a two-element `["s", value]` + * would pass and then be read as a path. + */ +export declare function assertValidWireOp(op: unknown): asserts op is WireOp; + +// ─── Classification ───────────────────────────────────────────────────────── +// +// Ops are tuples, so classification is easy to hand-roll and easy to get wrong. +// `op[0] === "s"` is not enough — a set on any path is "s"; what makes a base is +// that it targets the ROOT. And because a tuple may omit the PathRef (arity +// disambiguates), `op[1]` is a PathRef or a value depending on length. These +// exist so nobody writes that comparison twice. + +/** True for the `r` op. */ +export declare function isReplace(op: Op): boolean; + +/** + * True when this batch begins with `r` — a **base batch**. Exact rather than a + * heuristic, because flush guarantees `r` appears at index 0 or not at all. + * Storage tags such elements `"base"` so recovery can stop at the last one. + */ +export declare function isBase(ops: readonly (Op | WireOp)[]): boolean; + +// ─── Codec ─────────────────────────────────────────────────────────────────── + +/** + * Path interning and arity omission. ONE PAIR PER STREAM — the id table spans a + * whole subscription or file, so a consumer joining later needs its own. + * + * The table resets on a base batch, because a reader replays from the last one + * with a fresh decoder and everything after it must be self-contained. + */ +export declare function encoder(): { encode(ops: readonly Op[]): WireOp[] }; +export declare function decoder(): { decode(wire: readonly WireOp[]): Op[] }; + +// ─── Tracker ───────────────────────────────────────────────────────────────── + +export interface TrackerOptions { + /** + * Cap on characters compared when deriving append/truncate from a string + * write. A window slide's overlap lies near the end, so a cap bounds cost + * without changing the result. Default 65536; 0 disables detection. + */ + maxOverlapScan?: number; + /** Merge compatible adjacent ops on flush. Default true. */ + coalesce?: boolean; +} + +export interface Tracker { + /** + * Mutate this. Plain TS: assignment, push, splice, delete, all of it. + * + * Assigning to it replaces the whole value: emits `["r", next]` and discards + * ops recorded before it. It must be a SETTER on the tracker — a plain + * property would be overwritten, swapping the proxy for a plain object and + * silently ending tracking. + * + * To force a base batch without changing the value, call `rebase()`. + */ + state: T; + /** The untracked backing object. Reads only — writing here emits nothing. */ + readonly target: T; + /** + * Drain. Empty when nothing changed. + * + * The FIRST flush is always a base batch, carrying the value including any + * mutations made before it — a consumer starts with nothing, so a stream must + * open with something to apply deltas to. + * + * Ops that a later op makes unreachable are dropped first — a field written + * three times emits one op, and a parent replaced after its child emits one. + * Object key order is not preserved by that pass; see `delta.md` §5. + */ + flush(): Op[]; + /** Discard without emitting. For a producer that just sent a base batch. */ + discard(): void; + get dirty(): boolean; +} + +export declare function track(root: T, options?: TrackerOptions): Tracker; + +// ─── Applier ───────────────────────────────────────────────────────────────── + +/** + * Apply ops to a plain mutable object. Five verbs plus the dictionary entry, no + * domain knowledge, no library, no registry lookup. + * + * Returns the value, because a root set replaces it outright and cannot be done + * in place. Throws `PathError` when a path does not resolve, which a consumer + * treats exactly like a sequence gap: request a fresh base frame. + * + * Must not be pointed at a value produced by Immer, which deep-freezes. + */ +/** + * Fold ops into a value and return it — returned rather than mutated in place, + * because `r` replaces the value outright. Tolerates an `undefined` target, + * since a root op never reads it, so a consumer needs no base-batch branch. + * + * An `r` payload is **adopted, not copied**: the consumer owns the batch it was + * handed. Do not hand one batch to two in-process consumers, or their replicas + * will alias. + */ +export declare function apply(target: T, ops: readonly Op[]): T; + +export declare class PathError extends Error { + readonly path: Path; +} + +// ─── Replica ───────────────────────────────────────────────────────────────── + +export interface Replica { + /** Current value. `undefined` until the first base frame. */ + readonly value: T | undefined; + /** + * Feed one batch, with the sequence number the transport supplied. Returns + * "gap" when `seq` is not contiguous or a path failed to resolve; the caller + * then requests a fresh base batch. + */ + receive(ops: readonly Op[], seq: number): "ok" | "gap"; +} + +export declare function replica(): Replica; + +// ─── Semantics ─────────────────────────────────────────────────────────────── +// +// ROOT OPS +// `["s", [], v]` replaces the value; `["p", [], …]` splices it when the value +// is an array. The applier must special-case `path.length === 0` before +// resolving a parent — a prototype handling only a root set threw on the first +// randomised sequence that produced a root splice. +// +// STRINGS +// A write of `next` over `prev` derives ops from the longest suffix of `prev` +// that is a prefix of `next`: +// overlap === prev.length -> append(next.slice(overlap)) +// overlap > 0 -> truncate(prev.length - overlap) + append(rest) +// overlap === 0 -> set(next) +// Always correct: the overlap is verified, not guessed. This is what makes a +// rolling window cheap — the case every effect-recording library degrades on. +// +// Use a native probe, not a hand-written KMP: `indexOf` a short head of `next` +// in `prev`, verify with `endsWith`. Measured 47x faster over 2000 slides on a +// 50 KB string, verified identical across 200. +// +// `t` counts UTF-16 code units, matching String.prototype.slice. Not bytes. +// +// ARRAYS +// Mutator methods are intercepted in the `get` trap, so intent is recorded +// before the engine performs index writes: +// push/unshift/pop/shift/splice -> one `p` +// sort/reverse/fill/copyWithin -> `s` of the whole array (rare) +// Direct index writes are honest `s` ops. `a.length = n` is translated — +// shrink to a splice, grow to a splice of nulls — never emitted as a path, +// because `length` is not a document location. +// +// TRANSACTIONS +// Several writes to one address in one commit must each be recorded against +// the previous one's result, not against pre-transaction state. The applier +// replays in order; anything else applies the second against a stale base. +// +// ALIASING +// Op payloads are structured-cloned at record time. Without this a later +// mutation retroactively changes an already-emitted op and a local replay +// aliases the source — an observed bug, not a hypothetical. +// +// UNDEFINED +// `x = undefined` normalises to `d`. JSON has no undefined, and a set with a +// missing value is indistinguishable from a lost value after a round trip. +// +// SCOPE +// JsonValue only. No Map, Set, Date, RegExp, class instances, getters, symbol +// keys, or cycles. That keeps this to five traps and one string algorithm, and +// makes structuredClone on payloads always safe. diff --git a/packages/agent/docs/mobile-handoff/01-harness/02-scopes/scopes.md b/packages/agent/docs/mobile-handoff/01-harness/02-scopes/scopes.md new file mode 100644 index 000000000..8f16022e4 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/02-scopes/scopes.md @@ -0,0 +1,576 @@ +# Session Storage: Scopes + +> **Scope:** `packages/agent/src/harness/session/`, plus the call sites in +> `runtime/` listed in §10. +> +> **Depends on `01-delta`** for the op vocabulary that pending state is stored in +> (§2, §11) and for the `WireOp` form the record encoding carries (§12). The two +> are otherwise independent and compound: encoding shrinks every write, scopes +> move one class of write out of the main log entirely. +> +> Every claim here was checked against `runtime/lane.ts`, `runtime/progress.ts` +> and `runtime/drive/*.ts`. The type-level enforcement in §6 was verified with +> `tsc --strict`, including that mixed-scope commits are the *only* thing it +> rejects. + +## 1. The problem + +Operation state — pending tool output, pending assistant output — matters only +while the operation runs. It is crash-recovery scaffolding, not history. + +The JSONL log is append-only, so it persists anyway. Worse, deleting it *adds* +records: `deleteValue` and `deleteList` write a line each, and those lines also +persist. Today only a full snapshot rewrite reclaims the space, and no in-place +compaction exists (`jsonl/storage.ts` has `createFromSnapshot`, used for fork). + +Measured on 20 operations, each 400 assistant frames plus 2 tools at 60 +checkpoints of a 50 KB window: + +| | size | +| --- | --- | +| total JSONL | 93.89 MB | +| settled transcript entries — the part that is actually history | **0.06 MB** | + +Three orders of magnitude of the file is scaffolding for finished operations. + +**Two independent fixes, and they are not alternatives:** + +- **Encoding** (`delta.md`): value writes carry ops rather than whole values, and + addresses are interned. Takes the same workload to **5.32 MB in a single file**, + with no change to atomicity. This is the larger and safer win, and it applies to + every value and list, not just pending state. +- **Scopes** (this document): pending state leaves the main log entirely, so it is + never history to begin with. + +Do the encoding first. Scopes are worth it because 5.32 MB of dead weight per 20 +operations still accumulates, and there is no compaction pass to reclaim it. + +## 2. Scope on the address + +Scope has two halves that must not be confused: a **type-level tag** carrying no +data, and a **runtime scope id** that names the file. + +```ts +export type SessionScope = { readonly kind: "session" }; +export type EphemeralScope = { readonly kind: "ephemeral" }; +export type Scope = SessionScope | EphemeralScope; + +// Passing a scopeId is what makes an address ephemeral. +export function value(namespace: string, key: string): Value; +export function value(namespace: string, key: string, scopeId: string): Value; + +export function list(namespace: string, key: string): ValueList; +export function list(namespace: string, key: string, scopeId: string): ValueList; + +/** A main-log record retiring an ephemeral scope. See §5. */ +export function retireScope(id: string): Write; +``` + +The suffix is not decoration: `Session` already names the session interface in +`harness/session/types.ts`, and reusing it produces `TS2440`/`TS2484` plus an +ambiguous re-export from `session/index.ts`. + +```ts +interface Value extends ScopedAddress { + readonly namespace: string; + readonly key: string; + /** Present iff Sc is Ephemeral. Routes the write and names the sidecar. */ + readonly scopeId?: string; +} +``` + +The id is a plain runtime string, because it is an operation id — generated at +runtime and unavailable to the type system. That is precisely why §6 can +distinguish *session from ephemeral* statically but cannot distinguish *two +different ephemeral scopes*: the tag is in the type, the id is not. + +```ts +// Lists of op batches, not values — see `delta.md` §9 and §11 below. +export const pendingToolOutput = (operationId: string, invocationId: string) => + list("pi.pending.tool_output", `${operationId}:${invocationId}`, operationId); + +export const pendingAssistantOutput = (operationId: string, entryId: string) => + list("pi.pending.assistant_output", `${operationId}:${entryId}`, operationId); + +export const operationToolMemo = (operationId: string, invocationId: string, name: string) => + value("pi.op.tool_memo", `${operationId}:${invocationId}:${name}`, operationId); + +// unchanged — no scopeId, so Session by inference +export const laneStateValue = (lane: string) => value("pi.lane.state", lane); +``` + +**Scope is the file.** A session-scoped write goes to the main log; an +ephemeral-scoped write goes to `..jsonl`. + +Other backends need no sidecars at all: in-memory drops a `Map` keyed by scope id, +SQLite issues one `DELETE ... WHERE scope = ?`. Scope is meaningful to them only as +a lifetime hint. It is JSONL that needs the file split, and that is where the +constraints in §4 and §5 come from. + +## 3. Which addresses are scoped, and why + +The boundary is drawn by **write isolation**, not by lifetime. + +Scoping everything under `pi.op.*` on the grounds that it dies with the operation +looks right and is wrong. `lane.ts` shows why: **every** operation commit bundles +`operationState` with `laneState`. + +```ts +// lane.ts:405/431, 659/660, 749/750, 1071/1072 — the same shape each time +writes: [ + ...decision.writes, + setValue(operationStateValue(operationId), decision.operationState), + setValue(laneStateValue(this.name), durableLaneState(...)), +] +``` + +That pair is coordination state: lane state says the operation is at step N, +operation state holds the data for step N. Splitting them across files makes a +crash produce a lane that believes it is somewhere the data does not support. + +The **bulk** values behave differently. `openProgress` commits exactly one write +(`runtime/progress.ts:51`), and `pendingToolOutput` / `pendingAssistantFrames` +appear in multi-write transactions only as *deletes*. + +| | addresses | +| --- | --- | +| **ephemeral (sidecar)** | `pi.pending.tool_output`, `pi.pending.assistant_output`, `pi.op.tool_memo` | +| **session (main log)** | `pi.lane.state`, `pi.op.state`, `pi.op.meta`, `pi.result`, `pi.pending.entry`, `pi.branch.tip`, `pi.op.tool_args`, `pi.op.preparation` | + +`operationToolMemo` is ephemeral so that the memo-plus-checkpoint bundling in +`harness-tools.md` §7.5 is a single-file transaction. `terminal.ts:34` already +treats `operationToolMemoPrefix` and `pendingToolOutputPrefix` as the same cleanup +class, so this matches how the code already thinks about them. + +The size win is unaffected by leaving `operationState` behind: effectively all of +the 93.89 MB in §1 is pending tool and assistant output. + +## 4. Two files are not atomic + +Serialising on the Session line gives **ordering, not atomicity**. Two `write()` +calls to two descriptors, two fsyncs, no journal spanning them. A crash between +leaves the files disagreeing, and JSONL has nothing to repair it with. + +So the design does not try to make cross-file transactions work. It ensures none +exist: + +> **All writes in a transaction share one scope.** + +That is achievable because it is already true of the code — see §5 for the audit. + +Two consequences of *not* needing cross-file commits: + +- **No write ordering between files is required.** The commit-record pattern + (sidecar first, fsync, then a main-log record acknowledging it) is unnecessary if + no transaction spans files. There is nothing to acknowledge. +- **No commit marker for sidecar-only writes.** One tempting design is a small + main-log record for `openProgress`, since nothing acknowledges its write. It is + not needed: that transaction is one write in one file and already atomic. Torn + tails are handled the same way as in the main log — every line is a complete op + or a whole batch, so a reader stops at the last intact one. + +What remains is a **durability** choice, not a consistency one: if the sidecar is +fsynced less aggressively, recent checkpoints can be lost. That is the same bounded +loss the checkpoint interval already accepts, but it should be a deliberate policy +rather than an accident. + +## 5. Audit: does the existing code satisfy the rule? + +Every commit in `lane.ts` (12 sites) and every `writes:` producer in +`runtime/drive/*.ts` (32 sites) was read. + +**Result: yes, with one adjustment.** No transaction writes to two files. The +spanning cases are all *deletes* of ephemeral state bundled with main-log writes: + +- `response.ts:340` — the settle commit: `insertEntry`, `insertUsage`, + `setValue(branchTip)`, plus `deleteList(pendingAssistantFrames(...))`. +- `terminal.ts:50` `operationCleanupWrites` — bundles main-log deletes with + `toolMemos` and `toolOutputs` deletes, returned into the same array as + `operationResultValue` and `laneStateValue`. +- `tools.ts:230, 257` — `deleteValue(pendingToolOutput)` with main-log writes. +- `deferred.ts:157` — `deleteList(pendingAssistantFrames)`. + +**The adjustment: retirement becomes a main-log record.** + +```ts +retireScope(operationId): Write +``` + +The log is the truth; the sidecar file is a cache. Recovery reads the retire record +and ignores — then removes — the sidecar. Unlinking eagerly after commit is a pure +optimisation; losing the unlink costs disk, never correctness. + +This makes all four sites above single-scope, because the individual ephemeral +deletes disappear. It also *simplifies* `operationCleanupWrites`: the `scanValues` +calls for `operationToolMemoPrefix` and `pendingToolOutputPrefix` are replaced by +one `retireScope`. + +And it makes the open-time sweep principled rather than heuristic. A sidecar is +dead iff the main log retired it or its operation is absent — no inference from +operation status. + +### 5.1 Two ephemeral scopes in one transaction cannot arise + +A lane holds `operation: { meta, state } | null` — **singular**. Every write set +uses one `operationId`, either `drive.operationId` or the newly created one in +`startOperation`, which asserts no operation is active. Retire and start are always +separate transactions: the settle commits at `lane.ts:427` and `response.ts:339` +set lane state to `null`. + +Concurrency is across **lanes**, and each lane has at most one active operation, so +two ephemeral scopes never meet in a commit. The runtime assertion in §6 is defence +against a future change, not a live gap. + +## 6. Static enforcement + +> **`scopes.variance.ts` ships alongside this doc** and is the executable form of +> this section: `npx tsc --noEmit --strict --lib es2023 scopes.variance.ts`. +> Silence means the rule holds. Its three `@ts-expect-error` lines fail the +> compile if they ever start type-checking, so it catches the enforcement being +> weakened as well as broken. + + +Two phantom types, differing only in where `Sc` appears. This distinction is +load-bearing and is the single easiest thing to get wrong here. + +```ts +declare const storedScopeType: unique symbol; + +/** Addresses: COVARIANT — Sc only in return position. */ +export interface ScopedAddress { + readonly [storedScopeType]?: () => Sc; +} + +/** Writes: INVARIANT — Sc in both parameter and return position. */ +export interface Scoped { + readonly [storedScopeType]?: (scope: Sc) => Sc; +} + +export interface Value + extends StoredAddressBase, ScopedAddress { … } + +export interface ValueSetWrite extends Scoped { … } +``` + +**Why they differ.** Reading an address is safe at any scope — `getValue` does not +care which file a value lives in, so `Value` must be usable where +`Value` is expected. Forming a transaction is *not* safe at any scope, +because two files are not atomic, so a commit must pin down exactly one. + +Enforcement therefore belongs where transactions are formed, not where addresses +are read. `setValue(address: Value, …): Write` is the bridge: it +infers the scope from a covariant address and stamps it onto an invariant write. + +> **Getting this wrong is expensive.** Making addresses invariant too breaks every +> reader — `getValue`, `scanValues`, `readList` and everything downstream — and +> produced a 36-error cascade across storage backends that had done nothing wrong. +> The tempting workaround, making each reader generic over `Sc` to mean "any +> scope", propagates a type parameter through the whole storage stack to express +> something variance gives for free. + +Verified with `tsc --noEmit --strict`. Reads pass at both scopes: + +```ts +getValue(laneState); // Value +getValue(pendingOutput); // Value +``` + +Single-scope commits pass: + +```ts +commit([setValue(laneState, a), setValue(operationState, b)]); +commit([setValue(toolMemo, m), setValue(pendingOutput, o)]); +commit([setValue(laneState, a), retireScope("op_1")]); +``` + +Mixed-scope commits fail, and are the *only* things that fail: + +```ts +commit([setValue(laneState, a), setValue(pendingOutput, o)]); // ERROR +commit([setValue(pendingOutput, o), retireScope("op_1")]); // ERROR +``` + +### 6.1 What the type system cannot catch + +Two different ephemeral scopes both type as `Write`, because the id +is a runtime string (§2). A runtime assertion at commit covers it — compare +`scopeId` across all writes — and per §5.1 it should never fire. + +## 7. Recovery and sweeping + +On open, enumerate sidecars alongside the main log and load them. Their records +participate in replay exactly as main-log records do. + +A sidecar is removed when the main log holds a `retireScope` record for it, or when +its operation is absent — a crash between commit and unlink. Torn-tail semantics +are unchanged: each line is self-contained, so a crash mid-write loses exactly the +trailing line. + +## 8. Sequence numbers + +Scoped writes get their own sequence space per scope. Nothing outside a scope +orders against its contents, and keeping them separate means `nextSeq` in the main +header does not account for numbers consumed by files that will be deleted. + +Shared numbering would leave large gaps after several operations settled, and the +high-water mark would need separate persistence. + +## 9. Measured effect + +Same workload as §1: + +| | size | of today | +| --- | --- | --- | +| today, single JSONL | 93.89 MB | — | +| ops + interned addresses, single JSONL | 5.32 MB | 5.7% | +| **plus scopes — main log** | **0.06 MB** | **0.06%** | +| — sidecars, retired on settle | 5.26 MB | peak 0.26 MB per operation | + +The 5.7% figure is **rate-dependent and must not be quoted alone**: it holds at +2 KB of tool output per checkpoint and inverts above the cap, where a whole-value +replacement wins. + +The scope result is **not** rate-dependent. The main log holds settled entries +regardless of encoding, and that is the number that governs session file growth. + +## 10. What changes in the existing code + +The compiler finds these for you: once addresses carry a scope and `Write` is +invariant, every site that mixes scopes in one transaction fails to typecheck. +This list is what a full pass turned up, so you know when you are done. + +**New, in `session/values.ts` and `session/types.ts`** + +`SessionScope` / `EphemeralScope` / `Scope`; the two phantoms from §6; +`Value` and `ValueList`; `scopeId` on the address; the `value` / +`list` overloads; `retireScope`; scope-preserving `setValue` / `deleteValue` / +`appendList` / `deleteList`; `Write` with entries, usage and retirement +session-only by construction. + +**Generified** + +`CommitDecision`, `lane.command`, `Storage.commit`. +Give `Sc` a default of `SessionScope`: conditional types are not inference sites, +so without a default a mixed array falls back to the constraint `Scope` and every +call site fails. `settleOperation` and `continueOperation` stay **non**-generic — +see §5. + +**Committed writes** + +`CommittedScopeRetireWrite`, and `scopeId` carried through the committed shapes so +`JsonlStorage` can route on it. + +**Call sites that must change** — each currently mixes scopes in one transaction: + +| site | what it does today | what it becomes | +| --- | --- | --- | +| `drive/terminal.ts` `operationCleanupWrites` | enumerates tool memos and tool outputs via `scanValues`, deletes each alongside session deletes | one `retireScope(operationId)`; two of its four scans disappear | +| `drive/response.ts` (settle) | `deleteList(pendingAssistantOutput)` bundled with operation state | drop it — the sidecar is discarded at retire | +| `drive/deferred.ts` (superseded response) | same | same | +| `drive/tools.ts` (tool settle) | `deleteValue(pendingToolOutput)` plus memo deletes in a session write array | drop them | +| `drive/tool-placement.ts` | `deleteValue(pendingToolOutput)` | drop it | + +The pattern is the same everywhere: **intra-operation cleanup of ephemeral state +is impossible**, because those commits also write operation and lane state. The +sidecar is discarded wholesale at retire. The cost is that a multi-turn operation +holds superseded pending output until settle — bounded by turns, and never +reaching the main log. + +**Storage** + +Sidecar routing in `JsonlStorage.commit` (assert one scope id per transaction, +route the append), unlink on a `scope` record **after** the commit, and an +open-time sweep that loads sidecars and removes any the main log already retired. +In-memory drops a `Map` keyed by scope id; SQLite issues one +`DELETE ... WHERE scope = ?` and treats a retire record as a no-op, because it has +no sidecar and the terminal transaction deletes the scoped values explicitly. + +**Expected test churn** + +Nine tests assert the old cleanup write set and will fail — `drive-terminal` (3), +`drive-retry-deferred` (2), `drive-tools`, `drive-reconcile`, `drive-generation`, +and one pinning reserved namespaces. They expect six or seven individual deletes +where the new code emits fewer plus one `retireScope`. That is the change landing, +not a regression. + +## 11. List tags and stop conditions + +Tracked state is stored as a list of op batches (`delta.md` §9). Recovery needs "the +batches since the last base batch" without unpacking every row, which lists cannot +express today: `ListReadOptions` has `cursor`, `order` and `limit` only. + +`BranchScan` already solves the same problem for entries, and its vocabulary is +the one to copy — `stopAtType` / `stopAtId` for a terminator, plain field names +for a filter. + +```ts +appendList(address: ValueList, element: T, tag?: string): ListAppendWrite; + +export interface ListElement { + seq: number; + value: T; + tag?: string; +} + +export interface ListReadOptions { + cursor?: ListCursor; + order?: "asc" | "desc"; + limit?: number; + /** Include elements up to and including the first carrying this tag, then stop. */ + stopAtTag?: string; + /** Return only elements carrying this tag. */ + tag?: string; +} +``` + +**`stopAtTag` is a stop condition within a page, not a guarantee.** It can only end +a page earlier than `limit` would; it never overrides it. If the tagged element is +not in the page, the consumer sees no tagged element and pages again with the +cursor — the same loop `readAssistantFrames` already runs. There is no ordering +question between the two bounds and no "not found" error. + +For that loop to work, `readList` must return the tag on each element. Otherwise a +consumer cannot tell "page ended at the tag" from "page ended at the limit" +without parsing the value, which is the thing the tag exists to avoid. + +**The producer sets the tag, not storage.** For a tracked value the caller +already knows, because `isBase(ops)` is a token comparison on the first op +(`delta.md` §2): + +```ts +const ops = tracker.flush(); +if (ops.length === 0) return; +writes: [appendList(address, enc.encode(ops), isBase(ops) ? "base" : undefined)]; +``` + +Storage must never inspect an element to derive a tag. That is what keeps op +batches opaque to the storage layer, and it is the same property that makes +`EntryScan.type` work: the discriminant is a stored column, not something derived +from the payload. + +**The tag lives on the storage record**, beside `seq` and `value`: + +```jsonc +["l",7,9,[["r",{…}]],"base"] // per §12.1 +``` + +Storage must never parse an element to evaluate a predicate. That is what keeps +ops opaque to storage and the durable path free of domain knowledge +(`harness-tools.md` §7.7). It is also why `EntryScan.type` works: the discriminant +is a stored column, not something derived from the payload. + +`order` stays `"asc" | "desc"` rather than `BranchScan`'s +`"newestFirst" | "oldestFirst"`. Branch order is semantic — a walk from a tip +through a tree. List order is over `seq`. Borrowing the branch words would imply a +traversal that is not happening. + +Backends: SQLite gets a `tag` column and a real predicate. JSONL holds the list in +memory already, so it scans backwards and checks a field — no worse than today. +In-memory likewise. + +The primitive generalises past frames: any list wanting "the tail since the last +checkpoint" gets it. + +## 12. JSONL record encoding + +Two dictionaries at two layers. They are the same trick and are independent: the +**address** dictionary is over `namespace` + `key` on the record; the **path** +dictionary is over paths *inside* a value's ops (`delta.md` §4). + +### 12.1 Records + +``` +["@", addrId, namespace, key] address definition +["v", addrId, seq, wireOps] value write — WireOp[], delta.md §4 +["l", addrId, seq, element, tag?] list append — element is WireOp[] + for a tracked value +["x", addrId, seq] delete (value or list) +["!", addrId, seq] retire an ephemeral scope +``` + +**Record verbs and op verbs are separate vocabularies read at different levels**, +but they must not look alike. `!` rather than `r` for retire: `r` is delta's +replace op, and a reader scanning a file should not have to remember which nesting +level they are at to know what a tuple means. + +Retire takes an address id like everything else, defined by an `["@", id, …]` +whose namespace is the scope. That keeps one interning rule for the whole file +rather than a special case for one record. + +Entries and usage rows keep their current keyed form: they are written once, never +repeat an address, and are read by machinery that has nothing to do with ops. + +An address is defined on its **second** use, matching path interning — a +definition is pure overhead for an address written once, and a session has many of +those. + +### 12.2 Worked example + +Four writes across two addresses, before and after. + +```jsonc +// today — 1250 bytes +{"kind":"value","op":"set","seq":7,"namespace":"pi.op.state","key":"01a04cf6-…","value":{"at":"starting","control":{…},"settings":{…},"latestAssistantEntryId":null}} +{"kind":"value","op":"set","seq":8,"namespace":"pi.lane.state","key":"main","value":{"currentOperationId":"01a04cf6-…",…}} +{"kind":"value","op":"set","seq":9,"namespace":"pi.op.state","key":"01a04cf6-…","value":{"at":"checkpoint",…}} +{"kind":"value","op":"set","seq":12,"namespace":"pi.op.state","key":"01a04cf6-…","value":{"at":"assistant.ready",…}} +``` + +```jsonc +// with both dictionaries — 547 bytes +["@",0,"pi.op.state","01a04cf6-…"] +["v",0,7,[["r",{"at":"starting","control":{…},"settings":{…},"latestAssistantEntryId":null}]]] +["@",1,"pi.lane.state","main"] +["v",1,8,[["r",{"currentOperationId":"01a04cf6-…",…}]]] +["v",0,9,[["#",0,["at"]],["s",0,"checkpoint"]]] +["v",0,12,[["s","assistant.ready"]]] +``` + +| line | before | after | +| --- | --- | --- | +| address definition | – | 61 | +| op.state -> `starting` (base batch) | 353 | 256 | +| address definition | – | 31 | +| lane.state (base batch) | 181 | 114 | +| op.state -> `checkpoint` | 355 | **48** | +| op.state -> `assistant.ready` | 361 | **37** | +| **total** | **1250** | **547** (44%) | + +The shape matters more than the total. Base batches barely shrink — 353 to 256 is +envelope only, since the value ships whole either way. The transitions collapse +~8x because they carry one changed field instead of the whole state including the +never-changing `settings` block. + +Both dictionaries appear on line 5: `0` in `["v",0,9,…]` is an **address** id, +while `["#",0,["at"]]` defines a **path** id inside the value's ops. Same trick, +different layers, separate tables. Line 6 then drops the path entirely — arity +omission, because it targets the same path as the previous op in that batch. + +**Do not quote 44% as the file-level saving.** Dictionary entries are one-time, so +a real operation's ~11 op.state writes amortise them and the ratio improves; but +transcript entries are untouched, and they were 18 KB of a 67 KB tool-heavy run. +Applied there this hits the ~27 KB of value writes and would take the file down by +roughly a fifth, not by half. + +### 12.3 Reading + +A reader builds both tables while replaying, so a torn tail costs exactly the +trailing lines and no header needs rewriting. A snapshot rewrite starts fresh +tables and re-emits definitions naturally. + +A numeric `addrId` for which no definition has been seen is a corrupt file, not a +recoverable state — unlike a missing path id, which cannot occur because path +definitions are inside the same record as their first use. + +## 13. Open questions + +- File-handle pressure across concurrent lanes, each with a live sidecar. Probably + fine, unmeasured. +- fsync policy for sidecars (§4) — matched to the main log, or relaxed given the + contents are bounded-loss scaffolding. +- Whether a long-running operation should rotate its sidecar. `rebase()` + (`delta.md` §3.2.3) bounds *recovery* length but not file size, so a command + running for hours still grows its sidecar without bound. +- Whether an in-place compaction pass for the main log is worth building anyway. + `createFromSnapshot` plus atomic replace is the mechanism; scopes reduce the need + but do not remove it, since superseded session-scoped values still accumulate. diff --git a/packages/agent/docs/mobile-handoff/01-harness/02-scopes/scopes.variance.ts b/packages/agent/docs/mobile-handoff/01-harness/02-scopes/scopes.variance.ts new file mode 100644 index 000000000..364ff423a --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/02-scopes/scopes.variance.ts @@ -0,0 +1,65 @@ +/** + * Executable form of scopes.md §6. + * + * npx tsc --noEmit --strict --lib es2023 scopes.variance.ts + * + * Silence means the rule holds: reads accept either scope, single-scope commits + * pass, and mixed-scope commits are the ONLY thing rejected. The three + * `@ts-expect-error` lines fail the compile if they ever start type-checking, + * so this catches the enforcement being weakened as well as broken. + * + * The variance split is the part that is easy to get backwards. Making addresses + * invariant too breaks every reader — getValue, scanValues, readList and + * everything downstream — and the tempting fix, making each reader generic over + * Sc, propagates a type parameter through the whole storage stack to express + * what variance gives for free. + */ +declare const tag: unique symbol; +type SessionScope = { readonly kind: "session" }; +type EphemeralScope = { readonly kind: "ephemeral" }; +type Scope = SessionScope | EphemeralScope; + +/** Addresses: COVARIANT — Sc only in return position. Readers take any scope. */ +interface ScopedAddress { readonly [tag]?: () => Sc } +/** Writes: INVARIANT — Sc in both positions. Mixed commits cannot unify. */ +interface Scoped { readonly [tag]?: (s: Sc) => Sc } + +interface Value extends ScopedAddress { k: "v" } +interface ValueList extends ScopedAddress { k: "l" } +interface VSet extends Scoped { kind: "value"; op: "set" } +interface VDel extends Scoped { kind: "value"; op: "delete" } +interface LApp extends Scoped { kind: "list"; op: "append" } +interface EntryWrite { kind: "entry" } +interface RetireWrite extends Scoped { kind: "scope" } + +type Write = + | VSet | VDel | LApp + | (Sc extends SessionScope ? EntryWrite | RetireWrite : never); + +declare function setValue(a: Value, v: T): VSet; +declare function deleteValue(a: Value): VDel; +declare function appendList(a: ValueList, v: T): LApp; +declare function retireScope(id: string): RetireWrite; +declare function insertEntry(e: unknown): EntryWrite; +declare function commit(w: readonly Write[]): void; +declare function getValue(a: Value): T; // reader: any scope + +const laneState = null as unknown as Value; // session +const pendingOut = null as unknown as ValueList; // ephemeral +const toolMemo = null as unknown as Value; + +// READS accept either scope — that is what covariance buys. +getValue(laneState); +getValue(toolMemo); + +// Single-scope commits pass. +commit([setValue(laneState, "a")]); +commit([setValue(laneState, "a"), insertEntry({}), retireScope("op_1")]); +commit([appendList(pendingOut, "x"), setValue(toolMemo, "m")]); + +// @ts-expect-error mixed scopes in one transaction +commit([setValue(laneState, "a"), appendList(pendingOut, "x")]); +// @ts-expect-error a retire is session-only, so it cannot join an ephemeral commit +commit([appendList(pendingOut, "x"), retireScope("op_1")]); +// @ts-expect-error entries are session-only +commit([insertEntry({})]); diff --git a/packages/agent/docs/mobile-handoff/01-harness/03-execenv/bash.after.ts b/packages/agent/docs/mobile-handoff/01-harness/03-execenv/bash.after.ts new file mode 100644 index 000000000..95a6e3e74 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/03-execenv/bash.after.ts @@ -0,0 +1,172 @@ +import { type Static, Type } from "typebox"; +import type { Context } from "../context.ts"; +import type { AgentHarnessTool } from "../types.ts"; +import { getOrThrow } from "../types.ts"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "../utils/truncate.ts"; +import type { ExecutionToolContext } from "./tool-context.ts"; + +const MAX_TIMEOUT_SECONDS = 2_147_483_647 / 1000; +/** + * Coalescing cadence for live updates. The env owns capping and coalescing now + * (`executionenv.md` §3), so bash no longer has an update throttle or a + * checkpoint interval of its own. + */ +const BASH_UPDATE_INTERVAL_MS = 100; + +const bashSchema = Type.Object({ + command: Type.String({ description: "Bash command to execute" }), + timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })), +}); + +export type BashToolInput = Static; + +export interface BashToolDetails { + truncation?: TruncationResult; + /** Set by the exec env, on the machine the command ran on. */ + spillPath?: string; +} + +export interface BashExecution { + command: string; + cwd: string; + env: Record; + inheritEnv: boolean; +} + +export type BashPrepare = ( + execution: BashExecution, + toolContext: TContext, + context: Context, +) => void | Promise; + +export interface BashToolOptions { + commandPrefix?: string; + prepare?: BashPrepare; +} + +function validateTimeout(timeout: number | undefined): void { + if (timeout === undefined) return; + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new Error("Invalid timeout: must be a finite number of seconds"); + } + if (timeout > MAX_TIMEOUT_SECONDS) { + throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`); + } +} + +export function createBashTool( + options?: BashToolOptions, +): AgentHarnessTool { + return { + name: "bash", + label: "bash", + description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, + parameters: bashSchema, + async execute(_toolCallId, { command, timeout }, onUpdate, toolContext, _invocation, context) { + validateTimeout(timeout); + const { env } = toolContext; + const execution: BashExecution = { + command: options?.commandPrefix ? `${options.commandPrefix}\n${command}` : command, + cwd: env.cwd, + env: {}, + inheritEnv: true, + }; + await options?.prepare?.(execution, toolContext, context); + + // The env caps, coalesces and spills at the source. bash keeps a mirror + // only to build the settled result; it never truncates and never chooses + // a spill path. + let view = ""; + let truncation: TruncationResult | undefined; + let spillPath: string | undefined; + + const publish = (checkpoint: boolean): void => { + onUpdate( + { + content: [{ type: "text" as const, text: view }], + details: { truncation, spillPath }, + }, + checkpoint ? { checkpoint: true } : undefined, + ); + }; + + onUpdate({ content: [], details: undefined }); + + const result = await env.exec( + execution.command, + { + cwd: execution.cwd, + env: execution.env, + inheritEnv: execution.inheritEnv, + timeout, + capture: { + limits: { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES, retain: "tail" }, + intervalMs: BASH_UPDATE_INTERVAL_MS, + spill: true, + }, + onUpdate: (update) => { + switch (update.kind) { + case "chunk": + view += update.text; + break; + case "snapshot": + view = update.output.text; + truncation = update.output.truncation; + spillPath = update.output.spillPath; + break; + case "counters": + truncation = update.truncation; + break; + } + // A snapshot is the durable-worthy state: it is the only kind that + // can express eviction, so it is where a crash loses least. + publish(update.kind === "snapshot"); + }, + }, + context, + ); + + if (!result.ok) { + const error = result.error; + const status = + error.code === "timeout" + ? `Command timed out after ${timeout} seconds` + : error.code === "aborted" + ? "Command aborted" + : error.message; + throw new Error(view ? `${view}\n\n${status}` : status, { cause: error }); + } + + const captured = result.value.output; + view = captured.text; + truncation = captured.truncation; + spillPath = captured.spillPath; + + let outputText = captured.text; + let details: BashToolDetails | undefined; + if (captured.truncation.truncated) { + details = { truncation: captured.truncation, spillPath }; + const startLine = captured.truncation.totalLines - captured.truncation.outputLines + 1; + const endLine = captured.truncation.totalLines; + if (captured.truncation.lastLinePartial) { + // One line alone exceeded the byte cap. Line numbers would be + // misleading here, so report the line's own size instead — and only + // the env can measure it, since it holds the raw buffer. + const lastLineSize = formatSize(captured.lastLineBytes ?? captured.truncation.outputBytes); + outputText += `\n\n[Showing last ${formatSize(captured.truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${spillPath}]`; + } else if (captured.truncation.truncatedBy === "lines") { + outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${captured.truncation.totalLines}. Full output: ${spillPath}]`; + } else { + outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${captured.truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${spillPath}]`; + } + } + + if (result.value.exitCode !== 0) { + throw new Error( + `${outputText ? `${outputText}\n\n` : ""}Command exited with code ${result.value.exitCode}`, + ); + } + return { content: [{ type: "text", text: outputText || "(no output)" }], details }; + }, + }; +} diff --git a/packages/agent/docs/mobile-handoff/01-harness/03-execenv/execenv.md b/packages/agent/docs/mobile-handoff/01-harness/03-execenv/execenv.md new file mode 100644 index 000000000..decc617dd --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/03-execenv/execenv.md @@ -0,0 +1,423 @@ +# ExecutionEnv: Bounded Shell Output + +Moves output capping, spilling and coalescing out of individual tools and into +`Shell`, so they happen where the bytes originate. + +> **Scope:** `packages/agent/src/harness/env/`, `harness/utils/`, +> `harness/tools/bash.ts`, and the `Shell` contract in `harness/types.ts`. +> +> **Independent of `01-delta` and `02-scopes`.** `Shell`'s three update kinds are +> its own vocabulary and stop at the `ToolOutput` sink; `Shell` never emits ops +> and knows nothing about `delta.md`. This unit can land in any order. +> +> **This one has working code**, not just a specification: +> +> | file | lands as | +> | --- | --- | +> | `output-capture.ts` | `harness/utils/output-capture.ts` — new | +> | `bash.after.ts` | `harness/tools/bash.ts` — the rewritten tool | +> | `nodejs-env.diff` | the `NodeExecutionEnv.exec` change | +> | `nodejs-env.test.diff` | the test migration, 29 passing | +> +> All of it typechecked and ran: `cd packages/agent && npx vitest run --config +> vitest.harness.config.ts nodejs-env` gives 29 passed, 1 skipped. + +--- + +## 1. The problem + +Three separate faults, one root cause. + +**`exec` is unbounded at its root.** + +```ts +// packages/agent/src/harness/types.ts +exec(command, options, context): Promise> +``` + +```ts +// packages/agent/src/harness/env/nodejs.ts:463,474 +child.stdout?.on("data", (chunk: string) => { stdout += chunk; /* … */ }); +child.stderr?.on("data", (chunk: string) => { stderr += chunk; /* … */ }); +``` + +`cat 1gb.txt` materialises 1 GB of string in the agent process. No cap exists +below the tool layer, so no tool-level truncation can prevent it — by the time +`bash.ts` sees the output it has already been accumulated in full. + +**Truncation is tool-specific.** `bash.ts` owns a rolling buffer, `truncateTail`, +and a spill file. `read.ts` has its own truncation. Every future tool that +produces bulk output re-implements the same logic, differently. + +**Spill has no owner.** bash writes to `createTempFile({ prefix: "bash-" })` — +`/tmp`, cleaned up whenever the OS decides. The path is handed to the model so it +can `grep` the full output, which means the file must live on the machine where +the model's other tools run. + +That last point is the constraint that shapes everything: **the spill must be in +the execution environment.** Not in session storage, not on the agent machine. +If the exec env is a remote sandbox, a spill on the agent host is unreachable to +the model's own `read` and `grep`. + +Which means the capping has to happen there too. Otherwise reading a 1 GB file on +a sandbox host ships 1 GB to the agent machine, caps it, and ships the spill back. + +## 2. Scope + +**In scope.** Bounded capture in `Shell`, spill inside the exec env, coalesced +live updates, capability reporting. + +**Relationship to the rest.** The three update kinds here are `Shell`'s own +vocabulary and stop at the `ToolOutput` sink; they do not travel further. +`harness-tools.md` §4 converts them into ops against `ToolOutputState`. `Shell` +never emits ops and knows nothing about `delta.md`. + +**Out of scope, deliberately.** Making a *remote* exec env work well with a local +worker. The answer there is to run the worker where the exec env is — the server +stays local, the worker relocates — and that is a separate work package. This +document assumes worker and exec env are colocated, which is the case for +`NodeExecutionEnv` today. + +## 3. Types + +```ts +// ─── Policy ────────────────────────────────────────────────────── + +/** Which retained portion survives when output exceeds the limits. */ +export type OutputRetention = "head" | "tail"; + +/** How much output the caller can accept, and what to keep on overflow. */ +export interface OutputLimits { + maxBytes: number; + maxLines: number; + /** Defaults to "tail". */ + retain?: OutputRetention; +} + +/** Requests bounded capture. Absent means unbounded. */ +export interface CaptureOptions { + limits: OutputLimits; + /** + * Minimum interval between onUpdate calls, in ms. Output arriving inside a + * window is coalesced, never dropped. 0 emits per chunk. Defaults to 0. + */ + intervalMs?: number; + /** + * Archive the full output to a file inside this environment, created lazily + * when the limits are first exceeded. The path is meaningful on the machine + * the command ran on. + */ + spill?: boolean; +} + +// ─── Captured output ───────────────────────────────────────────── + +export type ShellStream = "stdout" | "stderr"; + +/** Bounded view of a command's output, aggregated in arrival order. */ +export interface CapturedOutput { + /** The retained portion, already truncated per OutputLimits. */ + text: string; + /** Totals over everything produced, plus what was kept. */ + truncation: TruncationResult; + /** Present only if spill was requested and the limits were exceeded. */ + spillPath?: string; + /** + * Size of the final line when it alone exceeded the byte cap + * (`truncation.lastLinePartial`). Only the env holds the raw buffer, so only + * the env can measure it — a tool cannot recover it from the retained view. + */ + lastLineBytes?: number; +} + +export type ShellOutputUpdate = + | { kind: "chunk"; stream: ShellStream; text: string } + | { kind: "snapshot"; output: CapturedOutput } + | { kind: "counters"; truncation: TruncationResult }; + +// ─── Exec ──────────────────────────────────────────────────────── + +export interface ShellExecOptions { + cwd?: string; + env?: Record; + inheritEnv?: boolean; + timeout?: number; + + /** Bounded capture. Absent means unbounded. */ + capture?: CaptureOptions; + /** Live output. Replaces onStdout / onStderr. */ + onUpdate?: (update: ShellOutputUpdate, context: Context) => void; +} + +export interface ShellExecResult { + output: CapturedOutput; + exitCode: number; +} + +/** What this implementation can actually do. Tiers differ in fidelity. */ +export interface ShellCapabilities { + /** false for a backend that can only stream raw bytes. */ + capture: boolean; + spill: boolean; + /** false when the backend cannot emit incrementally. */ + liveUpdates: boolean; +} + +export interface Shell { + capabilities(): ShellCapabilities; + exec( + command: string, + options: ShellExecOptions | undefined, + context: Context, + ): Promise>; + cleanup(context: Context): Promise; +} + +export interface ExecutionEnv extends FileSystem, Shell {} +``` + +`TruncationResult` is the existing type from +`packages/agent/src/harness/utils/truncate.ts`. It already carries `totalBytes`, +`totalLines`, `truncated`, `truncatedBy`, `outputBytes`, `outputLines`, which is +exactly what a UI needs to render "showing last 50 KB of 1.2 GB". No new type. + +`Shell` deliberately has **no tool vocabulary**. It emits text. Images, details, +usage, added tool names and terminate are `ToolOutput`'s concern and are produced +on the agent side. That is why `ShellOutputUpdate` has three variants and not six. + +## 4. Why three update kinds + +They are the three states a bounded buffer can be in: text grows, text slides, +text stops. + +| kind | when | why nothing else works | +|---|---|---| +| `chunk` | below the cap | append-only and complete; the common case, since most commands never reach the cap | +| `snapshot` | retained view changed by eviction | an append cannot express "and 20 bytes fell off the front" | +| `counters` | `head` mode past the cap | text is frozen forever; without this you resend `maxBytes` to move a number | + +Reader: + +```ts +switch (u.kind) { + case "chunk": view.text += u.text; break; + case "snapshot": view = u.output; break; + case "counters": view.truncation = u.truncation; break; +} +``` + +### 4.1 Ordering invariant + +> Once the cap is crossed, a `chunk` must never follow without an intervening +> `snapshot`. + +The first flush that evicts is a `snapshot` regardless of size. Violating this +leaves the reader appending to a view whose front has been discarded — it grows +without bound and silently disagrees with `truncation`. + +A `head` producer never evicts, so it emits chunks up to the cap and only +`counters` afterwards, and never needs a snapshot. + +### 4.2 Per-mode behaviour + +Worked example: `maxBytes = 100`, `intervalMs = 500`. + +**`retain: "tail"`** + +| flush | produced | total | evicted | emitted | +|---|---|---|---|---| +| 1 | 30 B | 30 | — | `chunk` 30 B | +| 2 | 40 B | 70 | — | `chunk` 40 B | +| 3 | 50 B | 120 | 20 B | **`snapshot`** 100 B | +| 4 | 10 B | 130 | 10 B | `snapshot` 100 B | +| 5 | 0 B | 130 | — | *(nothing)* | +| 6 | 5 B | 135 | 5 B | `snapshot` 100 B | + +Flush 3 is the crossing. Note flushes 4 and 6: **once the buffer is full, every +byte in evicts a byte out**, so there is no cheap-trickle regime past the cap. +Tail collapses to: chunks until full, snapshots at the interval thereafter. + +**`retain: "head"`** + +| flush | produced | total | emitted | +|---|---|---|---| +| 1 | 30 B | 30 | `chunk` 30 B | +| 2 | 40 B | 70 | `chunk` 40 B | +| 3 | 50 B | 120 | `chunk` 30 B *(the part that fit)* | +| 4 | 10 B | 130 | `counters` | + +Head never evicts. After the cap only totals move. + +**Head+tail is not offered.** `harness-tools.md` §3.1 rejected it and that stands: +`truncate.ts` exports `truncateHead` and `truncateTail`, neither combines them, and +none is being added. OpenCode's `preview` shape — half the budget from each end — +is a reasonable future addition for failed builds, where you want the invocation +and the error but not the middle, but it is out of scope here. + +### 4.3 Idle + +No output in a window emits nothing. `counters` should use a slower cadence than +`intervalMs` (2 s is reasonable) since it exists only to keep a total live. + +### 4.3.1 Coalescing is a size lever, not only a rate lever + +`intervalMs` sets how large a chunk the sink receives, and chunk size decides both +throughput and wire size downstream (`delta.md` §4.2). + +Measured against a 50 KB window: at 100-byte chunks the op encoding puts **135% of +the input on the wire** — past the cap each write emits `truncate` + `append`, and +two op envelopes exceed a 100-byte payload. At 8 KB chunks it is 31%. + +So an `intervalMs` of 0 is correct only for a tool that produces little output. For +anything streaming, coalescing is what keeps the encoding on the right side of that +curve, and the interval should be chosen with size in mind rather than only +"how often should the UI redraw". + +### 4.4 Traffic bound + +Worst case is `maxBytes / intervalMs`, independent of how much the command +produces. At 50 KB and 500 ms that is 100 KB/s whether the command emits 1 MB or +1 GB. This is the property that makes the pathological case safe, and it is why +snapshots are the safe path rather than a fallback. + +## 5. Implementation tiers + +**Tier 1 — Node (`NodeExecutionEnv`).** Mostly a move, not new code. The logic in +`packages/agent/src/harness/utils/shell-output.ts` already implements all of it: +a rolling buffer held at `DEFAULT_MAX_BYTES * 2` (`trimToLastUtf8Bytes`), +`truncateTail` applied at snapshot time, and lazy spill via `ensureFullOutputFile` +/ `appendFullOutput`. Relocate it into the env, generalise `truncateTail` to the +three retention modes, add coalescing. + +Note the existing two-stage structure and keep it: the raw buffer is trimmed to +`maxBytes * 2` as a memory guard, and the retained view is computed from it per +snapshot. `truncateTail` is line-aware — it walks backwards line by line and takes +a partial only when a single line alone exceeds `maxBytes` — so eviction is not +byte arithmetic. + +**Tier 2 — pi helper.** Same TypeScript on the far side of the connection. +Nothing to design. + +**Tier 3 — dumb SSH, shell script.** Implementable: + +```sh +cmd 2>&1 | tee "$SPILL" & +pid=$! +while kill -0 $pid 2>/dev/null; do + printf '\0SNAP\0'; tail -c "$MAXB" "$SPILL" + sleep "$INTERVAL" +done +``` + +Spill via `tee`, bounded snapshots via `tail -c` against the spill file. +`retain: "head"` is `head -c` and can stop reading early. Needs a framing +sentinel and a writable remote path. + +This tier can emit `snapshot` and `counters` but the chunk path is degraded, +because a `tail -c` pipeline buffers. That is acceptable: `snapshot` is +self-contained, so a snapshot-only stream is correct, just less live. It reports +`liveUpdates: false`. + +`capabilities()` exists so this degradation is declared rather than silent. A +backend reporting `capture: false` forces the caller to apply limits locally, +accepting that the bytes crossed the wire. + +## 6. Migration + +**Breaking.** `exec` no longer returns `{ stdout, stderr }`. Verified that no +caller consumes them separately — only `nodejs.ts` internals touch +`child.stdout` / `child.stderr` — so aggregating in arrival order costs nothing. +`chunk` carries `stream` so a renderer can still colour stderr. + +**`onStdout` / `onStderr` → `onUpdate`.** The callback-error path +(`callback_error`, abort on throw) carries over unchanged. + +**`shell-output.ts` collapses.** `executeShellWithCapture`, `createProgress`, +`tailOutput`, `ensureFullOutputFile` all move into the env. + +**`bash.ts` loses its truncation entirely.** No rolling buffer, no `truncateTail`, +no `createTempFile`, no `BASH_UPDATE_THROTTLE_MS`. `fullOutputPath` becomes +`spillPath` returned in `CapturedOutput`. Its `details` reduce to whatever remains +tool-specific. + +**`BASH_CHECKPOINT_INTERVAL_MS` goes, and nothing here replaces it.** The durable +cadence is not a `Shell` concern. + +The shipped `bash.after.ts` checkpoints when the update kind is `snapshot`, on the +reasoning that a snapshot is the only kind that can express eviction. **That is +wrong and should not be copied.** `nowCrossed` is sticky: once total output +exceeds the cap it never goes back, so every subsequent update is a snapshot. At +`intervalMs = 100` that is a durable write every 100 ms — the same cadence, not a +lower one. + +Two rates genuinely exist. The UI wants roughly 100 ms; durability wants roughly +two seconds. But a *tool* has no basis for choosing the second: it does not know +what a durable write costs, whether there is a sidecar, or what recovery is worth. +That belongs to the sink, and it is settled in `04-tool-output` — where the +`checkpoint` flag comes off the tool-facing API entirely. + +**`read.ts`** keeps its own truncation for now — it reads files rather than +running commands, so it does not go through `Shell`. Unifying that is a separate +question (§8). + +### 6.1 Three things that only surface when you run it + +Each of these passes typecheck and fails at runtime. + +**Await the spill chain before the final snapshot.** The spill file is created +lazily, on the first chunk, so its path does not exist yet when `exec` settles. +Snapshotting before that promise resolves reports `spillPath: undefined` for +output that *was* archived — the file is on disk, the caller just never learns +where. Await the chain, then `capture.flush()`, then snapshot. + +**Counting newlines is not counting lines.** A command emitting one 60 KB line +with no trailing newline has produced **one** line, not zero. The off-by-one +surfaces as a user-visible message reading `line 0`, and only for the +single-oversized-line case. Count newlines, plus one if the buffer does not end +with one. + +**`lastLineBytes` can only come from the env.** When one line alone exceeds the +byte cap, the footer reports that line's own size — and only the env holds the +raw buffer, so a tool cannot recover it from the retained view. +`CapturedOutput.lastLineBytes` carries it. + +There is also a portability trap in the shipped code: **no TypeScript parameter +properties.** `constructor(readonly x: T)` is rejected outright by Node's +`--experimental-strip-types`, which is the mode these files are meant to run +under. + +## 7. Spill lifetime + +The spill exists so the **model** can read it. That fixes its location: inside the +exec env, on the machine where the model's other tools run. + +It must **not** be deleted when the tool settles — the model may `grep` a spill +from an earlier turn in the same session. So spill lifetime is session-scoped, not +invocation-scoped, and something has to sweep it. + +Current behaviour is `createTempFile` into `/tmp` with OS-dependent cleanup, which +is the complaint that started this. Proposed: a session-scoped directory the env +owns, swept on session end, with a retention floor for crash cases. OpenCode uses +7-day retention on its `tool-output/` directory as precedent. + +## 8. Open questions + +- **Session-scoped spill directory.** Who creates it, who sweeps it, and what the + path looks like to the model. Needs an env-level concept that does not exist yet. +- **`read.ts` and other non-shell bulk output.** Should `FileSystem` grow the same + capture policy, or does `ToolOutput` handle those on the agent side because the + bytes already crossed? Leaning to the latter, since a file read is a single call + rather than a stream. +- **UTF-8 chunk boundaries.** A chunk boundary landing mid-codepoint bakes in a + replacement character. `setEncoding("utf8")` on the Node streams handles this + today; a tier-3 shell script does not. Codex spends real effort here + (`bytes_to_string_smart`, `chardetng`) — we should at minimum not corrupt UTF-8. +- **`counters` cadence.** Fixed 2 s, or derived from `intervalMs`. +- Whether head+tail retention is worth adding later, and if so whether the two + halves need separate budgets. +- **Serving spill to a UI.** When the worker is remote, the server cannot read the + spill file, so a "view full output" affordance must go through the worker. Out of + scope here, but it constrains the UI design. +- **Binary output.** A command emitting binary to stdout currently becomes lossy + UTF-8. Whether `Shell` should detect and refuse, or spill raw bytes and retain a + marker, is undecided. diff --git a/packages/agent/docs/mobile-handoff/01-harness/03-execenv/nodejs-env.diff b/packages/agent/docs/mobile-handoff/01-harness/03-execenv/nodejs-env.diff new file mode 100644 index 000000000..2e3719970 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/03-execenv/nodejs-env.diff @@ -0,0 +1,137 @@ +diff --git a/packages/agent/src/harness/env/nodejs.ts b/packages/agent/src/harness/env/nodejs.ts +index 732dc84db..db9eb9a57 100644 +--- a/packages/agent/src/harness/env/nodejs.ts ++++ b/packages/agent/src/harness/env/nodejs.ts +@@ -19,6 +19,7 @@ import { basename, isAbsolute, join, resolve } from "node:path"; + import { createInterface } from "node:readline"; + import { fileURLToPath } from "node:url"; + import type { Context } from "../context.ts"; ++import { OutputCapture } from "../utils/output-capture.ts"; + import { + type ExecutionEnv, + ExecutionError, +@@ -28,7 +29,10 @@ import { + type FileKind, + ok, + type Result, ++ type ShellCapabilities, + type ShellExecOptions, ++ type ShellExecResult, ++ type ShellStream, + toError, + } from "../types.ts"; + +@@ -365,11 +369,15 @@ export class NodeExecutionEnv implements ExecutionEnv { + return ok(join(...parts)); + } + ++ capabilities(): ShellCapabilities { ++ return { capture: true, spill: true, liveUpdates: true }; ++ } ++ + async exec( + command: string, + options: ShellExecOptions | undefined, + context: Context, +- ): Promise> { ++ ): Promise> { + const signal = context.abortSignal; + if (signal?.aborted) return err(new ExecutionError("aborted", "aborted")); + const timeoutMsResult = resolveTimeoutMs(options?.timeout); +@@ -392,9 +400,28 @@ export class NodeExecutionEnv implements ExecutionEnv { + ); + } + ++ // Spill lives in the exec env, on the machine where the model's own `read` ++ // and `grep` run — the whole reason capping happens here rather than on the ++ // agent side (`executionenv.md` §1). ++ let spillHandle: string | undefined; ++ let spillChain: Promise = Promise.resolve(); ++ const wantSpill = options?.capture?.spill === true; ++ + return await new Promise((resolvePromise) => { +- let stdout = ""; +- let stderr = ""; ++ const capture = new OutputCapture(options?.capture, options?.onUpdate); ++ const spillChunk = (chunk: string): Promise => { ++ if (!wantSpill) return Promise.resolve(); ++ spillChain = spillChain.then(async () => { ++ if (spillHandle === undefined) { ++ const created = await this.createTempFile({ prefix: "pi-output-", suffix: ".log" }, context); ++ if (!created.ok) return; ++ spillHandle = created.value; ++ capture.setSpillPath(spillHandle); ++ } ++ await this.appendFile(spillHandle, chunk, context); ++ }); ++ return spillChain; ++ }; + let settled = false; + let timedOut = false; + let callbackError: ExecutionError | undefined; +@@ -407,7 +434,7 @@ export class NodeExecutionEnv implements ExecutionEnv { + } + }; + +- const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => { ++ const settle = (result: Result) => { + if (timeoutId) clearTimeout(timeoutId); + if (signal) signal.removeEventListener("abort", onAbort); + if (child?.pid) this.activeChildPids.delete(child.pid); +@@ -458,31 +485,32 @@ export class NodeExecutionEnv implements ExecutionEnv { + } + } + ++ // setEncoding("utf8") is what keeps a chunk boundary from splitting a ++ // codepoint; without it a multi-byte sequence straddling a read bakes in a ++ // replacement character (`executionenv.md` §8). + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); +- child.stdout?.on("data", (chunk: string) => { +- stdout += chunk; +- try { +- options?.onStdout?.(chunk, context); +- } catch (error) { +- const cause = toError(error); +- callbackError = new ExecutionError("callback_error", cause.message, cause); +- onAbort(); +- } +- }); +- child.stderr?.on("data", (chunk: string) => { +- stderr += chunk; ++ const feed = (stream: ShellStream, chunk: string) => { + try { +- options?.onStderr?.(chunk, context); ++ capture.push(stream, chunk, context); ++ void spillChunk(chunk); + } catch (error) { + const cause = toError(error); + callbackError = new ExecutionError("callback_error", cause.message, cause); + onAbort(); + } +- }); ++ }; ++ child.stdout?.on("data", (chunk: string) => feed("stdout", chunk)); ++ child.stderr?.on("data", (chunk: string) => feed("stderr", chunk)); + + void waitForChildProcess(child).then( +- (code) => { ++ async (code) => { ++ // The spill chain is async and lazy: the file is created on the first ++ // chunk. Await it before snapshotting, or the returned CapturedOutput ++ // reports no spillPath for output that was in fact archived. ++ await spillChain; ++ capture.flush(context); ++ capture.dispose(); + if (callbackError) { + settle(err(callbackError)); + return; +@@ -495,7 +523,7 @@ export class NodeExecutionEnv implements ExecutionEnv { + settle(err(new ExecutionError("aborted", "aborted"))); + return; + } +- settle(ok({ stdout, stderr, exitCode: code ?? 0 })); ++ settle(ok({ output: capture.snapshot(), exitCode: code ?? 0 })); + }, + (error: Error) => settle(err(new ExecutionError("spawn_error", error.message, error))), + ); diff --git a/packages/agent/docs/mobile-handoff/01-harness/03-execenv/nodejs-env.test.diff b/packages/agent/docs/mobile-handoff/01-harness/03-execenv/nodejs-env.test.diff new file mode 100644 index 000000000..c27be5981 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/03-execenv/nodejs-env.test.diff @@ -0,0 +1,149 @@ +diff --git a/packages/agent/test/harness/nodejs-env.test.ts b/packages/agent/test/harness/nodejs-env.test.ts +index 361040495..ee28ef506 100644 +--- a/packages/agent/test/harness/nodejs-env.test.ts ++++ b/packages/agent/test/harness/nodejs-env.test.ts +@@ -8,7 +8,6 @@ import { afterEach, describe, expect, it } from "vitest"; + import { BACKGROUND_CONTEXT, withAbortSignal } from "../../src/harness/context.ts"; + import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; + import { FileError, getOrThrow } from "../../src/harness/types.ts"; +-import { executeShellWithCapture } from "../../src/harness/utils/shell-output.ts"; + import { createTempDir } from "./session-test-utils.ts"; + + const chmodRestorePaths: string[] = []; +@@ -303,7 +302,8 @@ describe("NodeExecutionEnv", () => { + BACKGROUND_CONTEXT, + ), + ); +- expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 }); ++ expect(result.output.text).toContain(`${await realpath(root)}:ok`.trim()); ++ expect(result.exitCode).toBe(0); + }); + + it.each([ +@@ -334,7 +334,7 @@ describe("NodeExecutionEnv", () => { + ), + ); + +- expect(result.stdout).toBe(`${expectedSessionFile}|true|preserved`); ++ expect(result.output.text).toBe(`${expectedSessionFile}|true|preserved`); + }, + ); + +@@ -358,7 +358,7 @@ describe("NodeExecutionEnv", () => { + ), + ); + +- expect(result.stdout).toBe("::explicit"); ++ expect(result.output.text).toBe("::explicit"); + } finally { + if (previousInherited === undefined) delete process.env[inheritedKey]; + else process.env[inheritedKey] = previousInherited; +@@ -396,7 +396,9 @@ describe("NodeExecutionEnv", () => { + await wslEnv.exec(`name='World'; echo "Hello, ${nameExpansion}!"`, undefined, BACKGROUND_CONTEXT), + ); + +- expect(result).toEqual({ stdout: "Hello, World!\n", stderr: "args:-s\n", exitCode: 0 }); ++ expect(result.output.text).toContain("Hello, World!"); ++ expect(result.output.text).toContain("args:-s"); ++ expect(result.exitCode).toBe(0); + } finally { + process.chdir(originalCwd); + process.env.PATH = originalPath; +@@ -425,7 +427,7 @@ describe("NodeExecutionEnv", () => { + () => controller.abort(), + ), + ); +- expect(result.stdout).toContain("child-exiting"); ++ expect(result.output.text).toContain("child-exiting"); + } finally { + controller.abort(); + cleanupDetachedChild(pidFile); +@@ -448,25 +450,30 @@ describe("NodeExecutionEnv", () => { + it("streams stdout and stderr chunks", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); +- let stdout = ""; +- let stderr = ""; ++ // Streams are aggregated in arrival order now; `chunk` carries `stream` so a ++ // renderer can still colour stderr (`executionenv.md` §6). ++ let seen = ""; ++ const streams: string[] = []; + const result = getOrThrow( + await env.exec( + "printf out; printf err >&2", + { +- onStdout: (chunk) => { +- stdout += chunk; +- }, +- onStderr: (chunk) => { +- stderr += chunk; ++ onUpdate: (update) => { ++ if (update.kind !== "chunk") return; ++ seen += update.text; ++ streams.push(update.stream); + }, + }, + BACKGROUND_CONTEXT, + ), + ); +- expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 }); +- expect(stdout).toBe("out"); +- expect(stderr).toBe("err"); ++ expect(result.exitCode).toBe(0); ++ expect(result.output.text).toContain("out"); ++ expect(result.output.text).toContain("err"); ++ expect(seen).toContain("out"); ++ expect(seen).toContain("err"); ++ expect(streams).toContain("stdout"); ++ expect(streams).toContain("stderr"); + }); + + it("reports a missing working directory before spawning", async () => { +@@ -484,7 +491,8 @@ describe("NodeExecutionEnv", () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const result = getOrThrow(await env.exec("exit 7", undefined, BACKGROUND_CONTEXT)); +- expect(result).toEqual({ stdout: "", stderr: "", exitCode: 7 }); ++ expect(result.output.text).toContain("".trim()); ++ expect(result.exitCode).toBe(7); + }); + + it("returns timeout errors for commands exceeding the timeout", async () => { +@@ -501,7 +509,7 @@ describe("NodeExecutionEnv", () => { + const result = await env.exec( + "printf out", + { +- onStdout: () => { ++ onUpdate: () => { + throw new Error("callback failed"); + }, + }, +@@ -542,12 +550,23 @@ describe("NodeExecutionEnv", () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const result = getOrThrow( +- await executeShellWithCapture(env, "yes line | head -n 15000", undefined, BACKGROUND_CONTEXT), ++ await env.exec( ++ "yes line | head -n 15000", ++ { ++ capture: { ++ limits: { maxBytes: 50 * 1024, maxLines: 2000, retain: "tail" }, ++ spill: true, ++ }, ++ }, ++ BACKGROUND_CONTEXT, ++ ), + ); +- expect(result.truncated).toBe(true); +- expect(result.fullOutputPath).toBeDefined(); +- const fullOutput = getOrThrow(await env.readTextFile(result.fullOutputPath!, BACKGROUND_CONTEXT)); ++ expect(result.output.truncation.truncated).toBe(true); ++ // The spill lives inside the exec env, on the machine the command ran on — ++ // which is where the model's own read and grep run (`executionenv.md` §1). ++ expect(result.output.spillPath).toBeDefined(); ++ const fullOutput = getOrThrow(await env.readTextFile(result.output.spillPath!, BACKGROUND_CONTEXT)); + expect(fullOutput.split("\n").length).toBeGreaterThan(10000); +- expect(result.output.length).toBeLessThan(fullOutput.length); ++ expect(result.output.text.length).toBeLessThan(fullOutput.length); + }); + }); diff --git a/packages/agent/docs/mobile-handoff/01-harness/03-execenv/output-capture.ts b/packages/agent/docs/mobile-handoff/01-harness/03-execenv/output-capture.ts new file mode 100644 index 000000000..3d10fea34 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/03-execenv/output-capture.ts @@ -0,0 +1,194 @@ +import type { Context } from "../context.ts"; +import type { CaptureOptions, CapturedOutput, ShellOutputUpdate, ShellStream } from "../types.ts"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + type TruncationResult, + truncateHead, + truncateTail, + utf8ByteLength, +} from "./truncate.ts"; + +/** + * Bounded capture for a Shell implementation. + * + * Owns the two-stage structure that `shell-output.ts` had inside `bash.ts`: a raw + * buffer trimmed to `maxBytes * 2` as a memory guard, and a retained view + * computed from it per snapshot. `truncateTail` is line-aware — it walks + * backwards line by line and takes a partial only when one line alone exceeds the + * cap — so eviction is not byte arithmetic and cannot be done incrementally. + * + * Emits the three update kinds from `executionenv.md` §4 and enforces the + * ordering invariant: once the cap is crossed, a `chunk` never follows without an + * intervening `snapshot`. + */ +export class OutputCapture { + readonly #maxBytes: number; + readonly #maxLines: number; + readonly #retain: "head" | "tail"; + readonly #intervalMs: number; + readonly #onUpdate?: (update: ShellOutputUpdate, context: Context) => void; + + /** Raw rolling buffer, held at twice the cap. */ + #buffer = ""; + /** Everything ever produced. */ + #totalBytes = 0; + /** Newlines seen. A trailing partial line is counted separately — a command + * emitting one 60 KB line with no newline has produced one line, not zero. */ + #newlines = 0; + #endsWithNewline = true; + /** True once the retained view has begun evicting (tail) or filled (head). */ + #crossed = false; + /** Pending chunks for the current coalescing window. */ + #pending: { stream: ShellStream; text: string }[] = []; + #lastFlush = 0; + #timer: ReturnType | undefined; + #spillPath: string | undefined; + + constructor( + options: CaptureOptions | undefined, + onUpdate: ((update: ShellOutputUpdate, context: Context) => void) | undefined, + ) { + this.#maxBytes = options?.limits.maxBytes ?? DEFAULT_MAX_BYTES; + this.#maxLines = options?.limits.maxLines ?? DEFAULT_MAX_LINES; + this.#retain = options?.limits.retain ?? "tail"; + this.#intervalMs = options?.intervalMs ?? 0; + this.#onUpdate = onUpdate; + } + + push(stream: ShellStream, text: string, context: Context): void { + if (text === "") return; + this.#totalBytes += utf8ByteLength(text); + this.#newlines += countNewlines(text); + this.#endsWithNewline = text.endsWith("\n"); + this.#buffer += text; + + // Memory guard. The retained view is computed from this, never equal to it. + const guard = this.#maxBytes * 2; + if (utf8ByteLength(this.#buffer) > guard) { + this.#buffer = trimToLastBytes(this.#buffer, guard); + } + + this.#pending.push({ stream, text }); + this.#schedule(context); + } + + setSpillPath(path: string | undefined): void { + this.#spillPath = path; + } + + /** The bounded view, recomputed from the raw buffer. */ + snapshot(): CapturedOutput { + const truncation = + this.#retain === "head" + ? truncateHead(this.#buffer, { maxBytes: this.#maxBytes, maxLines: this.#maxLines }) + : truncateTail(this.#buffer, { maxBytes: this.#maxBytes, maxLines: this.#maxLines }); + // The buffer already lost its front, so totals come from counters rather + // than from what survives. + const totalLines = this.#totalLines(); + const truncated = this.#totalBytes > this.#maxBytes || totalLines > this.#maxLines; + return { + text: truncation.content, + truncation: { + ...truncation, + lastLinePartial: truncation.lastLinePartial, + truncated, + truncatedBy: truncated ? (totalLines > this.#maxLines ? "lines" : "bytes") : null, + totalBytes: this.#totalBytes, + totalLines, + }, + ...(this.#spillPath === undefined ? {} : { spillPath: this.#spillPath }), + ...(truncation.lastLinePartial ? { lastLineBytes: this.#lastLineBytes() } : {}), + }; + } + + #totalLines(): number { + return this.#newlines + (this.#endsWithNewline || this.#totalBytes === 0 ? 0 : 1); + } + + /** Bytes in the final line of the raw buffer, for the single-oversized-line case. */ + #lastLineBytes(): number { + const start = this.#buffer.lastIndexOf("\n"); + return utf8ByteLength(start === -1 ? this.#buffer : this.#buffer.slice(start + 1)); + } + + #schedule(context: Context): void { + if (this.#onUpdate === undefined) return; + if (this.#intervalMs === 0) { + this.flush(context); + return; + } + if (this.#timer !== undefined) return; + const wait = Math.max(0, this.#lastFlush + this.#intervalMs - Date.now()); + this.#timer = setTimeout(() => { + this.#timer = undefined; + this.flush(context); + }, wait); + } + + /** Emit one update for everything buffered since the last flush. */ + flush(context: Context): void { + if (this.#onUpdate === undefined) return; + this.#lastFlush = Date.now(); + const pending = this.#pending; + this.#pending = []; + if (pending.length === 0) return; + + const nowCrossed = this.#totalBytes > this.#maxBytes || this.#totalLines() > this.#maxLines; + + if (this.#retain === "head") { + // Head never evicts. Below the cap the appended text is complete; above + // it, only totals move — resending maxBytes to change a number is the + // thing `counters` exists to avoid. + if (!nowCrossed) { + this.#onUpdate({ kind: "chunk", stream: pending[0]!.stream, text: joined(pending) }, context); + } else { + this.#onUpdate({ kind: "counters", truncation: this.snapshot().truncation }, context); + } + this.#crossed = nowCrossed; + return; + } + + // Tail. Once the buffer is full every byte in evicts a byte out, so there is + // no cheap-trickle regime past the cap: chunks until full, snapshots after. + if (nowCrossed) { + this.#crossed = true; + this.#onUpdate({ kind: "snapshot", output: this.snapshot() }, context); + return; + } + if (this.#crossed) { + // Defensive: never let a chunk follow the crossing. + this.#onUpdate({ kind: "snapshot", output: this.snapshot() }, context); + return; + } + this.#onUpdate({ kind: "chunk", stream: pending[0]!.stream, text: joined(pending) }, context); + } + + dispose(): void { + if (this.#timer !== undefined) clearTimeout(this.#timer); + this.#timer = undefined; + } +} + +const joined = (pending: { text: string }[]): string => pending.map((p) => p.text).join(""); + +function countNewlines(text: string): number { + let count = 0; + for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) count++; + return count; +} + +/** Keep the last `bytes` UTF-8 bytes without splitting a codepoint. */ +function trimToLastBytes(text: string, bytes: number): string { + if (utf8ByteLength(text) <= bytes) return text; + let low = 0; + let high = text.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (utf8ByteLength(text.slice(mid)) > bytes) low = mid + 1; + else high = mid; + } + return text.slice(low); +} + +export type { TruncationResult }; diff --git a/packages/agent/docs/mobile-handoff/01-harness/04-tool-output/bash-worked-example.md b/packages/agent/docs/mobile-handoff/01-harness/04-tool-output/bash-worked-example.md new file mode 100644 index 000000000..2dca46600 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/04-tool-output/bash-worked-example.md @@ -0,0 +1,219 @@ +# Worked Example: `bash` End to End + +One tool through every layer. `execute` is simplified — timeout validation, +cancellation and exit-code branches are elided. Everything on the output path is +shown. + +Layers: exec env → `ToolOutput` sink → harness events → durable storage → lane +state → facet → wire → consumer. + +--- + +## 1. The tool + +```ts +// packages/agent/src/harness/tools/bash.ts +export interface BashToolDetails { spillPath?: string } + +export function createBashTool(): AgentHarnessTool { + return { + name: "bash", + parameters: bashSchema, + output: { retain: "tail", maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES }, + + async execute(_id, { command, timeout }, signal, out, context) { + const env = context.env; + + const result = getOrThrow(await env.exec(command, { + cwd: env.cwd, + inheritEnv: true, + timeout, + capture: { limits: this.output, intervalMs: 500, spill: true }, + onUpdate: (u) => { + if (u.kind === "chunk") out.write(u.text); + if (u.kind === "snapshot") out.replace(u.output.text); + // counters: nothing to do, truncation totals ride the snapshot + }, + }, context)); + + if (result.output.spillPath) out.details.spillPath = result.output.spillPath; + if (result.output.truncation.truncated) out.write(`\n\n[${describe(result.output.truncation)}]`); + if (result.exitCode) throw new Error(`Command exited with code ${result.exitCode}`); + }, + }; +} +``` + +What is gone from today's implementation: the rolling `tailOutput` buffer, +`truncateTail`, `ensureFullOutputFile`, `createTempFile`, +`BASH_UPDATE_THROTTLE_MS`, `BASH_CHECKPOINT_INTERVAL_MS`, `updateDirty`, +`lastCheckpoint`, `scheduleOutputUpdate`, `emitOutputUpdate`, +`clearUpdateTimer`. About 40 lines of tool-local machinery, replaced by a capture +policy every tool gets. + +`execute` returns `void`. `details` is one field, set once — and note the tool no +longer computes `truncation`, because the env owns the window and the tool no +longer knows what was dropped. + +## 2. The exec env caps at the source + +`env.exec` applies `OutputLimits` where the bytes originate. For a sandbox host +that means `cat 1gb.txt` never ships 1 GB to the agent machine, and the spill +lands where the model's own `read` and `grep` run. See `executionenv.md`. + +Under the cap it emits `chunk`. Once the window starts evicting it emits +`snapshot`, because an append cannot express "and 20 bytes fell off the front". +For `retain: "tail"` there is no cheap-trickle regime past the cap — every byte +in evicts a byte out — so it is chunks until full, snapshots thereafter. + +## 3. The sink + +`ToolOutput` folds those into `ToolOutputState`: + +```ts +{ + content: [{ type: "text", text: "…the retained window…" }], + details: { spillPath: "/tmp/pi-session-x/bash-8f2.log" }, + usage: undefined, + addedTools: undefined, + terminate: false, + truncation: { truncated: true, truncatedBy: "bytes", totalLines: 8123, totalBytes: 262144 }, +} +``` + +`out.write(text)` appends to `content[0].text`; `out.replace(text)` assigns +the whole retained view when the env sends a `snapshot`, because an append cannot +express eviction. The tracker (`delta.md`) records intent either way: an append is +an `a`, and a window slide assigned wholesale becomes `t` + `a` via verified +overlap detection. + +## 4. Ops + +A 256 KB build over ~20 flushes, 50 KB window: + +The first batch is a base batch — it begins with `r`, carrying the initial state. Everything +after is deltas. The repeated content path is interned on its second use, and +consecutive ops on it omit the id entirely: + +```jsonc +["s",[],{"content":[{"type":"text","text":""}],"details":{},"terminate":false,"truncation":{…}}] + +["#",0,["content",0,"text"]] +["a",0,"make: Entering directory …\n"] +["a","cc -c src/a.c …\n"] +… +["s",["details","spillPath"],"/tmp/pi-session-x/bash-8f2.log"] +… +["t",0,4096] +["a","cc -c src/z.c\n"] +["s",["truncation","totalBytes"],262144] +``` + +Details are one `s`, in one flush out of twenty. The `t` + `a` pair is the window +slide, stated by the sink rather than inferred — the case a differ degrades on. + +## 5. Harness events + +```ts +{ type: "tool_start", toolCallId: "call_7", toolName: "bash", + args: { command: "make -j8" } } + +{ type: "tool_update", toolCallId: "call_7", ops: [ … ] } + +{ type: "tool_end", toolCallId: "call_7", isError: false } +``` + +`tool_start` is identity only — no `caps`, no `initial`. The first batch is a base +batch, so the initial state arrives on the update channel, and the caps are +already inside it as `truncation.maxBytes` / `maxLines`. + +`tool_end` carries no content and no details. Every byte already went out; +re-sending would duplicate any images. + +## 6. Durable storage + +`pendingToolOutput(operationId, "call_7")`, **ephemeral-scoped** to the operation, +so it lives in `.op_….jsonl` and is retired on settle rather than +persisting in the main log forever. Retirement is a main-log `retireScope` record, +so it commits atomically with the settle writes; the unlink is a consequence of +replaying that record, not part of the transaction (`session-scopes.md` §5). + +A `list`, one batch appended per flush, base batches tagged `"base"` so +recovery reads backwards with `stopAtTag` and stops there +(`session-scopes.md` §11). Written under the adaptive rule (`delta.md` §5) — ops +while they are smaller than a root set, otherwise the root set. For `make -j8` trickling a few KB per +checkpoint that is ops; for `cat 1gb.txt` it is capped snapshots. The write +interval is the crash-loss knob and lives in the capture policy. + +Recovery seeds a fresh `ToolOutput` from that state — it does **not** delete it, +which is what `clearReplayCheckpoint` does today and is a bug +(`harness-tools.md` §7.4). If bash memoised "spilled to /tmp/…" and crashed, a +replay that discarded the state would create a second spill file and lose the +first. + +The memo write and this checkpoint commit in the same transaction. Both are +ephemeral-scoped, so both land in the same sidecar — and the type system rejects a +commit that mixes scopes, so this cannot regress silently. + +## 7. Lane state and the facet + +```ts +export function reduceLaneSnapshot(view: LaneView, event: HarnessEvent): void { + switch (event.type) { + case "tool_start": + view.operation.tools.push({ id: event.toolCallId, name: event.toolName, + args: event.args, output: undefined }); + return; + case "tool_update": { + const tool = view.operation.tools.find((t) => t.id === event.toolCallId); + if (tool === undefined) return; // host will send a base batch + tool.output = apply(tool.output, event.ops); + return; + } + case "tool_end": { + const i = view.operation.tools.findIndex(t => t.id === event.toolCallId); + if (i >= 0) view.operation.tools.splice(i, 1); + return; + } + } +} +``` + +Plain mutation, no Immer, no return value. An event for a tool the view has never +seen simply does nothing; the host sends a `replace`. + +A lane facet runs this same function under the tracker, so the facet's own ops +fall out against **its** shape — which need not be `LaneView` and generally is +not. The facet never writes an op. + +## 8. Wire and consumer + +```jsonc +{ "seq": 0, "ops": [["s",[],{"transcript":[],"operation":null}]] } +{ "seq": 1, "ops": [ … ] } +``` + +One shape: a batch of ops. The first is a base batch — it begins with `r` — +and everything after is deltas. A gap, a reconnect, a provider +reload, or a fold that could not apply all take one path: send a fresh `replace`. + +The consumer is `apply` — five verbs, no domain knowledge, no library, no tool +code, against a plain mutable object it owns. + +## 9. What this run costs + +256 KB of output, ~20 flushes, 50 KB window: + +| | today | this design | +|---|---|---| +| durable writes | full `AgentToolResult` per checkpoint | ops, or a capped snapshot when smaller | +| durable location | main log, forever | sidecar, unlinked on settle | +| wire per flush | whole snapshot | one `truncate` + one `append` | +| details written | rebuilt whole, every flush | one `set`, once | +| truncation logic | in `bash.ts` | in the exec env, shared by all tools | +| spill location | `/tmp`, OS-cleaned | exec env, session-scoped | + +The details row is the one worth dwelling on. Nothing about bash's details +changed — they were always small. What changed is that they stopped riding inside +a container replaced wholesale on every update, which is why no tool anywhere +currently bothers to mutate them incrementally. diff --git a/packages/agent/docs/mobile-handoff/01-harness/04-tool-output/harness-tools.md b/packages/agent/docs/mobile-handoff/01-harness/04-tool-output/harness-tools.md new file mode 100644 index 000000000..a629d5984 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/04-tool-output/harness-tools.md @@ -0,0 +1,493 @@ +# Tool Output and Progress + +> **Scope:** harness-local. Nothing here depends on the facet system, RPC, or any +> presentation. Depends on `delta.md` (op vocabulary and tracker), +> `executionenv.md` (where truncation happens), and `session-scopes.md` +> (durability). + +## 1. The problem + +Three faults, one cause. + +**Tools each implement their own truncation.** `bash.ts` owns a rolling buffer, +`truncateTail`, a spill file, an update throttle and a checkpoint interval. +`read.ts` has its own truncation. Every future tool producing bulk output +reimplements this, differently. + +**Progress is a whole value.** `onUpdate(partialResult)` hands over a complete +`AgentToolResult` on every update, `tool_update` carries that whole result, and +`openToolProgress` persists it with `setValue`. + +**`details: unknown` was believed to force this** — you cannot append to a value +whose shape you do not know. That premise is now false. A structural tracker +(`delta.md`) records ops against JSON without knowing the type, so details need +no special handling at all. + +## 2. `ToolOutput` + +The harness constructs a sink per invocation and passes it to `execute`. **Tools +return nothing**; the sink holds the result. + +```ts +interface ToolOutput { + /** Append to the text block. */ + write(text: string): void; + /** Append an image block. Images are never windowed. */ + image(image: ImageContent): void; + /** + * Replace the retained text wholesale. + * + * For output originating in the exec env, which emits `chunk` below the cap and + * `snapshot` once the window starts evicting (`executionenv.md` §4). An append + * cannot express eviction, so a snapshot has to assign. The tracker still + * recovers `t` + `a` from the assignment by verified overlap, so this is not a + * whole-value replacement on the wire. + */ + replace(text: string): void; + /** + * Record totals when the text itself did not change — `head` mode past the cap, + * where output keeps arriving but the retained view is frozen + * (`executionenv.md` §4). Without this a renderer cannot report how much was + * suppressed, and resending the capped text to move a counter is what + * `counters` exists to avoid. + */ + counters(truncation: TruncationResult): void; + + /** The tool's own details object. Mutate it directly. */ + readonly details: TDetails; + + /** Accumulates. A subagent making several model calls adds to it. */ + usage(usage: Usage): void; + /** Replaces. */ + addTools(names: string[]): void; + /** Replaces — a tool that decides to terminate and then recovers can say so. */ + terminate(value: boolean): void; +} +``` + +```ts +execute( + toolCallId: string, + params: Static, + signal: AbortSignal, + out: ToolOutput, + context: Context, +): Promise; +``` + +`execute` returns `void`. Everything the tool produces flows through the sink — +including `usage` and `addTools`, which cannot be settle-time return values +because a replayed tool must be able to seed from durable state (§7.4). + +**Failure is a thrown error.** `isError` is set by the harness when `execute` +rejects. A tool may throw anything, including errors from libraries it did not +write. + +**`terminate` is orthogonal to how execution ended**, so a failing tool can ask +the loop to stop: + +```ts +try { await thing(); } catch (error) { out.terminate(true); throw error; } +``` + +This closes a gap in the current implementation, where `executeToolCall`'s catch +hardcodes `isError: true` with no terminate, and `immediateError`'s terminate +parameter is only ever passed `true` by `applyBeforeToolDecision` for a hook +block. + +### 2.1 Details are just an object + +```ts +async execute(id, params, signal, out, context) { + out.details.total = 42; + out.details.passed += 1; + out.details.failures.push({ name, message }); + out.details.current = undefined; // -> delete +} +``` + +Fully granular ops fall out, verified against the prototype: + +```jsonc +["#",0,["details","passed"]] +["s",0,1] +["p",["details","failures"],0,0,[{…}]] +["d",["details","current"]] +``` + +No recipes, no mutation map, no `initialDetails` on `tool_start`, no Immer, and +no consumer ever runs tool code. `TDetails` remains the tool's own exported type +so a renderer can cast `call.details` to it — that is all it is for. + +A survey of `packages/agent/src/harness/tools/` found **no tool mutating details +incrementally today**; only `bash.ts` writes them mid-stream and it rebuilds them +whole, because the container was replaced wholesale anyway. This design removes +that cause, and costs nothing if nobody takes it up. + +**Caveat:** details are now unbounded. A tool pushing to `failures` in a loop +grows without limit, and unlike text there is no cap. Nothing shipped does this; +the door is open in a way it was not when details were a replace-only value. + +### 2.2 Partial output survives failure + +Today `executeToolCall` catches and returns `createErrorToolResult(message)`, +building a fresh result from the error string alone — a tool that streamed 8 KB +and then threw reports only the error. + +With the sink, what was written is already the result. The harness appends the +error text as content and keeps the rest. The error text is model-visible +content, not presentation: the model needs to read why the call failed. + +`abortedMessage` and `interruptedMessage` should be made consistent with this; +today they build replacement results via `syntheticMessage`, so a cancelled +long-running command loses its partial output too. + +## 3. Content + +Content is exactly **one text block followed by zero or more image blocks**. A +tool cannot interleave text between images — text written before and after an +image lands in the same block. Deliberate: truncation only ever touches a string, +and an image is never partially anything. + +Images are **never windowed**. A byte or line cap over base64 payloads is +meaningless and `truncateTail` operates on text. `maxBytes` / `maxLines` govern +text only. + +### 3.1 Retention mode + +Declared on the tool definition: + +```ts +output?: { + retain?: "head" | "tail"; // default "tail" + maxBytes?: number; + maxLines?: number; +} +``` + +**`head`** — append until the cap, then stop. Nothing is ever removed. Right for +output meaningful from the start: file reads, listings, greps. + +**`tail`** — a rolling window. Right for anything whose interesting part is at +the end: builds, test runs. + +Head+tail is **not** offered. `truncate.ts` exports `truncateHead` and +`truncateTail`; neither combines them and none is being added. + +### 3.2 The tool does not truncate — the exec env does + +For output originating in the execution environment, capping, coalescing and +spilling happen **at the source**. See `executionenv.md`. The tool passes its +`OutputLimits` into `env.exec` and pipes the resulting updates into the sink. + +This is not a convenience. Reading a 1 GB file on a sandbox host must not ship +1 GB to the agent machine to be capped there, and the spill file must land where +the model's own `read` and `grep` run. + +For output originating on the agent machine (subagents, in-process work), +`ToolOutput` applies the same logic locally. Same code, different location. + +Note this reverses an earlier decision that there is no spill in the sink and a +temp-file path is tool-specific. The exec-env argument — model reachability +across a machine boundary — is what changed it. + +## 4. Tool output state + +`tool_update` carries `Op[]` (`delta.md` §6) targeting the invocation's +`ToolOutputState`: + +```ts +interface ToolOutputState { + content: (TextContent | ImageContent)[]; + details: JsonValue; + usage?: Usage; + addedTools?: string[]; + terminate: boolean; + truncation: TruncationResult; // totals over everything ever written +} +``` + +Ops rather than a typed variant union, for one decisive reason: **only ops give +details granularity without the harness knowing `TDetails`**. A typed union would +need per-tool recipes, which is the machinery §2.1 deletes. + +Text still gets delta treatment, because the sink applies the cap *before* +mutating, so a rolling window produces `truncate` + `append` on +`content[0].text` rather than a whole-value set. + +Interned ops also measure **smaller than a typed frame vocabulary** on every +workload, and 10x smaller on details, because a frame repeats `toolCallId` where +an interned op carries one integer. See `delta.md` §4.1. + +There is no `drop` event. The sink knows what it evicted and expresses it as +`truncate`; a consumer needs no separate signal and derives nothing. + +## 5. Harness events + +```ts +| { type: "tool_start"; + runId; turnId; toolCallId; toolName; + args: unknown } + +| { type: "tool_update"; + runId; turnId; toolCallId; + ops: Op[] } // a base batch begins with `r` + +| { type: "tool_end"; + runId; turnId; toolCallId; + isError: boolean } +``` + +**`tool_start` carries identity only.** An earlier draft added `caps` and +`initial`; both were redundant and are gone. The first batch is always a base batch +(`delta.md` §6), so the initial state arrives on the update channel — carrying it +twice means two ways to establish the base, which can disagree. And `caps` are +already inside the state: `ToolOutputState.truncation` carries `maxBytes` and +`maxLines`, which is what a renderer needs to say "50 KB limit". The draft itself +conceded a consumer "no longer has to" fold identically, since the producer's ops +encode eviction — that was the last reason for `caps` to travel, and it does not +hold. + +`tool_update` carries ops. A base batch travels the same channel as a delta, +because a replacement is itself an op (`delta.md` §2) — there is no second shape. `message_update` has the identical shape +(`message-update.md` §5.1); a consumer folds tool output and assistant output with +one code path. + +**`tool_end` carries no content, no details, no usage, no terminate.** Every byte +already went out. Re-sending would duplicate each base64 image at the moment +nothing new has happened. + +> **The fold is the result.** Nothing a consumer folded is ever re-sent to +> confirm it. + +This dissolves an earlier open question about settle-time truncation disagreeing +with the running fold. There is no separate settle-time truncation: the sink's +window *is* the truncation. Anything a tool wants to add at the end — bash's +`[Showing lines 8000-8123 of 8123]` footer — is `out.write(footer)`, one more +append. + +The harness still assembles `AgentToolResult` in process for the model, and +`createToolResultMessage` still writes the settled `ToolResultMessage` to the +transcript with `content`, `details`, `usage`, `addedToolNames`, `isError`. +Neither is a wire event. + +## 6. Lane reduction + +```ts +export function reduceLaneSnapshot(view: LaneView, event: HarnessEvent): void; +``` + +Plain mutation on a plain object. No `Draft`, no `produce`, no Immer, and **no +`Rebase` return value** — a fold that cannot apply an event leaves state alone, +and the host sends a `replace`. (An earlier draft had `void | Rebase`; under +Immer that throws, and without Immer there is nothing to return to. Note +`reducer.ts:4` currently defines `LaneSnapshotReduction = LaneSnapshot | { rebase: true }`, +so this is a real change to existing code, and `navigation_end` is the event that +drives it.) + +The event is the only input besides the view. No registry, no `resolve`, no tool +code — so a tool rewritten between crash and resume cannot make a persisted +stream unreadable. + +When the harness is wrapped by a facet, the same mutation runs under the tracker +from `delta.md` and ops fall out. The harness itself does not know this. + +## 7. Durability + +### 7.1 What exists today + +`pendingToolOutput(operationId, invocationId)` is a +`value>`, and — importantly — `progress.write(partial)` +fires **only** when `options?.checkpoint === true` (`drive/tools.ts:325`). Not on +every update. bash checkpoints every 2 s with a `JSON.stringify` dedupe; every +other tool never checkpoints at all. + +It is also **not a progress buffer**. It is the interruption checkpoint. On +resume, if the tool is not replay-safe, `readCheckpoint` turns it into a real +`ToolResultMessage` for the model: `[...checkpoint.content, INTERRUPTION_MARKER]` +plus `details` and `usage`. + +Note the implication that misled an earlier draft: because the checkpoint must +*be* the current state, it was read as needing `value` semantics. It does not. It +needs to be *derivable*, and folding a list of frames back to the last base batch +derives it — which is the point of tagging base batchs (§7.3). `checkpoint: true` +requests a durable write, not a replacement. + +`pendingAssistantFrames` is a `list` appended per frame, +`deleteList` on settle in `response.ts:345`, `deferred.ts:157`, `terminal.ts:47`. + +Note `operationCleanupWrites` (`terminal.ts:26`) does four `scanValues` calls to +enumerate what to delete at settle. Under scopes, the two covering +`operationToolMemoPrefix` and `pendingToolOutputPrefix` are replaced by a single +`retireScope(operationId)`. + +### 7.2 Renaming + +`pendingAssistantFrames` → **`pendingAssistantOutput`**, matching +`pendingToolOutput`. Frames stop being the durable unit; both addresses now hold +tracked state written as ops or snapshots. + +### 7.3 What to write, and when + +Both addresses are **ephemeral-scoped** (`session-scopes.md`), so they live in a +sidecar retired on settle rather than in the main log forever. + +Two independent wins, in order of importance: + +- **Encoding.** Ops instead of whole values, plus address interning: 93.89 MB to + 5.32 MB in a single file, no change to atomicity. Do this first. +- **Scopes.** Pending state leaves the main log entirely: 5.32 MB to 0.06 MB + surviving. + +**Both are `list`**, not values. One batch is appended per flush, and a batch +whose first op is `r` is tagged `"base"` on the storage record. +Recovery reads backwards with `stopAtTag: "base"` and applies forward +(`delta.md` §9, `session-scopes.md` §11). + +Writing one flush: + +```ts +const ops = out.flush(); +if (ops.length === 0) return; +writes: [appendList(address, ops, isBase(ops) ? "base" : undefined)]; +``` + +`isBase` comes from the delta package. It is not `ops[0][0] === "s"`: a set on any +path is `"s"`, and what makes a base is that it targets the root. Classification +lives beside the vocabulary so the comparison is written once. + +Emission follows the adaptive rule from `delta.md` §5: + +> Write ops when they are smaller than a replacement; otherwise write the +> replacement — which is itself an op, `["s", [], value]`. + +The crossover is exactly at the cap. Below it ops win by up to 30x; above it +snapshots win unboundedly. A trickling build stays on cheap ops; `cat 1gb.txt` +degrades to capped snapshots. Sweep in `delta.md` §5 — **do not quote a single +ratio without it**, since an earlier draft claimed 18x from a rate that happened +to favour ops. + +**Checkpointing is not deleted.** An earlier draft called for removing +`BASH_CHECKPOINT_INTERVAL_MS`; that was wrong. Checkpoint frequency is the knob +trading durable write volume against how much a crash loses, and it belongs in +the exec env's capture policy now rather than in bash. + +### 7.4 Replay must seed, not discard + +`clearReplayCheckpoint` currently writes `deleteValue(pendingToolOutput(...))` +before re-executing a replay-safe tool (`drive/tools.ts:257`). **This is a bug.** + +Replay-safe means the tool is re-executed, but memos exist precisely so it does +*not* redo work it already did — and skipped work emits nothing. Any output for +memoised work is lost today. + +The fix: seed a fresh `ToolOutput` from the durable state, then re-execute. The +tool appends to a sink that already holds what it produced before the crash. + +This is also why nothing may be settle-only. `usage` and `addTools` must survive +a seed, so they flow through the sink like everything else. + +### 7.5 The memo invariant + +> A tool's memo write and its output checkpoint must commit in the same +> transaction. + +Otherwise a tool does work, sets a memo, crashes before the next checkpoint, and +on replay skips the work while the seeded output has no record of it. + +**This does not hold today.** `setMemo` (`drive/tools.ts:112`) and `openProgress` +(`runtime/progress.ts:44`) are two separate `lane.command` calls, hence two +transactions. Making it hold means bundling the checkpoint into the memo's commit: + +```ts +setMemo(name, value) { + validateMemoName(name); + if (!active) return Promise.reject(ended()); + return lane.command((state) => { + if (!ownsEffect(state)) return { kind: "reject", error: ended() }; + const memo = operationToolMemo(drive.operationId, call.resultEntryId, name); + return { + kind: "commit", + writes: [ + value === undefined ? deleteValue(memo) : setValue(memo, value), + setValue(pendingToolOutput(drive.operationId, call.resultEntryId), out.snapshot()), + ], + next: state, + materialize: () => undefined, + }; + }, drive.context); +} +``` + +Both addresses are **ephemeral-scoped**, so this is a single-file transaction and +statically enforced as one (`session-scopes.md` §3, §6). `operationToolMemo` is +placed in that scope precisely for this. Ordering on the Session line would not be +enough — two file writes are not atomic. The periodic checkpoint stays as it +is — best-effort, for the interruption path; this forces one where correctness +requires it. + +The invariant holds because the tool does X, writes X's output to the sink, *then* +calls `setMemo("did X")` — so the sink's state at commit time already contains X's +output. + +**Ordering alone does not work**, in case it looks tempting: + +- memo first, checkpoint second → crash between → replay skips X and the seeded + output lacks it → silent loss; +- checkpoint first, memo second → crash between → replay redoes X and appends + again → duplicated output. + +Duplication is the less bad failure, so ordered writes are a tolerable fallback, +but neither is correct. + +**Cost:** `setMemo` now writes a full capped output state rather than a small +value. Memos are rare — a handful per invocation — so this is bounded by +`memo count x cap`, not by output volume. + +### 7.6 `openProgress` has a write-ordering race + +`commitWrite(item)` captures `item` when `write()` is called, and the write is +fire-and-forget with only `latest` tracked. A checkpoint captured at T1 can +therefore commit *after* a memo-bundled checkpoint at T2, overwriting newer state +with older — reintroducing exactly the loss §7.5 prevents. + +Fix: resolve the sink's state inside the command planner rather than at call time. + +```ts +commitWrite: () => setValue(address, out.snapshot()) // evaluated under the Session line +``` + +`lane.command` serializes on the Session line, so checkpoint writes become +monotonic by construction. This removes the race generally, not only against +memos. + +### 7.7 Nothing on the durable path runs tool code + +Ops are interpreted by a five-verb applier with no domain knowledge, so a tool +rewritten between crash and resume cannot make a persisted stream unreadable. + +> **The durable path uses only harness-owned reducers.** + +This also rules out persisting *facet* ops. The harness has no facet state, +facets come and go, and a recovering harness must rebuild its working values with +no facet present. + +## 8. Open questions + +- **Property tests for the tracker** (`delta.md` §3.3). Everything here rests on + producer and replica agreeing; nothing currently proves they do. +- Coalescing window: per tick, or a byte/time threshold. +- Whether image count needs a bound. Images are unwindowed, so a tool pushing + them in a loop grows `content` without limit. Currently treated as a tool bug. +- Whether details need a bound for the same reason (§2.1). +- Whether `retain: "head"` should keep emitting counter-only updates once capped + so a renderer can report how much was suppressed. `executionenv.md` answers this + for exec-originated output; agent-side output needs the same answer. +- Whether a failing tool *should* be able to terminate, or whether the current + inability is deliberate — there is a reasonable argument the model should + receive the error and decide. +- **Derived values.** `arguments` parsed from accumulated JSON should not be + replicated; derive it on demand. Safe because `parseStreamingJson` is total — + four fallbacks ending in `{}`, it cannot throw — so a replica derives with no + error path and no agreement protocol. Generalises to: no derived fields in + replicated state. diff --git a/packages/agent/docs/mobile-handoff/01-harness/04-tool-output/rate-limiting.md b/packages/agent/docs/mobile-handoff/01-harness/04-tool-output/rate-limiting.md new file mode 100644 index 000000000..564c167a7 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/04-tool-output/rate-limiting.md @@ -0,0 +1,250 @@ +# 04-tool-output: update cadence and rate limiting + +**Status: design, not implementation.** This unit is not built. What follows is +the problem, what three other agents do about it, and a candidate solution with +its measured behaviour and its known holes. + +Depends on `01-delta` (the op vocabulary), `02-scopes` (the durable list), and +`03-execenv` (where bytes originate). + +--- + +## 1. The invariant this unit exists to hold + +> **The durable record, what the model sees, and what the UI shows are always the +> same view.** + +Not a simplification — a commitment. If the UI shows output the model never +received, a user reads it, forms a belief about what the model knows, and is +wrong. Showing less is better than showing something different. + +Two consequences that look like limitations and are not: + +- **One byte budget, not two.** A separate "UI scrollback" budget would break the + invariant by construction. Codex has two (see §3) and their UI can scroll + through output the model never saw. +- **Two knobs on the tool, and only two:** how much to keep, and which end. Head + vs tail is genuinely per-command — `grep` and `find` want the first matches, + `npm test` wants the failure at the end — and nothing in the harness can infer + it. + +The spill file does not violate this. It is not a view; it is a file on disk the +model reaches through `read`/`grep` like any other file. + +--- + +## 2. The problem + +A tool produces output continuously. Every update must reach three consumers. How +often? + +**`intervalMs` is the wrong knob**, and it is what `03-execenv` currently ships. +It was chosen for an in-process TUI where 100 ms feels live. The cost of an emit +varies by ~1000x depending on throughput, so a fixed interval is wrong at both +ends: + +| | bytes per emit | at 100 emits/s | +| --- | --- | --- | +| slow build, 2 KB/s | 40 | 4 KB/s — fine | +| `cat 1gb.txt` | 51 000 | **5.1 MB/s** — untenable | + +**And no purely data-intrinsic policy exists.** "Emit every N bytes changed" gives +constant bytes per emit, so the emit *rate* scales with throughput. "Emit once per +window turnover" is 20 000 emits for 1 GB at a 50 KB window. A clock is +unavoidable; the question is whose, and what it measures. + +### 2.1 Where the delta encoding stops helping + +Measured, 50 KB tail window, one flush per arrival: + +| bytes arriving between flushes | ops | wire | vs a full replacement | +| --- | --- | --- | --- | +| 200 B | `t,a` | 232 B | 0% | +| 2 KB | `t,a` | 2 067 B | 4% | +| 25 KB | `t,a` | 25 504 B | 51% | +| 49 KB | `t,a` | 49 959 B | 100% | +| **50 KB and above** | **`s`** | **50 967 B** | **102%** | + +The delta helps in exact proportion to how little the window moved, and stops +entirely once arrival >= cap. At `cat` rates every update is a full `s`. That is +correct behaviour, not a failure — but it means **the pathological case is bounded +by the cap and the cadence, never by the encoding.** + +Related: at full turnover, `s`-on-the-field and `r`-on-the-root are within 0.3% +(51 833 vs 51 970 bytes). At low rates the field-level `s` is 224x smaller. So the +tracker naturally emits the narrowest op it can, exactly where narrowness pays. + +--- + +## 3. What other agents do + +Read before designing. All three differ from us and from each other. + +### tmux — backpressure at the source + +```c +#define READ_SIZE 1024 // max data held from a pty +#define READ_BACKOFF 512 // bytes waiting for the tty before backing off +#define READ_TIME 100 // µs to wait before the next read +``` + +tmux stops **reading the pty** when the client is behind, and discards output plus +a full redraw when the backlog gets large — which is our full-turnover `s`, +arrived at independently. + +The symptom that justifies it: *"when the user tries to ^C the command they have +to wait for this backlog to clear"*. **The real cost of a queue is interactivity, +not bandwidth.** Their failure mode is permanent data loss — users reported +scrollback silently truncated — which our spill avoids. + +### Codex — no backpressure, deliberately, and two budgets + +```rust +// Continue reading to EOF to avoid back-pressure +pub const DEFAULT_OUTPUT_BYTES_CAP: usize = 1024 * 1024; // head, silent drop +pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; +const READ_CHUNK_SIZE: usize = 8192; +``` + +Model gets the first 1 MB, head-retained, silently discarded past that. The UI +gets raw 8 KB chunks it appends itself, capped at 10 000 **events** — so up to +80 MB, bounded by count rather than rate. Crude, needs no timer and no consumer +signal, and breaks our invariant: their UI can scroll through output the model +never saw. + +### OpenCode — no live output at all + +```ts +// TODO: Add durable/live progress metadata streaming for long-running commands +// once V2 tool invocation progress context is wired. +``` + +`collectStream` folds the whole stream into a 1 MB head-retained buffer and +returns once. Their **v2 spec** states the separation we should copy: + +> *"Model-output bounding is not producer memory management. Processes and +> streaming sources may need separate capture or spooling limits before a tool +> result exists. Those limits must be modeled at the producer boundary and must +> not masquerade as model-output truncation. A producer cannot claim a complete +> retained output after it has already discarded bytes."* + +Also worth taking: *"if complete retention fails, settlement fails operationally +rather than publishing lossy success"* — better to fail the tool than hand the +model a silently truncated result. Both Codex and OpenCode-v1 do the lossy thing. + +**We are the only one of the three that keeps the tail.** For `npm test`, where +the failure is at the end, head retention shows the successful setup and drops the +error. + +--- + +## 4. Candidate: a byte-rate limiter owned by the sink + +The constraint is bandwidth, so measure bandwidth. **Unit: bytes per second.** One +value in the sink, not per tool. + +```ts +#maybeEmit(): void { + this.#credit = min(RATE, this.#credit + RATE * secondsSince(this.#lastRefill)); + this.#lastRefill = now(); + if (this.#credit <= 0) return; // accumulate instead + + const ops = this.#tracker.flush(); + if (ops.length === 0) return; + const wire = this.#encoder.encode(ops); + this.#credit -= sizeOf(wire); + this.#publish(wire); // ONE emit, all three consumers +} +``` + +`flush()` drains, so it can only be called once per batch — which is *why* durable +and UI share it, and why they cannot diverge. + +### Measured, 50 KB budget, RATE = 100 KB/s, 10 s + +| producer | emits/s | wire | +| --- | --- | --- | +| slow build, 2 KB/s | 100/s | 40 KB | +| busy build, 50 KB/s | 100/s | 529 KB | +| **`cat`, 100 MB/s** | **2.2/s** | **1.1 MB** | +| `cat`, no limit (`intervalMs=100` today) | 100/s | **50.6 MB** | + +**45x less wire for `cat`, and the slow case is more responsive than a 100 ms +interval, not less.** Same rule, opposite behaviour, because it measures the thing +that is actually scarce. Nobody configures a cadence. + +### 4.1 This depends on D1 being fixed first + +Holding back is only free if held-back writes collapse. **They currently do not** +— see `../01-delta/FINDINGS.md` D1: 1000 held-back interleaved writes produce 2001 +ops and 264 KB. Until that is fixed the rate limiter converts a bandwidth problem +into a memory problem. + +### 4.2 Known hole: the trailing emit + +`#maybeEmit` runs only when the tool writes. A command that bursts and then goes +quiet on a held write never sends it: + +``` +t=0ms sent 319B (credit -19) +t=10ms sent 329B (credit -248) +t=20ms HELD + ...quiet for 30s... + tracker still dirty — the UI shows state from t=10ms, forever +``` + +Needs a **one-shot** timer armed only while something is held, with the delay +computed as `deficit / RATE`. That is not the knob we deleted: it is derived from +`RATE`, not configured, and it does not poll. + +### 4.3 Undecided + +- **Durability cadence.** Does the durable write ride the same emit, or run + slower? Riding it is simpler and preserves the invariant trivially. A separate + slower clock reduces writes but needs its own trailing timer and its own + argument for why divergence is acceptable. +- **Backpressure.** tmux stops reading the pipe; Codex explicitly does not. We + currently do not. tmux is the only one with a human pressing Ctrl-C in the + pane — which suggests backpressure buys *interactivity*, and we may be closer to + Codex here. Unmeasured: whether our abort path has the Ctrl-C latency problem. +- **Pull-based instead of a rate limit.** The consumer says "ready" and gets the + current state; no estimation at all. Measured: **1000 producer writes cost the + same one op as 1**, so a slow consumer sees a bigger jump rather than a queue. + Cleaner in principle, needs consumer plumbing and still needs a durability floor. + +--- + +## 5. Consequences for `03-execenv` + +**`BASH_CHECKPOINT_INTERVAL_MS` goes and nothing in `03-execenv` replaces it.** +The durable cadence is not a `Shell` concern. + +`bash.after.ts` as shipped checkpoints when the update kind is `snapshot`, on the +reasoning that a snapshot is the only kind that can express eviction. **That is +wrong and should not be copied.** `nowCrossed` is sticky: once total output +exceeds the cap it never resets, so every subsequent update is a snapshot — a +durable write every `intervalMs`, the same cadence, not a lower one. + +**Two knobs can also go:** + +- **`maxLines`** is redundant with `maxBytes`. 2000 lines x 25 bytes is the same + bound; 100 000 tiny lines still yields ~50 KB. Keep truncation *line-aware* — + cut at a line boundary at or below the budget — which is what `truncateTail` + already does, so removing the parameter does not change behaviour. +- **A byte limit does not break UTF-8.** Only slicing by *code units* does. + Binary-searching on encoded length lands on a codepoint boundary every time, and + `trimToLastBytes` already does this. Verified across mixed ASCII / accented / + CJK / emoji at 10, 20 and 30-byte budgets. + +**`counters` as an update kind can go too.** It exists so head mode can move a +number without resending the frozen text — but nothing needs that number *live*. +The final `ShellExecResult` carries the totals and the visible content was +complete when the cap filled. Head mode emits chunks until full, then silence. +That reduces three update kinds to two. + +**`retain: "head" | "tail"` stays.** Head is correct for search-shaped output — +`grep`, `find`, `ls -R`, where results are unordered so the first N are as +informative as any N and arrive immediately. `truncateHead` is already used by +`read.ts`. + +Resulting `CaptureOptions`: `{ maxBytes, retain?, spill? }` — three, from five. diff --git a/packages/agent/docs/mobile-handoff/01-harness/05-assistant-output/message-update.md b/packages/agent/docs/mobile-handoff/01-harness/05-assistant-output/message-update.md new file mode 100644 index 000000000..8bd039c74 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/01-harness/05-assistant-output/message-update.md @@ -0,0 +1,242 @@ +# `message_update` Write Amplification + +> **Scope:** harness-local. Depends on `delta.md` for the op vocabulary and +> `session-scopes.md` for durability. + +## 1. The problem + +```ts +{ type: "message_update", runId, message, event, frame? } +``` + +Three representations of the same thing travel together: + +- `message` — the full `AssistantMessage`; +- `event` — an `AssistantMessageEvent`, which itself carries `partial: AssistantMessage`, + a second full copy; +- `frame` — the actual delta, optional. + +Per streamed token this is roughly two complete snapshots plus a delta, so bytes grow +quadratically over a response. + +The reducer does not even use the delta: + +```ts +case "message_update": + if (next.operation?.id === event.runId && event.message.role === "assistant") { + next.operation.streamingMessage = event.message; + } +``` + +A straight assignment. So on any wire, sending the event is *worse* than sending a +snapshot — it ships two where `replace` ships one. + +The wire adapter is already halfway to the fix: it drops `event` and sends `message` +plus `frame`. That is the full snapshot together with the delta that produced it. + +## 2. The compact form already exists + +```ts +/** + * Compact, replayable assistant-message progress. Terminal settlement is + * intentionally excluded and must be persisted separately. + */ +export type AssistantMessageFrame = + | { type: "start"; partial: AssistantMessage } + | { type: "text_start" | "text_delta" | "text_end"; contentIndex: number; ... } + | { type: "thinking_start" | "thinking_delta" | "thinking_end"; ... } + | { type: "toolcall_start" | "toolcall_checkpoint" | "toolcall_delta" | "toolcall_end"; ... }; +``` + +`AssistantMessageFrameEncoder` produces it, `reduceAssistantMessageFrames` folds it, +and `openFrameProgress` already persists it with `appendList`. The delta format is +built, used, and durable. It simply is not what `message_update` carries. + +## 3. Precedent + +pi already does wire-is-delta one layer down. `PiMessagesEvent` — the serialized form +a pi-messages backend sends — has no `partial`: + +```ts +| { type: "text_delta"; contentIndex: number; delta: string } +| { type: "text_end"; contentIndex: number; content: string; contentSignature?: string } +``` + +`pi-messages.ts` then rehydrates: it holds a local `partial`, mutates it per event +(`partial.content[i].text += event.delta`), and returns the in-process +`AssistantMessageEvent` with `partial` attached. + +So the convention is established. It just stops at the provider edge instead of +continuing through the harness. + +## 4. The change + +```ts +| { type: "message_update"; runId: string; entryId: string; ops: Op[] } +``` + +`message` and `event` are removed; `frame` stops being optional. + +The blast radius is small because `HarnessEvent` is not what most streaming consumers +read. `AgentEvent` (`agent-loop.ts`) and `AgentSessionEvent` (`agent-session.ts`) are +separate unions that happen to share the tag name and build their own +`message_update` from the pi-ai event directly. They are out of scope here. + +Real consumers of `HarnessEvent.message_update`: + +| consumer | change | +| --- | --- | +| `runtime/reducer.ts` | fold the frame instead of assigning `event.message` | +| `experimental/harness-wire-adapter.ts` | forward the frame; stop sending `message` | +| `harness/telemetry.ts` | name list only | +| `protocol/harness.ts` | schema | + +`message_end` continues to carry the settled message, because frames deliberately +exclude terminal settlement. That is once per message, not once per token. + +## 5. An incremental applier is needed + +`reduceAssistantMessageFrames` is a whole-stream fold over an `Iterable`. The +reducer needs a step function: + +```ts +export function applyAssistantMessageFrame( + state: AssistantFrameState, + frame: AssistantMessageFrame, +): void; +``` + +Plain mutation on a plain object. **No `Draft`, no Immer.** An earlier draft +argued for a draft-mutating signature so it would compose inside a `produce` +recipe; that motivation is gone (`delta.md` §8). The step function is still +needed — the whole-stream version becomes a loop over it — just for the simpler +reason that the reducer folds one frame at a time. + +### 5.1 pi-ai frames stay at the pi-ai boundary; our frames go on the wire + +Two things are called "frame" and they are not the same. `AssistantMessageFrame` +is pi-ai's semantic delta vocabulary (`text_delta`, `text_end`, …). What crosses a +boundary in this design is +`delta.md` §6 has no frame type at all — what travels is `Op[]`. The pi-ai frames +stop at the fold; ops are what cross a boundary. + +`AssistantMessageFrame` is pi-ai's own delta vocabulary and stays. What changes is +that it is no longer the durable unit or the replication unit. + +The harness folds frames into `LaneView` by plain mutation. Under the tracker +(`delta.md`) that yields: + +```json +{"op":"append","path":["operation","streaming","content",0,"text"],"value":"Let me "} +``` + +Measured, interned ops are **smaller than frames** on this workload — 13.6 KB +against 21.5 KB raw for 200 deltas — because a frame carries three keys of its own +where an interned op carries one integer. So the size argument for putting frames +on the wire is dead. + +What frames keep is semantic: `text_end` carries authoritative content plus a +signature in one atomic unit, where ops would need two with a weaker contract +about their relationship. That is why they remain the *input* vocabulary and are +folded before anything crosses a boundary. + +### 5.2 Reducer state must live in the reduced value + +Text and thinking fold purely: `block.text += frame.delta`, and `*_end` overwrites +with authoritative content plus signature. The `ended` flag in `ReducerBlockState` +is used only for validation and can be dropped. + +Tool calls cannot. `toolcall_delta` does `state.json += frame.delta`, accumulating +a **raw JSON string that is never stored on the message** — the block holds +`arguments`, the parsed value. You cannot recover the accumulator from the +snapshot, and you cannot append a delta to a parsed object. + +So the accumulator has to become part of the value being folded — e.g. +`operation.frameState[contentIndex].json` on `LaneView` — leaving +`AssistantMessage` clean. Generally: + +> **A replicated reducer's state must be part of the replicated value.** Anything +> held beside it diverges on any consumer that did not run the producer's fold. + +The corollary is that `arguments` itself should **not** be replicated. It is +derived from `json`, and a parsed object is a fresh reference on every parse, so +storing both ships two copies of the same information. Derive it on demand. + +This is safe precisely because `parseStreamingJson` is **total** — four fallbacks +ending in `{}`, it cannot throw — so a replica derives unconditionally with no +error path and no agreement protocol. There is no parse-failure state to +represent, and no block needs an error slot. + +### 5.3 Parse cost + +`parseStreamingJson` on a growing string once per delta is quadratic per message. +Since `arguments` is now derived rather than replicated, this cost falls on +whoever reads it rather than on every consumer. Honouring `toolcall_checkpoint` +and `toolcall_end` only, so `arguments` refreshes at checkpoint granularity, is +almost certainly why checkpoints exist. + +## 6. What `toolcall_checkpoint` is for + +`EncoderBlockState` carries `caughtUp` and `catchupJson`, so a subscriber joining +mid-tool-call receives a checkpoint to resync against rather than the deltas it +missed. It is a replication mechanism inside an encoder. + +With a delta-folding reducer it stops being a correctness requirement and becomes what +it should be: a resync point, and the bound on how much must be replayed. + +## 7. Write volume for pending output + +`openFrameProgress` calls `appendList(pendingAssistantFrames(...))`, so every +frame is a line. A long response is thousands of writes. + +**The address is renamed `pendingAssistantOutput`** and becomes a `list`, +matching `pendingToolOutput` +(`harness-tools.md` §7.2), and it stops being a list of frames. It holds tracked +state written as ops or snapshots under the adaptive rule (`delta.md` §5), in an +**ephemeral scope** so it is unlinked on settle rather than persisting in the main +log (`session-scopes.md`). + +The important property is that this list is **not history**: `deleteList` runs on +settle in `response.ts`, `deferred.ts`, and `terminal.ts`. It exists so a crash +mid-response can recover a partial assistant message. The settled message is persisted +separately. + +That means per-frame durability buys almost nothing, and the write rate can be traded +away directly: + +- **Coalesce with a bounded, non-resetting window.** The first pending frame opens the + window; later frames join it *without* extending the deadline, so a continuously + streaming response cannot postpone the first write indefinitely — the failure mode a + naive debounce has. Frames admitted during an active write form the next batch. +- **Concatenate on flush.** A run of `text_delta` frames for one `contentIndex` + collapses to a single frame with the joined text. The fold result is identical, so + this is lossless for our purposes. + +A crash then loses at most one window of in-flight streaming. + +### 7.1 Contrast with DeepSeek Harness + +DSH cannot make this trade. Its `assistant/chunk` events are canonical log entries, so +per-token durability is mandatory and it attacks size instead: + +- the same bounded non-resetting write-behind window; +- **packed rows** — runs of consecutive chunk deltas stored as + `text-chunks` / `reasoning-chunks` / `tool-call-chunks`, lossless and roughly 60% + smaller on a real session, with reading unconditional so layout never depends on the + write switch; +- checksummed zstd frames by default, with recovery from a torn final frame. + +Their packing must reconstruct exact event boundaries, sequence numbers and timestamps, +because `seq = log.length` and validation requires a contiguous logical log. Ours does +not, because the frames are discarded at settle — so plain concatenation is available +to us and packing machinery is not needed. + +## 8. Consequence for ad-hoc listeners + +`HarnessEvent.message_update` stops being self-describing. A listener that attaches +mid-stream sees a delta against a partial it does not hold. + +Any correct listener already has the base, because `watch()` installs the subscription +and captures the snapshot inside one `readLane` critical section, buffering until +`start()`. But a listener that calls `on("message_update")` directly, without a watch, +is no longer viable — worth knowing before committing. diff --git a/packages/agent/docs/mobile-handoff/02-plugins/01-facets/facets.md b/packages/agent/docs/mobile-handoff/02-plugins/01-facets/facets.md new file mode 100644 index 000000000..c3e06aafd --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/01-facets/facets.md @@ -0,0 +1,1883 @@ +# Plugin and Facet Architecture + +> **Status:** Design specification. Supersedes the facet/service model in `plugins.md` +> where the two disagree. Read `rpc.md` for transport framing. + +## 1. Shape of the system + +A **plugin** is a package with up to three entry points, one per host kind: + +```text +my-plugin/ + contract.ts service tokens + JSON DTOs (shared, no host imports) + server.ts server facet (optional) + worker.ts session-worker facet (optional) + tui.ts presentation facet (optional) +``` + +Nothing links the entry points at runtime. They share only the tokens in +`contract.ts`. Each is built into a single JavaScript file by esbuild, pointed at +that entry. + +A **facet** is the in-process unit: one object with a static manifest and one +construct function. A **host** is a process that assembles a facet graph — server, +session worker, or presentation. + +The topology is a tree of connections: + +```text +server +├─ TUI A +├─ web B +├─ session worker S0 +└─ session worker S1 +``` + +## 2. Tokens + +A token is the identity of a service contract. It carries the service type as a +phantom, its stable ID, whether it is RPC-capable, and — critically — its **mode**. + +```ts +type ServiceMode = "singleton" | "keyed" | "peer"; + +interface Service { + readonly id: string; + readonly mode: M; + readonly rpc: boolean; + readonly __type?: T; // phantom +} + +function defineService( + id: string, + options?: { rpc?: boolean }, +): Service; + +function defineKeyedService( + id: string, + options?: { rpc?: boolean }, +): Service; + +/** One instance per connected peer; each peer sees exactly its own. */ +function definePeerService( + id: string, + options?: { rpc?: boolean }, +): Service; +``` + +The three modes differ only in how many instances exist and who sees them: + +| mode | instances | consumer declares | consumer gets | +| --- | --- | --- | --- | +| `singleton` | one, shared | `uses` | the service | +| `keyed` | many, all visible to everyone | `observes` | one task per instance | +| `peer` | one per connected peer, visible only to that peer | `uses` | the service | + +`peer` is the answer to per-client state (§10.2). It is not a fourth mechanism — +the host instantiates lazily per peer and announces to that peer alone — but the +consumer ergonomics matter: from a client's side there is exactly one, so it says +`uses` and calls it, exactly like a singleton. Watching a *set* of instances is +what `observes` is for, and per-client state is not that. + +Mode lives on the token because it is a property of the contract, not a choice the +provider makes. This deletes an entire class of assembly error: a token cannot be +provided as a singleton and consumed as keyed, because there is nothing to declare. + +Tokens are RPC-capable by default. `{ rpc: false }` confines a token to its +providing process; such tokens are never announced and never resolvable remotely. + +## 3. Facets + +```ts +const modelSelectionTui = defineFacet({ + id: "@pi/model-selection:tui", + + uses: [Models, Tui], + provides: [], + observes: [], + + construct(ctx) { + const models = ctx.use(Models); + const tui = ctx.use(Tui); + + tui.commands.register("models.select", async (context) => { /* ... */ }); + + return []; + }, +}); +``` + +Three declaration fields, all pure token data, all readable without executing +anything: + +- **`uses`** — singleton tokens this facet requires. +- **`provides`** — tokens this facet implements, singleton or keyed. +- **`observes`** — keyed tokens this facet watches instances of. + +`construct` runs only after the kernel has validated the whole graph and +constructed every dependency. Everything `ctx.use()` returns is a **real object**, +never a proxy that becomes valid later. There is no phase during which a facet +holds something unusable. + +**`construct` is synchronous.** It wires objects and returns provisions; it does no +I/O. Anything asynchronous registers through `ctx.onActivate`, which runs after the +entire graph is constructed, in dependency order. `ctx.onDeactivate` runs before +disposal in reverse order. Three phases, each with a single job: + +| phase | sync? | may call dependencies? | purpose | +| --- | --- | --- | --- | +| `construct` | yes | no | wire objects, return provisions | +| activate | no | yes | I/O, subscriptions, initial fetches | +| deactivate | no | yes | orderly shutdown before disposal | + +Keeping `construct` synchronous is what makes the ordering guarantee simple: a facet +cannot observe a half-built graph, because nothing runs until all of it exists. + +### 3.1 Why not declaration-by-side-effect + +`plugins.md` derives the dependency ledger from `env.use()`/`env.provide()` calls +made during a synchronous `setup()`. That avoids writing the manifest twice, but it +forces `use()` to return a disconnected lazy proxy — an object whose type is a lie +until assembly finishes. Every facet author then has to remember a rule the language +cannot enforce. + +Separating declaration from construction costs one extra mention of each token and +buys: + +- graph validation with **zero facet code executed** — decisive for third-party + plugins, where a bad set should be rejected before it runs; +- honest types throughout construct; +- a facet whose entire surface is readable in ten seconds. + +The duplication is eliminated by type checking (§4), so it cannot drift. + +### 3.2 Construct context + +```ts +interface ConstructContext { + /** Resolved singleton dependency. Only accepts tokens declared in `uses`. */ + use(token: S): ServiceType; + + /** Owner handle for a keyed token declared in `provides`. */ + owner>(token: S): ServiceInstances>; + + /** Host-built replicated state; see §9.2. */ + state(definition: StateDefinition, initial: T): MutableState; + + /** Runs after the whole graph has constructed, in dependency order. */ + onActivate(fn: (context: Context) => void | Promise): void; + /** Runs before disposal, in reverse dependency order. */ + onDeactivate(fn: (context: Context) => void | Promise): void; +} + +interface ServiceInstances { + /** Invokes this facet's factory for `key`, registers it, announces it. */ + add(key: string): () => void; +} +``` + +`ctx.use()` is a typed lookup rather than a positional tuple or a named bag: no +invented labels, no positional matching, and passing an undeclared token is a +compile error. + +Owner handles arrive through `ctx` rather than being returned, because they are +kernel-owned machinery that exists before the factory does. Construct receives +dependencies and its own handles; it returns implementations. + +## 4. Return type and completeness + +`construct` returns an array of entries built by two helpers: + +```ts +function provide(token: Service, impl: T): ProvideEntry; +function provide(token: Service, factory: (key: string, scope: InstanceScope) => T): ProvideEntry; +function provide(token: Service, factory: (principal: Principal, scope: InstanceScope) => T): ProvideEntry; + +/** Instance-lifetime equivalent of the construct context's state(). */ +interface InstanceScope { + state(definition: StateDefinition, initial: T): MutableState; +} + +function watch( + token: Service, + handler: (instance: Instance, context: Context) => void | Promise, +): WatchEntry; + +interface Instance { + readonly key: string; + readonly service: T; +} +``` + +The union of tokens in the returned array must equal the union of `provides` plus +`observes` — mutual assignability in both directions, which gives both +**completeness** (nothing forgotten) and **no extras** (nothing undeclared): + +```ts +type TokensIn = R[number]["token"]; + +type CheckComplete = + [TokensIn] extends [Declared] + ? [Declared] extends [TokensIn] + ? unknown + : { __error: "missing implementation for declared token" } + : { __error: "returned an undeclared token" }; + +function defineFacet< + const U extends readonly AnySingleton[], + const P extends readonly AnyService[], + const O extends readonly AnyKeyed[], + const R extends readonly Entry[], +>(facet: { + id: string; + uses: U; + provides: P; + observes: O; + construct: (ctx: ConstructContext) => R & CheckComplete; +}): Facet; +``` + +Note this is an array of token/value pairs, not an object map. Tokens are objects, +and objects cannot be keys. String-ID keys reintroduce a seam between the token in +the manifest and a string literal in construct; unique symbols do not survive +`defineService`'s return type, collapsing to plain `symbol` and merging every key. +Pairing tokens with values in an array keeps each entry individually typed against +its own token and keeps completeness checkable. + +> **Open:** error message ergonomics for `CheckComplete` need experimentation. +> The failure points at construct's return type, which is correct but not pretty. + +### 4.1 Worked example + +```ts +export const questionSession = defineFacet({ + id: "@pi/question:session", + + uses: [Tools], + provides: [QuestionDialogs], // keyed token + observes: [], + + construct(ctx) { + const tools = ctx.use(Tools); + const dialogs = ctx.owner(QuestionDialogs); + const pending = new Map(); + + tools.add((draft) => { + draft.set("question", { + /* ... */ + async execute(_id, params, _u, _tc, invocation, context) { + const completion = Promise.withResolvers(); + pending.set(invocation.invocationId, { params, completion }); + const close = dialogs.add(invocation.invocationId); + try { + return toResult(await awaitAbortable(completion.promise, context.abortSignal)); + } finally { + close(); + pending.delete(invocation.invocationId); + } + }, + }); + }); + + return [ + provide(QuestionDialogs, (key) => { + const entry = pending.get(key)!; + return { + request: entry.state, + async submitAnswer(candidate, _context) { /* memoOnce, resolve */ }, + }; + }), + ]; + }, +}); +``` + +The factory receives the key and closes over facet-private state. `dialogs.add()` +triggers it. Construct still runs exactly once. + +## 5. Ordering and cycles + +`uses` and `provides` are static, so the kernel computes a topological order over +facets **before running any of them**. Every facet is constructed after all of its +dependencies. Failures — missing provider, duplicate singleton owner, cycle — are +reported against the manifest with no facet code executed. + +**Cycles between facets are rejected.** No ordering satisfies them, and the survey +of coding-agent features found no genuine construction-time cycle: tools and +providers are fan-in through contribution registries; hooks are the host calling +you; telemetry is a leaf; wrappers are ordered composition. + +The cases that look circular are late *call-time* references, not construction +dependencies. Those get an explicit, visible escape hatch: + +```ts +uses: [Tools, deferred(QuestionDialogs)] +// ctx.use(deferred(X)) returns () => X, resolved on first call, after assembly +``` + +Deferral is opt-in and rare, so the cost of a knot is local and legible rather than +being paid by every facet in the form of universally unusable proxies. + +### 5.1 Same-facet composition + +A facet's services share module and closure scope. Two services that need each +other are ordinary JavaScript: build both inside construct, wire the references by +hand, return both. Shared private state is a variable both close over. The kernel +is not involved and there is no intra-facet graph. + +If two services in one facet need each other symmetrically, that is usually one +service with two faces, or one private object behind two facades. + +## 6. Hosts, connections, and direction of authority + +**A host may depend only on what it connects to, and connections form a tree.** +That single rule replaces any global sort and keeps the cross-process graph acyclic +without special-casing the server as "built-ins that are just there." + +Each host sorts locally. Remote provisions are **leaves** — the upstream process +constructed them before announcing, so they are already satisfied by definition. + +Concretely: + +- A **session worker** connects to the server, so worker facets may `use` server + tokens. This is required: a `spawn_subagent` tool needs the server's session + management. +- A **presentation** connects to the server, so TUI facets may `use` server tokens. +- A presentation also consumes **session** services — but the provider from its + point of view is the server, which routes. The server's catalogue simply grows + when a session is attached. + +There is no direct presentation↔worker connection. + +### 6.1 The server never depends on a worker + +The server must construct before any worker exists, so it cannot `use` a worker +service. Inverted flows use a **reporting registry**: the server provides a token +that workers push into. + +```ts +// contract.ts +export interface SessionStatusReporting { + report(status: SessionStatus, context: Context): Promise; +} +export const SessionStatusReporting = + defineService("pi.session-status-reporting"); + +export interface SessionStatusView { + readonly state: State>; +} +export const SessionStatusView = + defineService("pi.session-status"); +``` + +The worker `uses` the reporting token and pushes; the server aggregates into +replicated state; presentations read the aggregate. Dependencies still point +upward, data flows down. + +This gets the lifecycle right for free: the aggregate exists immediately and is +empty; workers appear, register, and vanish; a crashed worker is an entry removal, +which the server already knows about because it owns the connection. + +## 7. Facet delivery and generations + +**A presentation ships with no plugin facets.** It has host services (`Tui`) and +nothing else. All plugin facets arrive over the wire as built bundles. + +This dissolves the problem of a detached presentation holding unresolved +requirements: while detached there are no requirements, because there are no +facets. + +Two generations, with different lifetimes: + +| generation | source | lifetime | example | +| --- | --- | --- | --- | +| **connection** | server | the server connection | session picker | +| **attachment** | session worker | one attachment | question dialog, chat | + +Switching sessions tears down and rebuilds only the attachment generation. The +picker keeps running throughout — which it must, since it is what triggers the +switch. + +The attachment generation resolves against the connection generation (upstream +first, per §6). The reverse is forbidden: a connection-generation facet cannot +depend on an attachment-generation token, because the attachment can go away. + +### 7.1 Why the worker chooses the presentation facets + +Plugins may be **global** or live in a session's **working directory**. So the set +of TUI facets depends on where the TUI was started — same binary, different +capabilities per project. + +The worker therefore knows which TUI bundles belong with it, and ships them through +the server on attach. The working directory is not merely a spawn parameter; it is +an input to the graph. + +> **Security:** attachment-generation bundles are third-party code arriving from a +> project directory and executing in the user's presentation process. Trust policy +> for directory-local plugins is an open decision and must be settled before this +> ships. + +## 8. Startup and handshakes + +### 8.1 Presentation connect + +```text +TUI → server: connect +server → TUI: catalogue of server RPC provisions + connection-generation bundles +TUI: assemble, validate, construct connection generation +``` + +### 8.2 Reaching a session + +Three routes, converging on one attach: + +| invocation | route | +| --- | --- | +| `pi` (bare, in a directory) | ask server to create a session for cwd | +| `pi --resume` | invoke the picker command at startup | +| `pi --session ` | attach directly | + +`--resume` needs no special machinery: the picker is an ordinary command registered +by the server-sourced picker facet, and resume invokes it at startup instead of +waiting for a keystroke. Same code path either way. + +Create carries the working directory, since the server needs it to spawn and the +worker needs it to resolve local plugins. + +### 8.3 Attach + +```text +TUI → server: attach(sessionId) +server: authorize; tear down previous attachment generation; + bind routing to worker +server → worker: client attached (identity) +worker → server: catalogue of session RPC provisions + TUI bundles +server → TUI: catalogue + bundles +TUI: assemble, validate, construct attachment generation +worker/server: hydrate state state values +``` + +The TUI's kernel validates once, with the worker's catalogue already in hand. There +is no provisional or degraded resolution state. + +### 8.4 Worker startup + +```text +worker → server: connect +server → worker: catalogue of server RPC provisions +worker: load global + cwd-local plugins; assemble; construct +``` + +The worker's own dependencies on server tokens resolve here, before any attachment. + +## 9. Replication primitives + +Exactly three things cross a connection. + +### 9.1 Service calls + +Ordinary request/response. For actions (`select`, `submitAnswer`) and one-shot +reads of things that do not change (scrolling back into chat history). Nothing is +replicated. Arguments and results are strict JSON; `Context` is stripped by the +proxy and reconstructed at the endpoint. + +### 9.2 Replicated state + +One authoritative writer, many readers. The stream carries opcodes: + +The stream carries a snapshot then ops; the vocabulary is in `delta.md` and the +frame type is given below. + +Full-value replication is the degenerate configuration where every update is a +`replace`. It is not a separate primitive, and it is what the adaptive rule +selects automatically when a value changes wholesale. + +#### State is plain TypeScript + +Facets never write ops. A provider mutates its state object normally; the +framework's tracker (`delta.md`) records intent and emits ops. + +```ts +// contract.ts +export const TranscriptState = defineState("pi.transcript.tail"); +``` + +```ts +const tail = scope.state(TranscriptState, initial); + +tail.mutate((s) => { + s.entries.push(entry); // -> splice + s.entries[0].text += chunk; // -> append + delete s.pending; // -> delete +}); +``` + +No mutation map, no recipes, no declared operations, no Immer. There is no +`defineValueState` / `defineReducedState` split either — an earlier draft needed +one because reduced states carried typed deltas a differ could not recover, and +the tracker recovers them. + +`mutate` is synchronous and takes no `Context`. A mutation is a pure state +transition: it calls nothing, cannot be cancelled, and has no caller identity to +consult. Authority is checked in the method that decides to mutate. + +#### Ops never carry provider code + +Consumers apply ops. They never see mutation names and never run provider code. + +This matters for a specific reason: a consumer running provider code would have +to resolve a definition from a registry, making the fold depend on ambient +registration — a reload changes the answer for the same input. Ops remove that, +and make non-JS consumers trivial, since the applier is five verbs. + +Consequences: + +- mutation names are not part of the wire contract and appear in no member table, + so there is no mutation-name version skew; +- a consumer needs no schema for arguments, only for the value in the `replace` + frame; +- `x = undefined` normalises to `delete`, since JSON has no `undefined`. + +#### Wire protocol + +A subscription carries batches of ops — `Op[]`. There is no frame type and no +`seq` in the payload: the SSE binding stamps `id:`, which is where transport +metadata belongs (`delta.md` §6). + +`Op` and its encoding — five verbs, array paths, tuple form, second-use path +interning — are specified in `delta.md` §2 and §4. They are not restated here. + +**A replacement is an op**, `["s", [], value]` — a set at the root path +(`delta.md` §6). There is no frame discriminator. A frame whose ops begin with a +root set is a **base frame**, and frame zero is always one. This collapses several +things `plugins.md` treats separately: + +- there is no distinct hydration path — the snapshot is simply the first frame; +- no buffering-during-snapshot dance, and therefore no two-phase + `watch()`/`start()` handshake as in mini today; +- cold start, reconnect, provider reload, session switch, a sequence gap, and a + fold that could not apply are **one code path**: send a fresh base frame. + +That last item is why there is no `Rebase` type anywhere in this design. + +The SSE `id:` is stamped by the host binding, starts at zero on every +subscription, and is contiguous. A consumer seeing a gap discards its replica and +resubscribes. + +#### Resubscription is a base frame plus buffered frames + +There is no cross-subscription resume and no `Last-Event-ID`. Resubscribing works +the way the harness already hands out lane state: snapshot the current value as +frame zero, buffer whatever arrives while the client is catching up, then deliver +the buffer as ordinary frames once it is ready. + +That is the same two-phase shape as `AgentHarness.watch` — `snapshot` then +`start` — and it is a purely local detail of the provider binding, invisible on +the wire. A consumer sees a base frame followed by contiguous frames, exactly as +on cold start. + +Real resume would need a retained op log so a client could ask for "everything +after seq N". We deliberately do not keep one: the durable form is a list of +frames per *value* (`delta.md` §9), retired with its scope, not a per-subscription +history. Buffering costs a bounded amount of memory for the duration of a +handshake; an op log would cost unbounded disk forever. + +**Emission is adaptive** (`delta.md` §5): ops when they are smaller than a root +set, otherwise the root set. The crossover is at the size of the bounded value, +which bounds traffic in both directions without any sliding-window diffing. + +#### The harness does not emit ops + +The harness emits typed `HarnessEvent`s and always will. Ops appear one layer up. + +A lane facet calls the harness in process, subscribes to typed events, and folds +them into **its own** state shape by plain mutation. The tracker turns that into +ops. The facet never writes an op and never thinks about paths. + +This fold has to exist regardless, because **the replicated shape is a facet's +choice, not the harness's**. Emitting ops from the harness would not remove the +fold; it would only replace "events into state" with "ops into state, with worse +types", and it would make `LaneSnapshot`'s field layout the wire contract. + +```ts +// packages/agent/src/harness/runtime/reducer.ts +export function reduceLaneSnapshot(view: LaneView, event: HarnessEvent): void; +``` + +Plain mutation, no Immer, no return value. See `harness-tools.md` §6. + +#### Shaping state to avoid write amplification + +Two rules, both consequences of the tracker recording intent: + +- **Keep growing strings at stable paths** — one per content block, one per tool + operation's output. A delta then touches exactly one path, which is also what + makes path interning collapse it to a single integer. +- **No derived fields in replicated state.** A parsed value recomputed from a raw + one is a fresh reference on every parse, so it ships as a whole-value `set` and + duplicates information already present. Derive on demand instead + (`message-update.md` §5.2). + +An array of content blocks is fine: it takes one `splice` when a block appears and +then nothing. What changes afterwards is a string *inside* a block. + +#### Requesting a state value + +A provider asks the host to build the state value during `construct`. Facets never +construct one themselves, because the state value is a binding: the host owns its +subscriber table, revision stamping, and disposal. + +```ts +interface State { + readonly value: T | undefined; // undefined until frame zero + subscribe(listener: (value: T) => void): void; // returns nothing (§13) +} + +// on ConstructContext and InstanceScope +state(definition: StateDefinition, initial: T): MutableState; +``` + +Provider side: + +```ts +export const transcriptSession = defineFacet({ + id: "@pi/transcript:session", + uses: [Agent], + provides: [Transcript], + observes: [], + + construct(ctx) { + const agent = ctx.use(Agent); + const tail = ctx.state(TranscriptState, { entries: [] }); + + agent.onEntry((entry, context) => { + tail.append([entry]); + if (tail.value.entries.length > TAIL_LIMIT) { + tail.evict(tail.value.entries.at(-TAIL_LIMIT)!.id); + } + }); + + return [ + provide(Transcript, { + tail, + page: (params, context) => archive.read(params.before, params.limit, context), + }), + ]; + }, +}); +``` + +A `mutate()` call advances the authoritative value **and** publishes the resulting +ops — one statement, so the value and the wire cannot disagree. `replace()` +publishes a whole new snapshot and is what reconnect, provider reload, and the +adaptive rule (`delta.md` §5) use. + +Consumer side: + +```ts +export const transcriptTui = defineFacet({ + id: "@pi/transcript:tui", + uses: [Transcript, Tui], + provides: [], + observes: [], + + construct(ctx) { + const transcript = ctx.use(Transcript); + const tui = ctx.use(Tui); + + transcript.tail.subscribe((tail) => render(tail.entries)); // frame zero arrives here too + tui.commands.register("transcript.older", async (context) => + render(await transcript.page({ before: oldestId(), limit: 100 }, context)), + ); + + return []; + }, +}); +``` + +The consumer never sees opcodes. It receives values, and frame zero is delivered as +an ordinary update — so there is no separate "ready" callback and no hydration +branch in facet code. + +#### Worked: adapting `AgentHarness.watch` + +The real API is asynchronous throughout: + +```ts +interface AgentHarness { + lane(name: string, context: Context): Promise; + lanes(context: Context): Promise; +} +interface AgentLane { + watch(context: Context): Promise>; +} +interface WatchHandle { + snapshot: T; + start(listener: EventListener): void; + resnapshot(context: Context): Promise; + unsubscribe(): void; +} +``` + +`watch()` installs the subscription **and** captures the snapshot inside a single +`readLane` critical section, so both are taken under the same lock. Events arriving +before `start()` are buffered, then flushed. `resnapshot()` does the same with a +`markBoundary()` callback re-establishing where the stream resumes. That is exactly +the guarantee frame zero needs, so the adapter is thin. + +`LaneSnapshot` is per lane, so `Lane` is keyed — one instance per lane, each taking +its own `watch()`. The harness already filters by lane +(`event.type === "usage" || !("lane" in event) || event.lane === this.name`), so the +adapter does no routing. + +Because `watch()` is async, **instance factories may be async**. The instance is +announced once the factory resolves; `add(key)` returns its closer immediately. + +```ts +export const Lane = defineKeyedService("pi.lane"); // key = lane name + +export const laneSession = defineFacet({ + id: "@pi/lane:session", + uses: [Harness], + provides: [Lane], + observes: [], + + construct(ctx) { + const harness = ctx.use(Harness); + const lanes = ctx.owner(Lane); + + ctx.onActivate(async (context) => { + for (const info of await harness.lanes(context)) lanes.add(info.name); + }); + + return [ + provide(Lane, async (laneName, scope, context) => { + const lane = await harness.lane(laneName, context); + const handle = await lane.watch(context); // phase 1: snapshot + subscribe + // The facet's own shape, not LaneSnapshot. reduceLaneView is its code. + const state = scope.state(LaneState, toLaneView(handle.snapshot)); + + handle.start((event) => { // phase 2: buffered, then live + if (event.type === "navigation_end") { + void handle.resnapshot(context).then((fresh) => state.replace(toLaneView(fresh))); + return; + } + state.mutate((v) => reduceLaneView(v, event)); // plain mutation; ops fall out + }); + + return { snapshot: state, setModel: (ref, ctx2) => lane.setModel(ref, ctx2) }; + }), + ]; + }, +}); +``` + +Consumers declare `observes: [Lane]` and get one task per lane, each with its own +replica. + +Four things to notice. + +- **The `watch`/`start` handshake does not cross the wire.** It is a local detail of + one adapter. Remote consumers see frame zero, then ops. Mini's buffering dance + disappears, because the harness already buffers between the two phases and the flush + lands as ordinary mutations after the initial value. +- **The facet owns the replicated shape.** `LaneView` is the facet's, not the + harness's — hence `toLaneView`. This is the point made above: the fold exists + regardless, so the harness gains nothing by emitting ops itself. +- **Rebase never reaches consumers, and is not a type.** `navigation_end` is an + ordinary event the adapter answers with `resnapshot()` plus `replace()` — the + *same* frame a reconnecting client receives. `markBoundary()` supplies the + ordering. No reducer signals anything by returning a value. +- **`HarnessEvent` is not a wire format.** `message_update` today carries the full + message *and* an `AssistantMessageEvent` holding a second copy *and* the delta, + so shipping it would be worse than shipping a snapshot. See `message-update.md`. + It never ships here, because ops travel. +- **Most of the union is already lane state.** `usage` folds into `stats.usage`; config + `value_update` into `configuration.*`; `message_update` into + `operation.streamingMessage`; `tool_start`/`tool_update` into + `operation.runningTools`; retries into `operation.retry`. The state value is very + nearly everything a presentation needs. + +The three that do **not** belong in it are a useful test of §9.4: + +| event | why not a mutation | where it goes | +| --- | --- | --- | +| `lane_created` | creates a lane, does not change one | `lanes.add(name)` — a new instance | +| `handler_error` | diagnostic; no late joiner needs it | events primitive | +| global `value_update` | not lane-scoped | state on whichever service owns that value | + +Note `lane_created` is delivered on the lane event stream, so discovering new lanes +after startup requires a session-level watch rather than the per-lane ones above — +an open question in §16. + +#### Host responsibilities + +Everything below is host machinery, not facet API: + +- stamping `seq` per frame within the provider binding, so business snapshots and + ops carry no transport metadata; +- delivering `replace` before any ops on a fresh subscription; +- choosing ops versus `replace` per flush by the adaptive rule (`delta.md` §5); +- applying only consecutive frames, and requesting a fresh `replace` after a gap, + reconnect, or provider replacement; +- hardening values as they cross the boundary (§14.3); +- announcing the state value definition ID in the member table. Mutation names are + **not** announced, because they never travel — ops are structural, so a consumer + needs no knowledge of how a value was produced. + +Two rules follow from the provider and every replica running the same fold: + +> **A reducer's state must be part of the replicated value.** Anything accumulated +> beside it — the classic case being a raw JSON string being parsed incrementally — +> diverges on any consumer that did not run the producer's fold. + +> **The durable path uses only host-owned reducers.** Plugin-interpreted data must be +> skippable, so an unreadable stream degrades that one value instead of failing the +> record that contains it. + +Mutation-name version skew does not exist: mutations never cross a boundary, so +adding, renaming or removing one is not a breaking change. The version-skew surface +is the *value shape* alone, which the `replace` frame's schema describes. + +Prefer several coarse state values to one large value. A cold replica has +`value === undefined`; that is local readiness and never crosses the wire. + +### 9.3 Events + +Fire-and-forget broadcast. No history, no durability, dropped on disconnect. For +presence, cursors, in-progress drags, transient notifications. + +### 9.4 Which to use + +> **Does a late joiner need it?** +> Yes → replicated state. No → event. + +State: transcript, model catalogue, session status, chat tail, canvas document. +Events: who is typing, where someone's cursor is, an uncommitted stroke. + +### 9.5 Worked: chat, and the tail/archive split + +A chat room is not one state. It splits along the same line: + +- **live tail** — last N messages as a state value. The first batch replaces with + the window; appends and evictions are ops. +- **archive** — a plain service call returning a page of older messages. Immutable + history, no liveness requirement, not replicated onto anything. + +There is no `subscribe` method. Attaching to the state value *is* the subscription, and it +produces the snapshot, so the server registers the reader before it reads. That +closes the window between a query and a later subscribe, and means the server never +has to answer "what happened after an arbitrary client-supplied cursor" — the +class of race that client-chosen timestamps create. + +The lane transcript is the same pattern: recent entries in the state value, older ones +fetched by query when the user scrolls up. + +### 9.6 Worked: shared canvas + +Document state is a state value; strokes are ops. Multi-writer is handled by +**optimistic apply with acknowledgement**, not CRDT: + +1. client generates an op ID, applies locally as unacknowledged, sends it; +2. server accepts first-come-first-served and publishes the resulting ops; +3. client sees its own op ID return and promotes it to acknowledged. + +Note this is the one place the single-writer assumption behind `delta.md` is +relaxed, and it is relaxed by serialising at the server rather than by merging. +The op vocabulary has no `test` verb precisely because there is no conflict model +to support. + +In-progress drags never touch the document. They are events, keyed by peer, +latest-wins, dropped on disconnect — which is exactly why the events primitive +exists. + +## 10. Peers, principals, and authority + +### 10.1 Principals on the context + +Every service method already takes a `Context`. The proxy strips it on the way out +and **reconstructs it at the endpoint**, so the kernel fills in the caller's +principal from the authenticated connection. It is control-plane data: never an +argument, never forgeable. + +```ts +type Principal = + | { kind: "local" } // same process, full authority + | { kind: "user"; peer: PeerId; userId: string; role: Role } + | { kind: "process"; peer: PeerId; host: "worker" | "server"; sessionId?: string }; + +interface Context { + readonly abortSignal: AbortSignal; + readonly principal: Principal; + // ... +} +``` + +There is exactly one `Context` type, and `principal` is always populated — the +same interface locally and across a connection, so a method body reads +`context.principal` without knowing which it was. That is the point: a service must +not have an unchecked path that only appears in-process. + +Peers are not only humans — a worker calling the server through a reporting +registry is a peer too, and server facets often want to distinguish *my own worker* +from *some TUI*. In-process calls carry an explicit `local` principal rather than an +absent one, so a missing check cannot masquerade as a missing peer. + +### 10.2 Per-peer services + +**Authority-bearing state belongs on a `peer` service, not a singleton.** A +singleton has one value by definition, so it cannot show an owner and a guest +different roots — there is nowhere for the difference to live. + +The provider declares the token `peer` and returns a factory. The host calls it +once per connected peer, passing that peer's principal, and announces the result to +that peer only. Each instance gets its own state values from its own `scope`. + +The consumer declares it in `uses` and calls it. No `observes`, no reconciliation, +no set to watch — from a client's side exactly one instance exists, because exactly +one was ever announced to it. + +### 10.3 Worked example: file browsing + +```ts +// contract.ts +export interface BrowseRoot { + readonly handle: string; // opaque, per-principal, revocable + readonly label: string; +} + +export interface BrowseView { + readonly roots: BrowseRoot[]; + readonly note?: string; // e.g. "Ask the owner for wider access" +} + +export const BrowseState = defineState("pi.browse.view"); + +export interface FileBrowsing { + readonly view: State; + list(handle: string, path: string, context: Context): Promise; +} + +export const FileBrowsing = definePeerService("pi.file-browsing"); +``` + +Server facet: + +```ts +export const fileBrowsingServer = defineFacet({ + id: "@pi/file-browsing:server", + + uses: [Fleet], + provides: [FileBrowsing], + observes: [], + + construct(ctx) { + const fleet = ctx.use(Fleet); + + return [ + provide(FileBrowsing, (principal, scope) => { + // One table per instance. Handles are minted here, so a handle a peer + // was never given is not merely rejected — it does not exist for them. + const table = new Map(); + + function publish() { + const roots = rootsForPrincipal(principal, fleet).map((path) => { + const handle = newHandle(); + table.set(handle, path); + return { handle, label: labelFor(path) }; + }); + return roots.length > 0 + ? { roots } + : { roots: [], note: "No browsable locations for this account" }; + } + + const view = scope.state(BrowseState, publish()); + + return { + view, + + async list(handle, path, context) { + const root = table.get(handle); + if (root === undefined) throw new ServiceError("forbidden", "unknown root"); + return readDirectory(root, path, context); + }, + }; + }), + ]; + }, +}); + +function rootsForPrincipal(principal: Principal, fleet: Fleet): string[] { + if (principal.kind === "local") return [ROOT]; + if (principal.kind === "user" && principal.role === "owner") return [ROOT, ...fleet.workspaces()]; + if (principal.kind === "user") return fleet.workspacesFor(principal.userId); + return []; +} +``` + +TUI facet — note that it contains no permission logic at all: + +```ts +export const fileBrowsingTui = defineFacet({ + id: "@pi/file-browsing:tui", + + uses: [FileBrowsing, Tui], + provides: [], + observes: [], + + construct(ctx) { + const browsing = ctx.use(FileBrowsing); + const tui = ctx.use(Tui); + + tui.commands.register("files.browse", async (context) => { + const view = browsing.view.value; + if (view === undefined) return; + + if (view.roots.length === 0) { + await tui.notify(view.note ?? "Browsing unavailable"); + return; + } + + const root = await tui.select( + "Browse", + view.roots.map((r) => ({ label: r.label, value: r })), + { signal: context.abortSignal }, + ); + if (root === undefined) return; + + let path = ""; + for (;;) { + const entries = await browsing.list(root.handle, path, context); + const chosen = await tui.select( + root.label, + entries.map((e) => ({ label: e.name, value: e })), + { signal: context.abortSignal }, + ); + if (chosen === undefined) return; + if (!chosen.isDirectory) return void open(root.handle, join(path, chosen.name)); + path = join(path, chosen.name); + } + }); + + return []; + }, +}); +``` + +An owner's `view` state carries both a machine root and workspace roots; a guest's +carries one or none. Same bundle, same code path, different data. The TUI never +branches on a role, never greys anything out, and cannot construct a handle it was +not given. + +### 10.4 Server-side validation is still mandatory + +The state governs rendering. It does not constrain the wire: a hostile client can +call any method with any argument, including one it never received. + +`list()` therefore checks its handle against the instance's table. Prefer +**unforgeable handles over parsed paths** — a table lookup fails by construction, +where path validation invites `..`, symlink, normalisation and case-folding bugs. +Revocation is free: drop the entry and outstanding handles stop working. + +Where an argument cannot be a handle, the method reads `context.principal` and +decides. The rule is that authority is checked at the provider, every time, no +matter what the client was previously shown. + +### 10.5 The peers cell + +Presence is replicated state the host provides: + +```ts +export const ConnectedPeers = + defineService<{ readonly state: State> }>("pi.peers"); +``` + +Server and worker facets read it to enumerate who is attached. A facet needs it +only for genuinely multi-peer features — a presence roster, a "3 viewers" badge. +Per-peer state does **not** need it, because `peer` mode handles instantiation and +teardown for you. + +### 10.6 Keyed instances + +`keyed` remains what it always was: many instances, all visible to every consumer, +each producing one abortable task under `observes`. Question dialogs are the +canonical case — three concurrent invocations produce three instances, and every +attached presentation sees all three. + +An instance is addressed by `(service, key, generation, member)`, so its state values are +naturally per-instance. Reusing a closed key creates a new generation, so stale +proxies cannot address the replacement. + +The distinction to keep straight: + +- **Do I want all of them?** → `keyed` + `observes`. +- **Do I want mine?** → `peer` + `uses`. + + +## 11. The presentation surface + +`Tui` is an ordinary singleton token, declared in `uses` and resolved through +`ctx.use` like any other. It is provided by the presentation kernel rather than a +plugin, so it sits at the root of the local sort, but facets cannot tell the +difference. + +```ts +interface TuiHost { + readonly slots: SlotContributions; + readonly commands: CommandContributions; // handlers take an AbortSignal; see §11.3 + readonly keybindings: KeybindingContributions; + readonly toolRenderers: ToolRendererContributions; + notify(message: string): Promise; + acquireModal(signal: AbortSignal): Promise; + select(title: string, items: SelectItem[], options: { signal: AbortSignal }): Promise; +} + +const Tui = defineService("pi.local.tui", { rpc: false }); +``` + +### 11.1 Slots + +A slot is a named region of host chrome a facet may fill. + +```ts +interface SlotContributions { + /** Exclusive. A second claim on the same slot is an assembly error. */ + claim

(slot: string, factory: (props: P) => Component): void; + /** Additive. Ordered by contribution order. */ + add

(slot: string, factory: (props: P) => Component): void; +} +``` + +```ts +ctx.use(Tui).slots.claim("footer", (props) => new FooterComponent(props, models)); +``` + +`claim` is exclusive and a duplicate is an **assembly error, not last-write-wins** — +two plugins silently fighting over the footer is a bug that must surface at +validation, alongside missing providers and duplicate singletons. + +Props are DTOs. A factory never receives the `TUI` or `Theme` instance, so a +component cannot reach back into host internals through its own arguments. + +### 11.2 Mounting is host-owned + +Facets do not mount or unmount. They contribute a factory; the host instantiates +it, tracks the mount against the contributing facet, and **unmounts it on +disposal** — restoring the built-in chrome for a claimed slot, or dropping the +entry for an additive one. + +There is therefore no `dispose?()` on a contribution, and no teardown for a facet +author to forget. This is the same rule as §13: registration is ownership. + +Two consequences fall out of the generation model in §7: + +- **Attachment drop** — presentation facets consuming session services are + disposed, so their mounts vanish and host chrome renders in their slots. Only + connection-generation chrome reads attachment state. +- **Session switch** — attachment-generation facets are disposed and their mounts + go with them; connection-generation mounts survive, so there is no flicker and + no lost scroll state. + +### 11.3 Contributions with in-flight work + +§11.2 covers *removal*: the host tracks each mount against its contributing facet +and unmounts on disposal, so there is no teardown to forget. That is complete for +anything whose disposal is synchronous. + +It is not complete for contributions that can be **executing** when their +contributor is disposed — the presentation analogue of an in-flight tool call +(§13.2). Three cases, and only one of them is hard. + +**Synchronous callbacks are free.** A keypress handler, a render call, a click +listener. JavaScript is single-threaded, so a handler runs to completion before +disposal can be scheduled — disposal cannot interleave with a synchronous frame. +Deregistration is a list removal. Nothing further is needed, and this is most of +the surface: slots, keybindings, tool renderers, theme tokens. + +**Modals and pickers already carry a signal.** `acquireModal(signal)` and +`select(title, items, { signal })` take an `AbortSignal` because the host may need +to dismiss them. Disposal aborts it, the promise rejects, and the facet's +`await` unwinds. The mechanism exists; disposal just has to use it. + +**Async command handlers are the hard case**, and they are exactly a tool call in +different clothing: + +```ts +ctx.use(Tui).commands.add("deploy", async (args, signal) => { + await longRunningThing(signal); +}); +``` + +The user runs `/deploy`, the facet is disposed ten seconds later, and the handler +is still awaiting. So `CommandContributions` is a registry with in-flight work and +takes the same four phases as the tool registry: deregister, signal, race the +kernel deadline, and settle by outcome — here dismissing any progress UI and +restoring host chrome rather than producing a `ToolResultMessage`. + +Two consequences for the API: + +- **A command handler receives an `AbortSignal`.** Not optional. Without it the + registry can deregister but cannot signal, and phase 2 is unavailable. +- **The host renders command progress, not the facet.** If a facet paints its own + progress and is disposed mid-command, the paint outlives the painter. Host-owned + progress is removed with the mount, which is the §11.2 rule applied to a case + §11.2 did not name. + +The rule this generalises to: + +> A contribution registry needs settlement machinery **iff** a contribution can be +> mid-execution across disposal. Synchronous contributions never can; anything +> handed an `AbortSignal` always can. + +Which makes the signal the marker. A registry whose contributions take a signal +owns invocation tracking and a deadline; one whose contributions do not is a plain +list and disposal is a removal. + +### 11.4 Reload + +Reload is unload plus load, so mounts are torn down and rebuilt by the same path. +Cross-host reload is two-phase, so no facet is ever alive with a dead provider: + +```text +1. server → presentations with dependent facets: dispose, ack + (the provider is still ALIVE, so in-flight calls abort against a live peer) +2. server → session worker: dispose + reload +3. server → presentations: load +race(acks, 2s); a presentation that misses the window goes degraded, +which disposes those facets anyway. +``` + +Calls carry a caller generation and the provider drops retired ones. Because a +reload is a round trip to every connected presentation, it stays user-initiated — +it must not be wired to a file watcher. + +## 12. Contribution registries + +Services are one owner, many consumers. Providers and tools invert that: many +contributors, one host-owned result. A registry replays ordered contributions over +a fresh working copy, so removal is a rebuild rather than an inverse mutation. +(Unrelated to the tracker in `delta.md`; nothing here is replicated.) + +```text +fresh working copy +→ built-in providers (@pi/providers-builtin) +→ remote catalogue (@pi/providers-catalog) +→ models.json transform (@pi/providers-models-json) +→ auth/availability mark (@pi/auth) +→ validated state +``` + +Tools additionally support ordered wrapping — `telemetry(permission(sandbox(bash)))` +— which recomposes deterministically when a contributor disappears. Only the host +finalizes a draft; facets never call `setTools()`. + +Slots, commands, keybindings, and tool renderers (§11) are all instances of this +same shape on the presentation host. + +**Registries split by whether a contribution can be in flight.** A registry whose +contributions are values — slots, keybindings, theme tokens, tool renderers — is a +list, and disposal is a removal (§11.2). A registry whose contributions are +*invoked and take an `AbortSignal`* — tools, commands — owns invocation tracking, a +signal chained from the caller's, and settlement against the kernel's disposal +deadline (§13.2). The signal in the contribution's signature is the marker for +which kind it is. + +Contributions configure rebuilt behaviour; hooks intercept live operations. They +remain separate mechanisms. + +## 13. Lifecycle, failure, disposal + +- Construct in dependency order; dispose in reverse. +- A facet owns its provisions, keyed instances, and observations. Ownership is + **implicit**: every handle a facet receives is a host-built binding that + registers its own disposer, so `subscribe`, `on`, timers, and instance handles + are released with the facet. There is no `own()` and nothing to remember. + This covers handles the host hands over; it does not cover ambient authority a + facet can reach without being handed anything (§14.3). + Consequently `subscribe()` returns nothing — there is no unsubscribe handle to + hold or leak. +- Admitted inbound calls may finish while their provider deactivates; withdrawal + rejects new calls. +- Disconnect aborts in-flight requests and closes that peer's subscriptions and + instance tasks. It must **not** perform service-owned or durable cancellation. +- Three cancellation domains stay distinct: per-invocation, service-owned + (`job.cancel()`), and durable Harness (`requestAbort()`). +- Never blindly replay a mutation after an uncertain disconnect. Reconnect, + hydrate, reconcile — or design around a stable operation ID. +- Errors cross as `{ code, message }` with stable codes; unexpected exceptions + become `internal_error` with no stack. + +Reload: shape-preserving replacement by `Facet.id` is permitted when the +replacement declares an identical manifest. Because the manifest is static, this +check happens **before** constructing the candidate — a real improvement over +validating shape by running setup. Structural changes require graph reassembly or a +process restart. + +### 13.1 Teardown, not swapping + +The alternative is a proxy per dependent, swapping the implementation behind +consumers so they are never torn down. It is rejected, and the prior art is +unusually clear. + +**OSGi ships both.** Bundle refresh computes the transitive closure of everything +wired to the old exports and restarts it; Declarative Services also offers +`ReferencePolicy.DYNAMIC`, where a consumer keeps running across a swap. Their own +guidance makes `STATIC` the default, because dynamic requires every consumer to be +defensive about the service vanishing mid-call. + +**Cordis is the proxy model, and the ergonomics show.** `ctx.get(name)` returns +`undefined` when absent and the guidance is to *"handle their absence"* — the +defensive check is the recommended path. `inject` opts *into* teardown: the plugin +enters waiting and is reactivated when the service returns. + +**JS HMR is this design with one addition.** Updates propagate up the import graph +until a module calls `import.meta.hot.accept()`; if nothing accepts, full reload. +React Fast Refresh bails to full reload when a hook signature changes — the same +conclusion, reached from a different direction: implementation swaps survive, shape +changes do not. + +**Erlang is the counterexample and it does not transfer.** Two module versions +coexist and `code_change/3` migrates state at the transition. That works because +contracts are messages rather than types, so a process can handle both shapes +during changeover, and because isolation means there is no shared state to +reconcile. Neither holds here. + +Three specific objections, in increasing order of severity: + +**Contract changes.** JVM HotSwap permits method bodies only; DCEVM and JRebel go +further and still break on shape change. We are better placed than any of them, +because a token's `protocol` block carries a TypeBox schema and `CheckComplete` +already does mutual assignability — so a swap *could* be gated on the new +provider's schema being assignable both ways. This is the one objection we can +actually answer. + +**In-flight registrations.** A tool contributed by the old provider may be +mid-execution. It cannot be cancelled (side effects have happened), handed over +(different closure), or allowed to settle into a registry that no longer contains +it. A proxy does not help: the invocation is bound to the implementation that +started it. §13.2 is required either way, which is most of why the proxy earns +nothing. + +**State.** This is the one with no answer. A facet holds a replica derived from the +old provider's stream. After a swap it is either stale — silently wrong — or the +new provider sends a base batch, which *is* a resubscribe. The state was torn down; +only the facet was not. Erlang solves this with an explicit migration hook, which +is Erlang's design without Erlang's isolation. + +**Cached references make the proxy worse, not better.** Cordis's guard lives on the +access path, not on the value: `ctx.foo` throws if the service is gone, but a +reference you stored keeps working and calls into the dead plugin's closure. Our +`ctx.use(Token)` returns a value at construct time by design, so we have the same +hazard — and teardown-of-dependents is what makes it safe, because the holder dies +with the provider. A proxy does not fix cached references; it makes them silently +wrong instead of impossible. + +> **Deferred, not adopted.** If teardown ever proves too coarse, the addition is +> HMR's `accept()` rather than OSGi's dynamic policy: a per-dependency opt-in, +> `uses: [Harness, accepts(Models)]`, meaning *my acquisition can be re-pointed, I +> derive no state from this provider, and I hold no in-flight registrations against +> it*. The kernel would permit the swap only if the schema check passes and +> silently fall back to teardown otherwise. **Teardown must remain the path that +> always works**; the moment `accepts` is load-bearing for correctness, every +> consumer is writing defensive code again. +> +> Worth measuring the cost of teardown before building it. If disposing a +> presentation facet is a repaint, the mechanism buys nothing. + +### 13.2 Disposal with in-flight work + +Disposal removes a registration. That is sufficient for a service, and **not** for +a registry whose contributions can be executing when their contributor goes away. + +The forcing constraint is settlement, not cleanup: the harness owes the model a +`ToolResultMessage` for every call it started. "The facet went away" is not a +result, and an operation waiting on one hangs. + +Neither reference system solves this. Cordis's `_unload` is +`await Promise.all(disposers)` with a try/catch and no deadline — a disposer that +hangs hangs the reload. DSH goes further and places the obligation on the tool +author: async work must *"observe or forward `exec.signal` and settle only after"* +reaching *"quiescence"*, with the registry rechecking cancellation afterwards. That +is the settlement concept Cordis lacks, but it is stated in prose and enforced by +nothing, so a tool that ignores its signal still wedges the unload. + +**Four phases, and the last two are what neither system has:** + +1. **Deregister.** The contribution leaves the registry immediately. Synchronous, + cheap, and it stops the problem growing. +2. **Signal.** Abort each in-flight invocation. +3. **Race a deadline.** Kernel-imposed, on every disposer. +4. **Settle by outcome.** At expiry the registry resolves *its own* promise with an + aborted outcome and abandons the contributor's, attaching a catch and dropping + the reference. The harness sees an ordinary aborted invocation and its existing + `abortedMessage` path produces the result. No new settlement machinery. + +Phase 4 is what makes the deadline safe rather than a leak with extra steps: **the +invocation settles even when the contribution does not.** + +**Layering.** The kernel knows facets and contributions, not invocations, so it +bounds disposal generically: every disposer gets a deadline, and one that overruns +is logged and dropped. A registry with in-flight work is what makes that deadline +meaningful, because only it knows a call is outstanding. + +The signal is **registry-owned and chained** from the harness signal, never the +harness signal itself — so deregistering one tool aborts that tool's invocations +without cancelling the operation. DSH's warning that *"replacement cannot detach +caller cancellation"* is about exactly this; the chain must be one-way. + +**Invocation tracking belongs to the registry, not the contributor.** A facet could +track its own outstanding calls and signal them, but then a buggy or hostile facet +has no bound at all. Every invocation already routes through the registry, so it +can hold the signal and the count without trusting anyone. + +**What the deadline actually guarantees, stated honestly:** disposal completes in +bounded time and the kernel holds no reference afterwards. **Not** that the bundle +is collected. If the abandoned contribution has a live interval, an open socket, or +a pending child process, its own async work still references the closure and the +compartment stays alive. We can bound our retention; we cannot bound the runtime's. +The only real fix is process isolation, where teardown is a kill — which is why +Erlang supervisors terminate rather than negotiate, and why OSGi `refresh` can hang +on a bundle that will not stop. + +## 14. Isolation and trust + +Condensed; a full treatment lives in the isolation spec. Recorded here because it +constrains the API surface above. + +### 14.1 Threat model + +**A malicious server ships a facet to an unsuspecting client.** + +§7 states that a presentation ships with no plugin facets and that all of them +arrive over the wire as built bundles. So third-party code executes in the user's +process by design, and connecting to a server is not consent to run its code. The +victim is the *user*, with their filesystem, their credentials, their SSH keys — +not the operator. + +An earlier draft scoped this to "a careless plugin author, not a hostile one", +with a determined attacker explicitly out of scope. **That was wrong** and it +selected the wrong mechanism. The attacker is hostile, the delivery is remote, and +the target is a workstation. + +**Availability is not in scope.** A malicious server can already refuse to serve, +hang, or send garbage; crashing the client is a subset of that. What must be +prevented is **disk access and exfiltration**. + +That asymmetry decides several things below, and it is the reason this section is +shorter than a general sandboxing treatment would be. + +### 14.2 Mechanism: a V8 isolate behind a string-only membrane + +Facet code runs in an `isolated-vm` isolate. Measured behaviour, not inference — +see the sandbox PoC: + +``` +require / process / fetch undefined +globals visible 64 +Function("return process")() undefined +spinning guest interrupted at its timeout, isolate reusable after +``` + +**What was rejected, and why.** + +*SES / `lockdown()`* — an earlier draft chose this. It hardens intrinsics but +leaves guest and host **in the same VM**, which is precisely the class Figma's +Realms shim failed on: "confusing an object from outside the sandbox with an +object from inside... possible because the shim uses the same JavaScript VM for +all code both inside and outside". Figma shipped Realms, was breached within two +months, and moved to a different VM. Choosing SES repeats their first attempt. + +*Node `worker_threads`* — `terminate()` is a genuine kill (3 ms on a tight loop), +but a worker has full `fs`, `env` and `child_process`. Node's `--permission` model +does apply inside workers, but Node documents it as a **"seat belt"** that +"malicious code can bypass", and it does not inherit per-worker. Availability +without authority is the wrong half for this threat model. + +*Deno workers* — `permissions: "none"` gives real per-worker authority reduction, +verified. But `terminate()` does **not** stop a spinning worker: 2990 ms of CPU +over 3 s wall, measured after terminate. And it is a runtime switch. + +*QuickJS-WASM* — a genuinely different VM, so object confusion is impossible, and +it is what Figma shipped. Rejected on two measurements: **8–17× slower** (300 +markdown components repaint in 1118 ms versus 108 ms), and **no `Intl`**, which +`packages/tui` needs for grapheme segmentation in every width calculation. It +remains the fallback if a native addon is unacceptable. + +*ShadowRealm* — same thread, same VM, and its own explainer disclaims being "a +full spectrum mechanism against security issues". Not a successor for this +purpose. + +**The membrane is what makes `isolated-vm` safe, and it is structural.** + +`isolated-vm` can be used unsafely, and an earlier draft rejected it for exactly +that: handing the guest a live handle via `derefInto()`, or returning a +`Reference` from a host call, lets the guest walk that object's prototype chain +into the host realm. (The draft also called the project "maintenance mode" — that +is out of date; releases run 6.0.1 in Jul 2025 through 7.0.1 in Aug 2026.) + +The membrane forecloses it by construction rather than by care: + +1. `encode()` is the only path from host to guest, and it is `JSON.stringify` + with a replacer. **Its output is a string.** A string cannot carry a reference. +2. Host callables never cross. They become an integer id into a host-side table. + The guest receives a **number**. +3. Exactly **one** `Reference` reaches the guest — the single call-in point — and + its `deref()` throws cross-isolate. +4. `derefInto()` is used once, on the guest's **own** global. No host object is + ever its argument. + +The guest's entire view of the host is `{ number, string }`. There is no object +graph, so there is nothing to walk. Audited: `deref`, `copySync` and `getSync` all +blocked; `derefInto()` yields an inert marker that is not callable and exposes no +host globals. + +**One isolate, N contexts** — not one isolate per facet. Contexts give separate +globals, and a timeout on one context leaves the others running. They share a +`memoryLimit`, so one facet's allocation bomb disposes the isolate and every +context in it — which does not matter here, because availability is out of scope +(§14.1). The cost difference is real: **164 KB per context versus 1124 KB per +isolate**. + +A facet that must survive its neighbours — host chrome running as a facet — takes +its own isolate. That is a per-facet decision, not a global one. + +**Limits worth knowing.** + +- **Budgets do not nest.** A timeout bounds one evaluation. A guest function + re-entered *by the host* — a contributed callback, a component method — gets its + own budget, not the outer one. Bounding total facet time needs separate + accounting. +- **References release, but lazily.** The membrane interns by id with `WeakRef` + plus a `FinalizationRegistry`, which both gives identity across crossings and + releases on collection. Finalization is not prompt, so **do not create + references in a hot path**: a factory registered at construct is one reference + forever; a component method returning a fresh closure each frame is one per + frame. +- **Native addon.** Prebuilds cover linux-x64/arm64, darwin-arm64 and win32-x64 + for Node 22 and 24 only; anything else falls back to a source build needing + Python and a toolchain. Shipping SEA builds moves that from every user's install + to CI. +- **Still one engine.** A separate isolate is a far stronger boundary than SES, + but it is not Figma's "different VM" claim. The guarantee here is V8's isolate + boundary plus the membrane's discipline. + +### 14.3 Ambient authority defeats registration-is-ownership + +§13 states that every handle a facet receives is a host-built binding that +registers its own disposer, so there is nothing to remember. That holds for +handles the host *hands over*. It says nothing about authority a facet can reach +**without** being handed anything: `setInterval`, `process.on`, +`document.addEventListener`, a WebSocket. + +Registration-is-ownership is a property of the API surface, and it is only as +complete as the surface is exclusive. Where a facet has another way to reach the +outside world, disposal is back to author discipline — exactly the position §13.1 +criticises Cordis for (*"do not assume unload automatically removes arbitrary +third-party callbacks"*). + +How complete we can make it is **not uniform**, and pretending otherwise would be +the wrong kind of tidy. + +**Session and server facets: closable, and this argues for compartments.** They run +in Node with full ambient authority today — a facet can `import` timers, `fs`, +`net`. Inside a compartment with no module access, the only globals are the ones we +endow, so an endowed `setTimeout` that registers its own disposer is the *only* +`setTimeout`. That is a real argument for resolving the open question above toward +compartmenting session facets, on ergonomics rather than on trust. + +**TUI: closable for the same reason.** A presentation facet already gets a +compartment (§14.2) and reaches the terminal only through the `TuiHost` facade. +There is no ambient terminal to grab. Endow timers the same way and the surface is +exclusive. + +**Web: not closable, and it is worth being explicit about why.** The DOM is an +ambient mutable graph reachable from *any node in it*. Hand a component one +element and it has `ownerDocument`, `parentNode`, `window` — and from there +`addEventListener`, `MutationObserver`, a detached subtree that outlives its +mount. `lockdown()` does not help: the escape is not a prototype, it is a live +object graph we deliberately gave away. + +Two mechanisms would actually close it, and both cost more than the problem is +currently worth: + +- **An iframe per facet.** Real isolation, `postMessage` boundary, teardown is + removing the frame. VS Code does this for webviews. The cost is no shared DOM: + styling, layout, and focus all become protocol. + + Figma goes further and is worth studying, because it is the strongest form of + "make the surface exclusive". They split by *capability*: plugin logic runs in + QuickJS-on-WASM with the scene graph and **no browser APIs at all**, while UI + runs in an iframe with browser APIs and **no scene access**, connected by + `postMessage`. Plugin code never touches the DOM — not "sandboxed DOM access", + no `document` in the environment. That is what lets them guarantee cleanup. + + Two things to take from their history. They shipped **Realms** first and it was + found insecure within two months, which is the same escape class §14.2 cites for + rejecting `isolated-vm` — production evidence rather than inference. And the + QuickJS cost is real: practitioners report *"truly impenetrable errors"* and a + badly degraded debugging story, which §14.2 should weigh when it names QuickJS as + the upgrade path. As one put it, sandboxing is a thing *"everyone wants, but + there are so few examples of it actually ever working."* +- **Declarative-only components.** The facet never receives a node — it returns a + description and the host reconciles. §11.1 already points this way (*"props are + DTOs; a factory never receives the `TUI` or `Theme` instance"*), and extending it + to *never receives a DOM node* would hold. But it rules out refs, and therefore + most component frameworks worth using. + +**So the rule is: make the safe path the easy path, and do not pretend the escape +is closed.** + +Provide `ctx.dom.on(el, event, fn)` and `ctx.timer.every(ms, fn)` that register +their own disposers, and make them the obvious way to do the thing. §14.1's threat +model is a *careless* author, not a hostile one, and a careless author uses the +ergonomic path. The escape remains reachable; it just is not the one you fall into. + +Where the surface can be made exclusive — session, server, TUI — enforce it with +endowments rather than relying on the ergonomics. Where it cannot — web — document +that facet authors own their own DOM cleanup, and treat the iframe as the upgrade +path if the threat model ever moves from careless to hostile. + +This is the same shape as §14.2's isolate decision: take the mechanism that fits +the current threat model, name the stronger one, and record what would trigger +moving to it. + +### 14.4 Consequences for the API + +Isolation does not change the shapes in §3–§4, but it fixes three things that would +otherwise be conventions: + +- **Ownership is implicit.** Every handle a facet receives is a host-built binding + that registers its own disposer. Hence no `own()`, and `subscribe()`/`on()` + return nothing — there is no unsubscribe handle to hold, and therefore none to + leak. Local `{ rpc: false }` services are the exception: they hand back the raw + implementation with no interception and no auto-disposal. +- **Every object a binding returns is itself a binding** — host-built, plain + prototype, hardened, registering its own disposer. This is the invariant that + makes the previous point hold transitively. +- **Borrowed immutable JSON is enforced, not advised.** Values are hardened as they + cross the boundary and rejected if unfrozen, so §9's no-clone rule is safe rather + than a documented hope. + + This does **not** extend to a replica's internal buffer. `apply` from `delta.md` + mutates a plain object in place and would throw against a frozen one, so the host + keeps the replica mutable and hardens only the value it hands to a facet through + `State.value`. Freezing happens at the binding, not at the buffer. The same rule + governs the producer side: the tracker proxies a mutable object and hardens what + it exposes. + +Facets are built with `await` compiled to generators, so the host owns resumption. +Disposal then stops three distinct things, none of which subsumes the others: +cancel the scope's root context (stops the **operation**), unwind the driver +(stops the **continuation**, running every `finally` and no `catch`), then release +registrations in reverse (stops the **effects**). + +Endowments are minimal: `Date` and `Math` are replaced with clock-backed versions, +timers return opaque integers and are scope-owned, `process` and `console` are +shape-only stubs, and `fetch`, `Buffer`, `require`, and `process.exit` are never +endowed. **Network access is a binding, never an endowment.** + +## 15. Protocol: exposing services to foreign clients + +Everything above assumes both ends are TypeScript compiling against the same tokens. +A browser, a script, or another language cannot do that — it needs a description it +can fetch. + +**Schemas are opt-in and per service.** Most plugins never expose anything, and pay +nothing. A service with no `protocol` block is in-process and native-RPC only: it is +absent from the catalogue and the HTTP router returns `no_such_member`. + +### 15.1 Declaring a protocol + +```ts +import { object, array, string, int, oneOf, literal } from "@pi/schema"; + +export const TranscriptEntry = object({ + id: string(), + role: oneOf([literal("user"), literal("assistant")]), + text: string(), +}); +export type TranscriptEntry = Static; + +export const TranscriptState = defineState("pi.transcript.tail", { + schema: object({ entries: array(TranscriptEntry) }), +}); + +export interface Transcript { + readonly tail: State; + page(params: { before: string; limit: number }, context: Context): Promise; + subscribeRaw(sink: (e: TranscriptEntry) => void): Unsubscribe; +} + +export const Transcript = defineService("pi.transcript", { + protocol: { + methods: { page: { params: object({ before: string(), limit: int() }), + result: array(TranscriptEntry) } }, + state: { tail: TranscriptState }, + }, +}); +``` + +`page` and `tail` are published. `subscribeRaw` is absent from `protocol`, so it is +local — nothing marks it, and publishing it later means adding one entry. + +Only the state **value** needs a schema, for the `replace` frame. Mutations do not, +because recipes never leave the provider (§9.2) and ops are structural. + +Method parameters are a single object, not positional. Names then survive into the +TypeScript signature, into the JSON Schema as `properties`, and into the request body, +and adding an optional field does not change arity. + +`@pi/schema` re-exports TypeBox constructors as free functions so declarations stay +readable. They are functions rather than constants because TypeBox schemas are mutable +objects and a shared constant would alias into every schema referencing it. + +### 15.2 The protocol must match the type + +A schema that drifts from the interface is the failure this design exists to prevent, +so it is checked, with the same mutual-assignability technique as `CheckComplete` (§4): + +- every key in `protocol.methods` must exist on the service interface — a typo is a + compile error, not a silently unpublished member; +- for each, `(params: Static

, context: Context) => Promise>` must be + assignable to the interface member and vice versa; +- every key in `protocol.state` must be a `State` member whose `T` matches the + definition's value schema. + +Omission is always legal. Omission is the feature. + +### 15.3 Routes + +``` +GET /v1/catalogue +POST /v1/call/{service}/{member} +POST /v1/call/{service}/{key}/{member} keyed instance +GET /v1/state/{service}/{member} SSE +GET /v1/state/{service}/{key}/{member} SSE +``` + +`peer` services take no key: the server resolves the instance from the authenticated +session, so a client cannot address another peer's (§10.2). + +**Catalogue** + +```json +{ "version": "1", + "services": { + "pi.transcript": { + "mode": "singleton", + "methods": { "page": { "params": { "$ref": "#/definitions/PageParams" }, + "result": { "type": "array", + "items": { "$ref": "#/definitions/TranscriptEntry" } } } }, + "state": { "tail": { "value": { "$ref": "#/definitions/TranscriptTail" } } } + } + }, + "definitions": { "TranscriptEntry": { "type": "object", "properties": { "...": {} } } } } +``` + +**Call** + +``` +POST /v1/call/pi.transcript/page +{ "before": "entry_88", "limit": 50 } + +200 { "result": [ { "id": "entry_87", "role": "assistant", "text": "..." } ] } +403 { "code": "forbidden", "message": "unknown root" } +404 { "code": "no_such_member" } ← unpublished members land here +422 { "code": "invalid_params", "message": "limit: expected integer" } +``` + +**State over SSE** + +``` +GET /v1/state/pi.lane/main/snapshot +Accept: text/event-stream +id: 0 +event: replace +data: {"lane":"main","transcript":[],"operation":null} + +id: 1 +event: ops +data: [{"op":"splice","path":["transcript"],"index":12,"remove":0,"items":[{"id":"e12"}]}] + +id: 2 +event: ops +data: [{"op":"append","path":["operation","streamingMessage","content",0,"text"],"value":"Sure, I"}] +``` + +`id` is stamped by the binding, not carried in the payload. The first batch is +always a base batch, so hydration is not +a separate route. `Last-Event-ID` is **not** honoured: `seq` restarts per +subscription, and resubscription is a base frame plus buffered frames (§9.2); a gap, an unknown id, and a provider reload all take that same path. A +closed keyed instance ends the stream with `event: closed` and the client does not +retry. + +A foreign client needs only the five ops of `delta.md` §2 — `set`, `delete`, +`append`, `truncate`, `splice`. No mutation names, no recipes, no provider code. The applier is +a page of code in any language. + +**This is where non-conformance becomes visible**, and it is the one place worth +being deliberate about it. The format is JSON-Patch-*shaped*, not RFC 6902: paths are +arrays rather than string pointers, and `append`, `truncate` and `splice` have no +RFC equivalent. A client reaching for an off-the-shelf `jsonpatch` library will not +work. + +Two mitigations, neither of which is "conform": + +- The catalogue advertises the op vocabulary explicitly, so a client discovers the + five verbs rather than assuming six different ones. +- If a client genuinely needs RFC 6902, the server can offer a lossy downgrade behind + a content negotiation header — join paths with slashes and `~0`/`~1` escaping, + materialise `append` and `truncate` into whole-value `replace`, expand `splice` + into `add`/`remove` runs. About fifteen lines. It reintroduces the quadratic text + cost, which is the point: conformance is available and it is the client paying for + it. + +Conforming by default would impose that cost on every consumer to serve a +hypothetical one, and would not even buy real interoperability, since Immer's output +was never conformant either (§9.2). + +### 15.4 What publishing commits you to + +A member in the catalogue is a compatibility promise in a way an internal token is not. +Members should carry a stability marker from the start so that publishing does not +silently mean freezing, and §16 lists version negotiation as unresolved. + +## 16. Deltas against `plugins.md` + +| topic | `plugins.md` | here | +| --- | --- | --- | +| dependency declaration | derived from `setup()` side effects | static `uses`/`provides`/`observes` | +| handles during setup | disconnected lazy proxies | real objects, after validation | +| validation | requires running facet code | pure manifest analysis | +| mode | declared per call site, validated | property of the token | +| cycles | tolerated via laziness | rejected; explicit `deferred()` escape | +| replication | `ReplicatedState` full-value; `DeltaState` deferred | one primitive; `replace` + five-verb ops | +| hydration | separate atomic snapshot + buffering | frame zero of the stream | +| presentation facets | loaded locally | delivered by server and worker | +| server↔worker | unspecified inversion | reporting registries; deps point upstream | +| authority | `requireClientIdentity` in method bodies | principal on context; filtered views + handle checks | +| per-client state | not addressed | `peer` mode: one instance per peer, consumed as a singleton | +| resource ownership | explicit `own()` | implicit; every handle is a self-disposing binding | +| state updates | hand-written patch unions | plain mutation on the provider; five-verb ops on the wire | +| UI mounting | facet-owned panels, unspecified host API | slots with `claim`/`add`; host unmounts on disposal | +| isolation | trusted code, unspecified | SES compartment per presentation facet | +| foreign clients | not addressed | opt-in `protocol` block; JSON Schema catalogue + HTTP/SSE | +| lifecycle | `setup` only | sync `construct`, async activate / deactivate | + +## 17. Open decisions + +- Whether cwd-sourced *session* facets need compartments too (§14.2). +- `CheckComplete` error message ergonomics. +- Where roles come from, and whether they are per-server or per-session. +- Whether handle tables are facet-owned or a kernel-provided utility. +- How an owner grants and revokes guest access at runtime. +- Protocol version negotiation, and how a state value definition's reducer participates + in source-generation skew (§9.2). +- Flow control for high-frequency state values; gap recovery is settled (§9.2). +- Discovering lanes created after startup (§9.2). +- Whether `reduceLaneSnapshot` becomes a draft mutator now or later; until it does, + lane state is replace-only. +- Whether `deferred()` survives contact with real plugins, or should be removed. +- Property tests for the tracker (`delta.md` §3.3) before any facet depends on it. +- Where the adaptive ops-vs-replace threshold is measured — per flush against the + current snapshot size, or amortised. diff --git a/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/README.md b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/README.md new file mode 100644 index 000000000..fffda9b62 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/README.md @@ -0,0 +1,162 @@ +# Facet sandbox — isolated-vm + +**Unmodified pi facet code running in a V8 isolate with no ambient authority.** +Full JIT, so ~1.8× native rather than the 8–17× a WASM interpreter costs. + +```bash +npm install +npm run demo # a facet: contributions, components, commands, callbacks +npm run audit # escape audit — the interesting one +npm run bench # crossing cost, isolate cost, 300 components +npm test # 412 property assertions +``` + +Node 22+ (`--experimental-strip-types`). See `../facet-sandbox-poc` for the +QuickJS-WASM equivalent. + +## The safety claim, and why it is structural + +isolated-vm **can** be used unsafely, and that is what `facets.md` §14.2 objected +to. The footgun is handing the guest a live handle to a host object — via +`derefInto()`, or by returning a `Reference` from a host call — after which the +guest walks that object's prototype chain into the host realm. + +This membrane makes that impossible **by construction, not by care**: + +1. `encode()` is the only way a host value reaches the guest, and it is + `JSON.stringify` with a replacer. **Its output is a string.** A string cannot + carry a reference. +2. Host callables never cross. They become an integer id into `hostTable`, which + lives entirely host-side. The guest receives a **number**. +3. Exactly **one** `Reference` is ever given to the guest — `__invokeRef`, the + single call-in point. +4. `derefInto()` is used exactly once, on the guest's **own** global. No host + object is ever its argument. + +The guest's entire view of the host is `{ number, string }`. There is no object +graph, so there is nothing to walk. + +### Audit results (`npm run audit`) + +``` +Ambient authority: + require / process / fetch ["undefined","undefined","undefined"] + global names visible 64 globals + +Classic escape ladders: + Function('return process')() undefined + constructor walk on host data undefined + +The isolated-vm Reference footgun: + __invokeRef.deref() blocked: TypeError ("Cannot dereference + this from current isolate") + __invokeRef.copySync() blocked: TypeError + __invokeRef.getSync('constructor') blocked: TypeError + derefInto() is callable? inert (object) + invoke derefInto() result blocked: TypeError + host globals via derefInto() no host globals + +Resource bounds: + spinning guest interrupted after 205ms + isolate usable afterwards true +``` + +One probe gave a **false positive** in a first draft: testing `f.deref === undefined` +to prove a returned host function is a Proxy rather than a Reference. The +membrane's proxy returns a proxy for *every* property, so `f.deref` is truthy. +That is not a leak — calling it routes to `hostFn["deref"]`, which does not exist +and throws host-side. The audit now calls it instead of checking for absence. + +## Performance + +| | isolated-vm | QuickJS | native | +| --- | --- | --- | --- | +| 300 markdown components, small | **70 ms** | 494 ms | ~40 ms | +| 300 markdown components, typical | **108 ms** | 1118 ms | 64 ms | +| 300 markdown components, large | **419 ms** | 4260 ms | ~150 ms | +| 146 KB bundle load | **35 ms** | 104 ms | — | +| membrane crossing | **4.5 µs** | 7–17 µs | — | +| per compartment | 1080 KB, 5.5 ms | **77 KB, 0.8 ms** | — | + +**~1.8× native at typical size.** QuickJS is 8–17× and gets worse with input size. + +Isolates cost **14× more memory than a QuickJS runtime** (1080 KB vs 77 KB) and +are slower to create. For a handful of facets that is irrelevant; for hundreds it +would not be. + +`Intl.Segmenter` **exists** here, which is the single hard blocker for reusing +`packages/tui` components under QuickJS. That alone may decide it. + +## Limitations + +**Budgets do not nest.** A timeout bounds one `evalSync`. A guest function +re-entered *by the host* — a contributed callback, a component method — gets +`callGuestRef`'s own budget, not the outer one. Asserted explicitly in +`property-test.ts`; a runaway callback took 5005 ms under an outer budget of 50 ms. +Bounding total facet time needs separate accounting. + +**Async is a settle-callback, not a native promise.** `applySync` is synchronous, +so a host Promise cannot cross. The host returns a token, the guest builds a real +Promise against it, and the host settles it by calling back in. Verified: two +sequential guest `await`s while the host event loop ticked 22 times. + +**Native addon, and the ABI matrix is real.** `isolated-vm` ships prebuilds and +only falls back to `node-gyp rebuild` when none matches — which is why install +takes a second, not an hour. It is **not** building V8; V8 is already in the Node +binary. But coverage is narrow: + +| version | engines | prebuilds | +| --- | --- | --- | +| **6.2.0** | `>=22.0.0` | linux-x64/arm64, darwin-arm64, win32-x64 — abi127, abi137 | +| 7.0.1 | `>=24.0.0` | — | +| 7.0.0 | `>=26.0.0` | — | + +darwin-**x64** is absent everywhere. Installing `isolated-vm@7` on Node 22 falls +through to a source build and **fails without Python and a C++ toolchain** — +verified here. Note also that 6.2.0 is `latest` on npm despite 7.0.1 existing, +because it published one day later. + +The project is **not abandonware** — 6.0.1 (Jul 2025) through 7.0.1 (Aug 2026), +actively released. §14.2's "maintenance mode" claim is out of date and should be +corrected. The real risk is different: adopting it couples your minimum Node +version to theirs, and they dropped Node 20 in 6.x and Node 22 in 7.x within a +year. Shipping SEA builds moves that from every user's install to your CI. + +**Memory: references release, but lazily.** The `WeakRef` + `FinalizationRegistry` +interning table ports unchanged from the QuickJS membrane and works — 20 000 +crossings went to **0 live refs**. But finalization is not prompt: 5 000 refs were +still live 200 ms after a forced GC. Under sustained churn refs accumulate faster +than the collector reclaims them, and a 32 MB isolate did hit its limit. + +The consequence is an API rule, not a membrane fix: **do not create references in +a hot path.** `slots.claim(factory)` at construct is one reference forever; a +component method returning a fresh closure every frame is one per frame. + +**Hitting `memoryLimit` kills the facet cleanly.** It throws a catchable +`"Isolate was disposed during execution due to memory limit"` and the host +survives — but the isolate is dead and unrecoverable, so teardown must tolerate +an already-disposed isolate. `dispose()` on it throws `"Isolate is already +disposed"`; the membrane guards this. + +> **Do not call `isolate.getHeapStatisticsSync()` on a stressed isolate.** It +> **aborted the whole process** during testing — a hard crash, not an exception. +> A `heapMB()` helper was removed from the membrane rather than shipped. + +**Still one process, one engine.** A separate isolate is a far stronger boundary +than SES, but Figma's argument for QuickJS was that a *different VM* cannot +confuse objects because the representations differ. Here the guarantee is V8's +isolate boundary plus the membrane's discipline — strong, and structurally +enforced above, but not the same category of claim. + +## Files + +- `src/membrane.ts` — the membrane. The safety argument is in the header comment. +- `src/facet-example.js` — unmodified facet code: `ctx.use()`, `slots.claim()` + with a closure, a class instance returned to the host, a subscribe callback. +- `src/demo-facet.ts` — loads it and exercises four boundary crossings. +- `src/escape-audit.ts` — the audit above. +- `src/property-test.ts` — 412 assertions: random `JsonValue` round-trips both + directions, identity across crossings, callables, error propagation, reference + release under GC, prototype pollution both ways, budgets, async, disposal. +- `src/bench.ts` — the numbers above. +- `src/markdown-bundle.js` — the real pi `Markdown` component, esbuild-bundled. diff --git a/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/package.json b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/package.json new file mode 100644 index 000000000..fb597ecc6 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/package.json @@ -0,0 +1,13 @@ +{ + "name": "facet-sandbox-ivm", + "private": true, + "type": "module", + "description": "Running pi facets in an isolated-vm isolate: full JIT, no ambient authority.", + "scripts": { + "demo": "node --experimental-strip-types src/demo-facet.ts", + "audit": "node --experimental-strip-types src/escape-audit.ts", + "bench": "node --experimental-strip-types src/bench.ts", + "test": "node --experimental-strip-types --expose-gc src/property-test.ts" + }, + "dependencies": { "isolated-vm": "6.2.0" } +} diff --git a/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/bench.ts b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/bench.ts new file mode 100644 index 000000000..f917c767e --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/bench.ts @@ -0,0 +1,48 @@ +/** + * Cost of the isolate, the membrane, and a real component at transcript scale. + * Compare against `../facet-sandbox-poc` (QuickJS) and native. + */ +import ivm from "isolated-vm"; +import { readFileSync } from "node:fs"; +import { createCompartment } from "./membrane.ts"; + +// ── membrane crossing ───────────────────────────────────────────────────── +{ + const c = await createCompartment(); + await c.endow("__h", { echo: (v: unknown) => v }); + c.load(`globalThis.spin = function (n) { for (var i = 0; i < n; i++) __h.echo("x"); };`); + c.load(`spin(2000);`); + const N = 20000, t = Date.now(); + c.load(`spin(${N});`, 60000); + console.log(`membrane crossing: ${((Date.now() - t) * 1000 / N).toFixed(1)} µs`); + c.dispose(); +} + +// ── isolate creation ────────────────────────────────────────────────────── +{ + const before = process.memoryUsage().rss, t = Date.now(); + const held = Array.from({ length: 50 }, () => { + const iso = new ivm.Isolate({ memoryLimit: 32 }); + const ctx = iso.createContextSync(); + ctx.evalSync(`var c = { render: function () { return ["x"]; } };`); + return iso; + }); + console.log(`50 isolates: ${(((process.memoryUsage().rss - before) / 50) / 1024).toFixed(0)} KB each, ${Date.now() - t} ms total`); + for (const i of held) i.dispose(); +} + +// ── real Markdown at transcript scale ───────────────────────────────────── +{ + const iso = new ivm.Isolate({ memoryLimit: 512 }); + const ctx = iso.createContextSync(); + ctx.global.setSync("global", ctx.global.derefInto()); + const t = Date.now(); + ctx.evalSync(readFileSync(new URL("./markdown-bundle.js", import.meta.url), "utf8"), { timeout: 60000 }); + console.log(`146 KB bundle load: ${Date.now() - t} ms`); + for (const [label, pts] of [["small", 2], ["typical", 8], ["large", 35]] as [string, number][]) { + ctx.evalSync(`__bench(${pts}, 20, 50)`, { timeout: 120000 }); // warm the JIT + const ms = ctx.evalSync(`__bench(${pts}, 300, 3)`, { timeout: 300000 }); + console.log(`300 markdown components, ${label.padEnd(8)} ${ms.toFixed(0)} ms per full repaint`); + } + iso.dispose(); +} diff --git a/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/demo-facet.ts b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/demo-facet.ts new file mode 100644 index 000000000..eda12e7fa --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/demo-facet.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; +import { createCompartment } from "./membrane.ts"; +const mounted = new Map(), commands = new Map(), notifications = [], subs = []; +const Tui = { + slots: { claim: (s, f) => mounted.set(s, f) }, + commands: { add: (n, h) => commands.set(n, h) }, + notify: (m) => notifications.push(m), +}; +const Transcript = { tail: { subscribe: (f) => subs.push(f) } }; +const c = await createCompartment(); +await c.endow("__host", { use: (t) => (t === "Tui" ? Tui : Transcript) }); +c.load(readFileSync(new URL("./facet-example.js", import.meta.url), "utf8")); +c.load(`globalThis.__r = __facet.construct({ use: function (t) { return __host.use(t); } });`); +console.log("contributions:", { slots: [...mounted.keys()], commands: [...commands.keys()], subs: subs.length }); +const comp = mounted.get("footer")({ label: "presses" }); +console.log(comp.render(30).join("\n")); +comp.handleInput("abc"); +console.log(comp.render(30).join("\n")); +console.log("command ->", commands.get("footer.reset")()); +subs[0]({ entries: [1, 2, 3] }); +console.log("notifications:", notifications); +c.dispose(); diff --git a/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/escape-audit.ts b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/escape-audit.ts new file mode 100644 index 000000000..5f3692435 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/escape-audit.ts @@ -0,0 +1,63 @@ +/** + * Escape audit. + * + * The claim the membrane makes is that the guest's entire view of the host is + * `{ number, string }` — no object graph, so nothing to walk. This tries to + * break that, including the specific `Reference` footgun that makes isolated-vm + * dangerous when used naively. + */ +import { createCompartment } from "./membrane.ts"; + +const c = await createCompartment(); +const seen: string[] = []; +await c.endow("__h", { + sink: (v: string) => seen.push(v), + give: () => ({ nested: { a: 1 } }), + giveFn: () => () => "host function result", +}); + +const probe = (name: string, src: string) => { + c.load(`globalThis.__p = String(${src});`); + c.load(`__h.sink(__p);`); + const got = seen.pop(); + const bad = got !== undefined && /GOT (?!undefined)|INVOKED|CALLABLE|^object$/.test(got) && !/blocked/.test(got); + console.log(` ${bad ? "!!" : "ok"} ${name.padEnd(34)} ${got}`); + return !bad; +}; + +console.log("Ambient authority:"); +let ok = true; +ok = probe("require / process / fetch", `JSON.stringify([typeof require, typeof process, typeof fetch])`) && ok; +ok = probe("global names visible", `Object.getOwnPropertyNames(globalThis).length + " globals"`) && ok; + +console.log("\nClassic escape ladders:"); +ok = probe("Function('return process')()", `(function(){ try { return String((function(){}).constructor("return typeof process")()); } catch(e){ return "blocked: "+e.name; } })()`) && ok; +ok = probe("constructor walk on host data", `(function(){ try { var o = __h.give(); return String(o.constructor.constructor("return typeof process")()); } catch(e){ return "blocked: "+e.name; } })()`) && ok; + +console.log("\nThe isolated-vm Reference footgun:"); +ok = probe("__invokeRef.deref()", `(function(){ try { return "GOT " + typeof __invokeRef.deref(); } catch(e){ return "blocked: "+e.name; } })()`) && ok; +ok = probe("__invokeRef.copySync()", `(function(){ try { return "GOT " + typeof __invokeRef.copySync(); } catch(e){ return "blocked: "+e.name; } })()`) && ok; +ok = probe("__invokeRef.getSync('constructor')", `(function(){ try { return "GOT " + typeof __invokeRef.getSync("constructor"); } catch(e){ return "blocked: "+e.name; } })()`) && ok; +ok = probe("derefInto() is callable?", `(function(){ var d = __invokeRef.derefInto(); return typeof d === "function" ? "CALLABLE" : "inert (" + typeof d + ")"; })()`) && ok; +ok = probe("invoke derefInto() result", `(function(){ try { __invokeRef.derefInto()(); return "INVOKED"; } catch(e){ return "blocked: "+e.name; } })()`) && ok; +ok = probe("host globals via derefInto()", `(function(){ var d = __invokeRef.derefInto(); return String(d.process || d.global || "no host globals"); })()`) && ok; + +console.log("\nWhat a host FUNCTION looks like to the guest:"); +ok = probe("typeof a returned host fn", `(function(){ var f = __h.giveFn(); return typeof f; })()`) && ok; +// NOTE: `f.deref` is NOT undefined — the membrane's proxy returns a proxy for +// every property. That is not a leak: calling it routes to hostFn["deref"], +// which does not exist, and throws host-side. Testing for the property's +// absence gave a false positive in a first draft of this audit. +ok = probe("calling f.deref() reaches nothing", `(function(){ try { var f = __h.giveFn(); f.deref(); return "INVOKED"; } catch(e){ return "blocked: "+e.name; } })()`) && ok; +ok = probe("calling the host fn works", `(function(){ var f = __h.giveFn(); return String(f()); })()`) && ok; + +console.log("\nResource bounds:"); +const t0 = Date.now(); +let interrupted = false; +try { c.load(`while(true){}`, 200); } catch { interrupted = true; } +console.log(` ${interrupted ? "ok" : "!!"} spinning guest interrupted after ${Date.now() - t0}ms`); +console.log(` ok isolate usable afterwards ${c.load(`1+1`) === 2}`); + +c.dispose(); +console.log(`\n${ok && interrupted ? "AUDIT PASSED" : "AUDIT FAILED"}`); +process.exit(ok && interrupted ? 0 : 1); diff --git a/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/facet-example.js b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/facet-example.js new file mode 100644 index 000000000..0bdf0a9b4 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/facet-example.js @@ -0,0 +1,42 @@ +// ── REAL facet code. Nothing here knows it is sandboxed. ──────────────────── +// No imports rewritten, no string marshalling, no __dispatch. It uses the +// host API exactly as facets.md §11.1 describes it. + +class FooterComponent { + constructor(props) { this.label = props.label; this.count = 0; } + render(width) { + const text = ` ${this.label}: ${this.count} `; + const pad = Math.max(0, width - text.length - 2); + const bar = "\u2500".repeat(Math.max(0, width - 2)); + return [`\u250c${bar}\u2510`, `\u2502${text}${" ".repeat(pad)}\u2502`, `\u2514${bar}\u2518`]; + } + handleInput(data) { this.count += data.length; } +} + +const facet = { + id: "@demo/footer:tui", + uses: ["Tui", "Transcript"], + + construct(ctx) { + const tui = ctx.use("Tui"); + const transcript = ctx.use("Transcript"); + + // A factory crossing guest → host, by reference. + tui.slots.claim("footer", (props) => new FooterComponent(props)); + + // A command handler, likewise. + tui.commands.add("footer.reset", () => { + tui.notify("footer reset"); + return "ok"; + }); + + // A subscription: host calls back into the guest. + transcript.tail.subscribe((tail) => { + tui.notify(`transcript now has ${tail.entries.length} entries`); + }); + + return []; + }, +}; + +globalThis.__facet = facet; diff --git a/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/iso-vs-ctx.ts b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/iso-vs-ctx.ts new file mode 100644 index 000000000..48cf8f27b --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/iso-vs-ctx.ts @@ -0,0 +1,34 @@ +import ivm from "isolated-vm"; +const rss = () => process.memoryUsage().rss / 1024 / 1024; + +// 1. Are two CONTEXTS in one isolate isolated from each other? +{ + const iso = new ivm.Isolate({ memoryLimit: 64 }); + const a = iso.createContextSync(), b = iso.createContextSync(); + a.evalSync(`globalThis.secret = "from-A"`); + console.log("context B sees A's global:", b.evalSync(`typeof globalThis.secret`)); + // Can a spin in A be timed out without harming B? + try { a.evalSync(`while(true){}`, { timeout: 100 }); } catch { /* expected */ } + console.log("B usable after A was interrupted:", b.evalSync(`1+1`) === 2); + // Does A's memory bomb kill B? + try { a.evalSync(`var x=[]; for(;;) x.push(new Array(50000).fill(1));`, { timeout: 15000 }); } + catch (e: any) { console.log("A bomb:", e.message.slice(0, 45)); } + try { console.log("B after A's bomb:", b.evalSync(`1+1`)); } + catch (e: any) { console.log("B after A's bomb: DEAD -", e.message.slice(0, 40)); } + try { iso.dispose(); } catch {} +} + +// 2. Cost per facet, both ways. +for (const mode of ["one isolate, N contexts", "N isolates"]) { + const before = rss(), t = Date.now(); + const held: any[] = []; + const shared = mode.startsWith("one") ? new ivm.Isolate({ memoryLimit: 512 }) : null; + for (let i = 0; i < 50; i++) { + const iso = shared ?? new ivm.Isolate({ memoryLimit: 32 }); + const ctx = iso.createContextSync(); + ctx.evalSync(`var c = { render: function () { return ["x"]; } };`); + held.push(iso); + } + console.log(`50 facets, ${mode.padEnd(24)}: ${(((rss() - before) / 50) * 1024).toFixed(0)} KB each, ${Date.now() - t} ms`); + if (shared) { try { shared.dispose(); } catch {} } else for (const i of held) { try { i.dispose(); } catch {} } +} diff --git a/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/leak.ts b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/leak.ts new file mode 100644 index 000000000..d6c5b42f7 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/leak.ts @@ -0,0 +1,27 @@ +import { createCompartment } from "./membrane.ts"; +const c = await createCompartment(); +let seen = 0; +await c.endow("__l", { drop: (_fn: unknown) => { seen++; } }); +c.load(`globalThis.churn = function (n) { for (var i = 0; i < n; i++) __l.drop(function () { return i; }); };`); + +const rss = () => process.memoryUsage().rss / 1024 / 1024; +const before = rss(); +c.load(`churn(20000);`, 60000); +const peak = c.debugRefCount(); +const afterChurn = rss(); + +globalThis.gc?.(); await new Promise((r) => setTimeout(r, 100)); +globalThis.gc?.(); await new Promise((r) => setTimeout(r, 100)); +const settled = c.debugRefCount(); + +console.log(`20000 crossings: host called ${seen}x`); +console.log(` guest refs: ${peak.guestRefs} -> ${settled.guestRefs}`); +console.log(` host table: ${peak.hostTable} -> ${settled.hostTable}`); +console.log(` rss: ${before.toFixed(0)} -> ${afterChurn.toFixed(0)} -> ${rss().toFixed(0)} MB`); + +// Cost of the release path: one evalSync crossing per finalized ref. +const t = Date.now(); +c.load(`churn(5000);`, 60000); +globalThis.gc?.(); await new Promise((r) => setTimeout(r, 200)); +console.log(` 5000 more + gc: ${Date.now() - t} ms, refs now ${c.debugRefCount().guestRefs}`); +c.dispose(); diff --git a/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/markdown-bundle.js b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/markdown-bundle.js new file mode 100644 index 000000000..b43c46c82 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/markdown-bundle.js @@ -0,0 +1,3888 @@ +"use strict"; +var __m = (() => { + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); + }; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; + }; + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // packages/tui/bundle-bench.ts + var bundle_bench_exports = {}; + __export(bundle_bench_exports, { + bench: () => bench + }); + + // node_modules/marked/lib/marked.esm.js + function M() { + return { async: false, breaks: false, extensions: null, gfm: true, hooks: null, pedantic: false, renderer: null, silent: false, tokenizer: null, walkTokens: null }; + } + var T = M(); + function N(l3) { + T = l3; + } + var _ = { exec: () => null }; + function E(l3) { + let e = []; + return (t) => { + let n = Math.max(0, Math.min(3, t - 1)), s = e[n]; + return s || (s = l3(n), e[n] = s), s; + }; + } + function d(l3, e = "") { + let t = typeof l3 == "string" ? l3 : l3.source, n = { replace: (s, r) => { + let i = typeof r == "string" ? r : r.source; + return i = i.replace(m.caret, "$1"), t = t.replace(s, i), n; + }, getRegex: () => new RegExp(t, e) }; + return n; + } + var Te = ((l3 = "") => { + try { + return !!new RegExp("(?<=1)(?/, blockquoteSetextReplace: /\n {0,3}((?:=+|-+) *)(?=\n|$)/g, blockquoteSetextReplace2: /^ {0,3}>[ \t]?/gm, listReplaceNesting: /^ {1,4}(?=( {4})*[^ ])/g, listIsTask: /^\[[ xX]\] +\S/, listReplaceTask: /^\[[ xX]\] +/, listTaskCheckbox: /\[[ xX]\]/, anyLine: /\n.*\n/, hrefBrackets: /^<(.*)>$/, tableDelimiter: /[:|]/, tableAlignChars: /^\||\| *$/g, tableRowBlankLine: /\n[ \t]*$/, tableAlignRight: /^ *-+: *$/, tableAlignCenter: /^ *:-+: *$/, tableAlignLeft: /^ *:-+ *$/, startATag: /^/i, startPreScriptTag: /^<(pre|code|kbd|script)(\s|>)/i, endPreScriptTag: /^<\/(pre|code|kbd|script)(\s|>)/i, startAngleBracket: /^$/, pedanticHrefTitle: /^([^'"]*[^\s])\s+(['"])(.*)\2/, unicodeAlphaNumeric: /[\p{L}\p{N}]/u, escapeTest: /[&<>"']/, escapeReplace: /[&<>"']/g, escapeTestNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/, escapeReplaceNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g, caret: /(^|[^\[])\^/g, percentDecode: /%25/g, findPipe: /\|/g, splitPipe: / \|/, slashPipe: /\\\|/g, carriageReturn: /\r\n|\r/g, spaceLine: /^ +$/gm, notSpaceStart: /^\S*/, endingNewline: /\n$/, listItemRegex: (l3) => new RegExp(`^( {0,3}${l3})((?:[ ][^\\n]*)?(?:\\n|$))`), nextBulletRegex: E((l3) => new RegExp(`^ {0,${l3}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`)), hrRegex: E((l3) => new RegExp(`^ {0,${l3}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`)), fencesBeginRegex: E((l3) => new RegExp(`^ {0,${l3}}(?:\`\`\`|~~~)`)), headingBeginRegex: E((l3) => new RegExp(`^ {0,${l3}}#`)), htmlBeginRegex: E((l3) => new RegExp(`^ {0,${l3}}<(?:[a-z].*>|!--)`, "i")), blockquoteBeginRegex: E((l3) => new RegExp(`^ {0,${l3}}>`)) }; + var Oe = /^(?:[ \t]*(?:\n|$))+/; + var we = /^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/; + var ye = /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/; + var B = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/; + var Pe = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/; + var j = / {0,3}(?:[*+-]|\d{1,9}[.)])/; + var oe = /^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/; + var ae = d(oe).replace(/bull/g, j).replace(/blockCode/g, /(?: {4}| {0,3}\t)/).replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g, / {0,3}>/).replace(/heading/g, / {0,3}#{1,6}/).replace(/html/g, / {0,3}<[^\n>]+>\n/).replace(/\|table/g, "").getRegex(); + var Se = d(oe).replace(/bull/g, j).replace(/blockCode/g, /(?: {4}| {0,3}\t)/).replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g, / {0,3}>/).replace(/heading/g, / {0,3}#{1,6}/).replace(/html/g, / {0,3}<[^\n>]+>\n/).replace(/table/g, / {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(); + var F = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/; + var $e = /^[^\n]+/; + var U = /(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/; + var Le = d(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label", U).replace("title", /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(); + var _e = d(/^(bull)([ \t][^\n]*?)?(?:\n|$)/).replace(/bull/g, j).getRegex(); + var H = "address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul"; + var K = /|$))/; + var ze = d("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))", "i").replace("comment", K).replace("tag", H).replace("attribute", / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(); + var le = d(F).replace("hr", B).replace("heading", " {0,3}#{1,6}(?:\\s|$)").replace("|lheading", "").replace("|table", "").replace("blockquote", " {0,3}>").replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list", " {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]").replace("html", ")|<(?:script|pre|style|textarea|!--)").replace("tag", H).getRegex(); + var Me = d(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph", le).getRegex(); + var W = { blockquote: Me, code: we, def: Le, fences: ye, heading: Pe, hr: B, html: ze, lheading: ae, list: _e, newline: Oe, paragraph: le, table: _, text: $e }; + var se = d("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr", B).replace("heading", " {0,3}#{1,6}(?:\\s|$)").replace("blockquote", " {0,3}>").replace("code", "(?: {4}| {0,3} )[^\\n]").replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list", " {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html", ")|<(?:script|pre|style|textarea|!--)").replace("tag", H).getRegex(); + var Ee = { ...W, lheading: Se, table: se, paragraph: d(F).replace("hr", B).replace("heading", " {0,3}#{1,6}(?:\\s|$)").replace("|lheading", "").replace("table", se).replace("blockquote", " {0,3}>").replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list", " {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]").replace("html", ")|<(?:script|pre|style|textarea|!--)").replace("tag", H).getRegex() }; + var Ie = { ...W, html: d(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment", K).replace(/tag/g, "(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(), def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, heading: /^(#{1,6})(.*)(?:\n+|$)/, fences: _, lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, paragraph: d(F).replace("hr", B).replace("heading", ` *#{1,6} *[^ +]`).replace("lheading", ae).replace("|table", "").replace("blockquote", " {0,3}>").replace("|fences", "").replace("|list", "").replace("|html", "").replace("|tag", "").getRegex() }; + var Ae = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/; + var Ce = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/; + var ue = /^( {2,}|\\)\n(?!\s*$)/; + var Be = /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-", Te ? "(?`+)[^`]+\k(?!`)/).replace("html", /<(?! )[^<>]*?>/).getRegex(); + var ce = /^(?:\*+(?:((?!\*)punct)|([^\s*]))?)|^_+(?:((?!_)punct)|([^\s_]))?/; + var Ze = d(ce, "u").replace(/punct/g, I).getRegex(); + var Ge = d(ce, "u").replace(/punct/g, pe).getRegex(); + var he = "^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)"; + var Ne = d(he, "gu").replace(/notPunctSpace/g, X).replace(/punctSpace/g, Z).replace(/punct/g, I).getRegex(); + var Qe = d(he, "gu").replace(/notPunctSpace/g, ve).replace(/punctSpace/g, qe).replace(/punct/g, pe).getRegex(); + var je = d("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)", "gu").replace(/notPunctSpace/g, X).replace(/punctSpace/g, Z).replace(/punct/g, I).getRegex(); + var Fe = d(/^~~?(?:((?!~)punct)|[^\s~])/, "u").replace(/punct/g, I).getRegex(); + var Ue = "^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)"; + var Ke = d(Ue, "gu").replace(/notPunctSpace/g, X).replace(/punctSpace/g, Z).replace(/punct/g, I).getRegex(); + var We = d(/\\(punct)/, "gu").replace(/punct/g, I).getRegex(); + var Xe = d(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme", /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email", /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(); + var Je = d(K).replace("(?:-->|$)", "-->").getRegex(); + var Ve = d("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment", Je).replace("attribute", /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(); + var v = /(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+(?!`)[^`]*?`+(?!`)|``+(?=\])|[^\[\]\\`])*?/; + var Ye = d(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label", v).replace("href", /<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title", /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(); + var ke = d(/^!?\[(label)\]\[(ref)\]/).replace("label", v).replace("ref", U).getRegex(); + var de = d(/^!?\[(ref)\](?:\[\])?/).replace("ref", U).getRegex(); + var et = d("reflink|nolink(?!\\()", "g").replace("reflink", ke).replace("nolink", de).getRegex(); + var ie = /[hH][tT][tT][pP][sS]?|[fF][tT][pP]/; + var J = { _backpedal: _, anyPunctuation: We, autolink: Xe, blockSkip: He, br: ue, code: Ce, del: _, delLDelim: _, delRDelim: _, emStrongLDelim: Ze, emStrongRDelimAst: Ne, emStrongRDelimUnd: je, escape: Ae, link: Ye, nolink: de, punctuation: De, reflink: ke, reflinkSearch: et, tag: Ve, text: Be, url: _ }; + var tt = { ...J, link: d(/^!?\[(label)\]\((.*?)\)/).replace("label", v).getRegex(), reflink: d(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label", v).getRegex() }; + var Q = { ...J, emStrongRDelimAst: Qe, emStrongLDelim: Ge, delLDelim: Fe, delRDelim: Ke, url: d(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol", ie).replace("email", /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(), _backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/, del: /^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/, text: d(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\": ">", '"': """, "'": "'" }; + var ge = (l3) => rt[l3]; + function O(l3, e) { + if (e) { + if (m.escapeTest.test(l3)) return l3.replace(m.escapeReplace, ge); + } else if (m.escapeTestNoEncode.test(l3)) return l3.replace(m.escapeReplaceNoEncode, ge); + return l3; + } + function V(l3) { + try { + l3 = encodeURI(l3).replace(m.percentDecode, "%"); + } catch { + return null; + } + return l3; + } + function Y(l3, e) { + let t = l3.replace(m.findPipe, (r, i, o) => { + let u = false, a = i; + for (; --a >= 0 && o[a] === "\\"; ) u = !u; + return u ? "|" : " |"; + }), n = t.split(m.splitPipe), s = 0; + if (n[0].trim() || n.shift(), n.length > 0 && !n.at(-1)?.trim() && n.pop(), e) if (n.length > e) n.splice(e); + else for (; n.length < e; ) n.push(""); + for (; s < n.length; s++) n[s] = n[s].trim().replace(m.slashPipe, "|"); + return n; + } + function $(l3, e, t) { + let n = l3.length; + if (n === 0) return ""; + let s = 0; + for (; s < n; ) { + let r = l3.charAt(n - s - 1); + if (r === e && !t) s++; + else if (r !== e && t) s++; + else break; + } + return l3.slice(0, n - s); + } + function ee(l3) { + let e = l3.split(` +`), t = e.length - 1; + for (; t >= 0 && m.blankLine.test(e[t]); ) t--; + return e.length - t <= 2 ? l3 : e.slice(0, t + 1).join(` +`); + } + function fe(l3, e) { + if (l3.indexOf(e[1]) === -1) return -1; + let t = 0; + for (let n = 0; n < l3.length; n++) if (l3[n] === "\\") n++; + else if (l3[n] === e[0]) t++; + else if (l3[n] === e[1] && (t--, t < 0)) return n; + return t > 0 ? -2 : -1; + } + function me(l3, e = 0) { + let t = e, n = ""; + for (let s of l3) if (s === " ") { + let r = 4 - t % 4; + n += " ".repeat(r), t += r; + } else n += s, t++; + return n; + } + function xe(l3, e, t, n, s) { + let r = e.href, i = e.title || null, o = l3[1].replace(s.other.outputLinkReplace, "$1"); + n.state.inLink = true; + let u = { type: l3[0].charAt(0) === "!" ? "image" : "link", raw: t, href: r, title: i, text: o, tokens: n.inlineTokens(o) }; + return n.state.inLink = false, u; + } + function st(l3, e, t) { + let n = l3.match(t.other.indentCodeCompensation); + if (n === null) return e; + let s = n[1]; + return e.split(` +`).map((r) => { + let i = r.match(t.other.beginningSpace); + if (i === null) return r; + let [o] = i; + return o.length >= s.length ? r.slice(s.length) : r; + }).join(` +`); + } + var w = class { + constructor(e) { + __publicField(this, "options"); + __publicField(this, "rules"); + __publicField(this, "lexer"); + this.options = e || T; + } + space(e) { + let t = this.rules.block.newline.exec(e); + if (t && t[0].length > 0) return { type: "space", raw: t[0] }; + } + code(e) { + let t = this.rules.block.code.exec(e); + if (t) { + let n = this.options.pedantic ? t[0] : ee(t[0]), s = n.replace(this.rules.other.codeRemoveIndent, ""); + return { type: "code", raw: n, codeBlockStyle: "indented", text: s }; + } + } + fences(e) { + let t = this.rules.block.fences.exec(e); + if (t) { + let n = t[0], s = st(n, t[3] || "", this.rules); + return { type: "code", raw: n, lang: t[2] ? t[2].trim().replace(this.rules.inline.anyPunctuation, "$1") : t[2], text: s }; + } + } + heading(e) { + let t = this.rules.block.heading.exec(e); + if (t) { + let n = t[2].trim(); + if (this.rules.other.endingHash.test(n)) { + let s = $(n, "#"); + (this.options.pedantic || !s || this.rules.other.endingSpaceChar.test(s)) && (n = s.trim()); + } + return { type: "heading", raw: $(t[0], ` +`), depth: t[1].length, text: n, tokens: this.lexer.inline(n) }; + } + } + hr(e) { + let t = this.rules.block.hr.exec(e); + if (t) return { type: "hr", raw: $(t[0], ` +`) }; + } + blockquote(e) { + let t = this.rules.block.blockquote.exec(e); + if (t) { + let n = $(t[0], ` +`).split(` +`), s = "", r = "", i = []; + for (; n.length > 0; ) { + let o = false, u = [], a; + for (a = 0; a < n.length; a++) if (this.rules.other.blockquoteStart.test(n[a])) u.push(n[a]), o = true; + else if (!o) u.push(n[a]); + else break; + n = n.slice(a); + let c = u.join(` +`), p = c.replace(this.rules.other.blockquoteSetextReplace, ` + $1`).replace(this.rules.other.blockquoteSetextReplace2, ""); + s = s ? `${s} +${c}` : c, r = r ? `${r} +${p}` : p; + let k = this.lexer.state.top; + if (this.lexer.state.top = true, this.lexer.blockTokens(p, i, true), this.lexer.state.top = k, n.length === 0) break; + let h = i.at(-1); + if (h?.type === "code") break; + if (h?.type === "blockquote") { + let R = h, f = R.raw + ` +` + n.join(` +`), S = this.blockquote(f); + i[i.length - 1] = S, s = s.substring(0, s.length - R.raw.length) + S.raw, r = r.substring(0, r.length - R.text.length) + S.text; + break; + } else if (h?.type === "list") { + let R = h, f = R.raw + ` +` + n.join(` +`), S = this.list(f); + i[i.length - 1] = S, s = s.substring(0, s.length - h.raw.length) + S.raw, r = r.substring(0, r.length - R.raw.length) + S.raw, n = f.substring(i.at(-1).raw.length).split(` +`); + continue; + } + } + return { type: "blockquote", raw: s, tokens: i, text: r }; + } + } + list(e) { + let t = this.rules.block.list.exec(e); + if (t) { + let n = t[1].trim(), s = n.length > 1, r = { type: "list", raw: "", ordered: s, start: s ? +n.slice(0, -1) : "", loose: false, items: [] }; + n = s ? `\\d{1,9}\\${n.slice(-1)}` : `\\${n}`, this.options.pedantic && (n = s ? n : "[*+-]"); + let i = this.rules.other.listItemRegex(n), o = false; + for (; e; ) { + let a = false, c = "", p = ""; + if (!(t = i.exec(e)) || this.rules.block.hr.test(e)) break; + c = t[0], e = e.substring(c.length); + let k = me(t[2].split(` +`, 1)[0], t[1].length), h = e.split(` +`, 1)[0], R = !k.trim(), f = 0; + if (this.options.pedantic ? (f = 2, p = k.trimStart()) : R ? f = t[1].length + 1 : (f = k.search(this.rules.other.nonSpaceChar), f = f > 4 ? 1 : f, p = k.slice(f), f += t[1].length), R && this.rules.other.blankLine.test(h) && (c += h + ` +`, e = e.substring(h.length + 1), a = true), !a) { + let S = this.rules.other.nextBulletRegex(f), te = this.rules.other.hrRegex(f), ne = this.rules.other.fencesBeginRegex(f), re = this.rules.other.headingBeginRegex(f), be = this.rules.other.htmlBeginRegex(f), Re = this.rules.other.blockquoteBeginRegex(f); + for (; e; ) { + let G = e.split(` +`, 1)[0], C; + if (h = G, this.options.pedantic ? (h = h.replace(this.rules.other.listReplaceNesting, " "), C = h) : C = h.replace(this.rules.other.tabCharGlobal, " "), ne.test(h) || re.test(h) || be.test(h) || Re.test(h) || S.test(h) || te.test(h)) break; + if (C.search(this.rules.other.nonSpaceChar) >= f || !h.trim()) p += ` +` + C.slice(f); + else { + if (R || k.replace(this.rules.other.tabCharGlobal, " ").search(this.rules.other.nonSpaceChar) >= 4 || ne.test(k) || re.test(k) || te.test(k)) break; + p += ` +` + h; + } + R = !h.trim(), c += G + ` +`, e = e.substring(G.length + 1), k = C.slice(f); + } + } + r.loose || (o ? r.loose = true : this.rules.other.doubleBlankLine.test(c) && (o = true)), r.items.push({ type: "list_item", raw: c, task: !!this.options.gfm && this.rules.other.listIsTask.test(p), loose: false, text: p, tokens: [] }), r.raw += c; + } + let u = r.items.at(-1); + if (u) u.raw = u.raw.trimEnd(), u.text = u.text.trimEnd(); + else return; + r.raw = r.raw.trimEnd(); + for (let a of r.items) { + this.lexer.state.top = false, a.tokens = this.lexer.blockTokens(a.text, []); + let c = a.tokens[0]; + if (a.task && (c?.type === "text" || c?.type === "paragraph")) { + a.text = a.text.replace(this.rules.other.listReplaceTask, ""), c.raw = c.raw.replace(this.rules.other.listReplaceTask, ""), c.text = c.text.replace(this.rules.other.listReplaceTask, ""); + for (let k = this.lexer.inlineQueue.length - 1; k >= 0; k--) if (this.rules.other.listIsTask.test(this.lexer.inlineQueue[k].src)) { + this.lexer.inlineQueue[k].src = this.lexer.inlineQueue[k].src.replace(this.rules.other.listReplaceTask, ""); + break; + } + let p = this.rules.other.listTaskCheckbox.exec(a.raw); + if (p) { + let k = { type: "checkbox", raw: p[0] + " ", checked: p[0] !== "[ ]" }; + a.checked = k.checked, r.loose ? a.tokens[0] && ["paragraph", "text"].includes(a.tokens[0].type) && "tokens" in a.tokens[0] && a.tokens[0].tokens ? (a.tokens[0].raw = k.raw + a.tokens[0].raw, a.tokens[0].text = k.raw + a.tokens[0].text, a.tokens[0].tokens.unshift(k)) : a.tokens.unshift({ type: "paragraph", raw: k.raw, text: k.raw, tokens: [k] }) : a.tokens.unshift(k); + } + } else a.task && (a.task = false); + if (!r.loose) { + let p = a.tokens.filter((h) => h.type === "space"), k = p.length > 0 && p.some((h) => this.rules.other.anyLine.test(h.raw)); + r.loose = k; + } + } + if (r.loose) for (let a of r.items) { + a.loose = true; + for (let c of a.tokens) c.type === "text" && (c.type = "paragraph"); + } + return r; + } + } + html(e) { + let t = this.rules.block.html.exec(e); + if (t) { + let n = ee(t[0]); + return { type: "html", block: true, raw: n, pre: t[1] === "pre" || t[1] === "script" || t[1] === "style", text: n }; + } + } + def(e) { + let t = this.rules.block.def.exec(e); + if (t) { + let n = t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal, " "), s = t[2] ? t[2].replace(this.rules.other.hrefBrackets, "$1").replace(this.rules.inline.anyPunctuation, "$1") : "", r = t[3] ? t[3].substring(1, t[3].length - 1).replace(this.rules.inline.anyPunctuation, "$1") : t[3]; + return { type: "def", tag: n, raw: $(t[0], ` +`), href: s, title: r }; + } + } + table(e) { + let t = this.rules.block.table.exec(e); + if (!t || !this.rules.other.tableDelimiter.test(t[2])) return; + let n = Y(t[1]), s = t[2].replace(this.rules.other.tableAlignChars, "").split("|"), r = t[3]?.trim() ? t[3].replace(this.rules.other.tableRowBlankLine, "").split(` +`) : [], i = { type: "table", raw: $(t[0], ` +`), header: [], align: [], rows: [] }; + if (n.length === s.length) { + for (let o of s) this.rules.other.tableAlignRight.test(o) ? i.align.push("right") : this.rules.other.tableAlignCenter.test(o) ? i.align.push("center") : this.rules.other.tableAlignLeft.test(o) ? i.align.push("left") : i.align.push(null); + for (let o = 0; o < n.length; o++) i.header.push({ text: n[o], tokens: this.lexer.inline(n[o]), header: true, align: i.align[o] }); + for (let o of r) i.rows.push(Y(o, i.header.length).map((u, a) => ({ text: u, tokens: this.lexer.inline(u), header: false, align: i.align[a] }))); + return i; + } + } + lheading(e) { + let t = this.rules.block.lheading.exec(e); + if (t) { + let n = t[1].trim(); + return { type: "heading", raw: $(t[0], ` +`), depth: t[2].charAt(0) === "=" ? 1 : 2, text: n, tokens: this.lexer.inline(n) }; + } + } + paragraph(e) { + let t = this.rules.block.paragraph.exec(e); + if (t) { + let n = t[1].charAt(t[1].length - 1) === ` +` ? t[1].slice(0, -1) : t[1]; + return { type: "paragraph", raw: t[0], text: n, tokens: this.lexer.inline(n) }; + } + } + text(e) { + let t = this.rules.block.text.exec(e); + if (t) return { type: "text", raw: t[0], text: t[0], tokens: this.lexer.inline(t[0]) }; + } + escape(e) { + let t = this.rules.inline.escape.exec(e); + if (t) return { type: "escape", raw: t[0], text: t[1] }; + } + tag(e) { + let t = this.rules.inline.tag.exec(e); + if (t) return !this.lexer.state.inLink && this.rules.other.startATag.test(t[0]) ? this.lexer.state.inLink = true : this.lexer.state.inLink && this.rules.other.endATag.test(t[0]) && (this.lexer.state.inLink = false), !this.lexer.state.inRawBlock && this.rules.other.startPreScriptTag.test(t[0]) ? this.lexer.state.inRawBlock = true : this.lexer.state.inRawBlock && this.rules.other.endPreScriptTag.test(t[0]) && (this.lexer.state.inRawBlock = false), { type: "html", raw: t[0], inLink: this.lexer.state.inLink, inRawBlock: this.lexer.state.inRawBlock, block: false, text: t[0] }; + } + link(e) { + let t = this.rules.inline.link.exec(e); + if (t) { + let n = t[2].trim(); + if (!this.options.pedantic && this.rules.other.startAngleBracket.test(n)) { + if (!this.rules.other.endAngleBracket.test(n)) return; + let i = $(n.slice(0, -1), "\\"); + if ((n.length - i.length) % 2 === 0) return; + } else { + let i = fe(t[2], "()"); + if (i === -2) return; + if (i > -1) { + let u = (t[0].indexOf("!") === 0 ? 5 : 4) + t[1].length + i; + t[2] = t[2].substring(0, i), t[0] = t[0].substring(0, u).trim(), t[3] = ""; + } + } + let s = t[2], r = ""; + if (this.options.pedantic) { + let i = this.rules.other.pedanticHrefTitle.exec(s); + i && (s = i[1], r = i[3]); + } else r = t[3] ? t[3].slice(1, -1) : ""; + return s = s.trim(), this.rules.other.startAngleBracket.test(s) && (this.options.pedantic && !this.rules.other.endAngleBracket.test(n) ? s = s.slice(1) : s = s.slice(1, -1)), xe(t, { href: s && s.replace(this.rules.inline.anyPunctuation, "$1"), title: r && r.replace(this.rules.inline.anyPunctuation, "$1") }, t[0], this.lexer, this.rules); + } + } + reflink(e, t) { + let n; + if ((n = this.rules.inline.reflink.exec(e)) || (n = this.rules.inline.nolink.exec(e))) { + let s = (n[2] || n[1]).replace(this.rules.other.multipleSpaceGlobal, " "), r = t[s.toLowerCase()]; + if (!r) { + let i = n[0].charAt(0); + return { type: "text", raw: i, text: i }; + } + return xe(n, r, n[0], this.lexer, this.rules); + } + } + emStrong(e, t, n = "") { + let s = this.rules.inline.emStrongLDelim.exec(e); + if (!s || !s[1] && !s[2] && !s[3] && !s[4] || s[4] && n.match(this.rules.other.unicodeAlphaNumeric)) return; + if (!(s[1] || s[3] || "") || !n || this.rules.inline.punctuation.exec(n)) { + let i = [...s[0]].length - 1, o, u, a = i, c = 0, p = s[0][0] === "*" ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd; + for (p.lastIndex = 0, t = t.slice(-1 * e.length + i); (s = p.exec(t)) !== null; ) { + if (o = s[1] || s[2] || s[3] || s[4] || s[5] || s[6], !o) continue; + if (u = [...o].length, s[3] || s[4]) { + a += u; + continue; + } else if ((s[5] || s[6]) && i % 3 && !((i + u) % 3)) { + c += u; + continue; + } + if (a -= u, a > 0) continue; + u = Math.min(u, u + a + c); + let k = [...s[0]][0].length, h = e.slice(0, i + s.index + k + u); + if (Math.min(i, u) % 2) { + let f = h.slice(1, -1); + return { type: "em", raw: h, text: f, tokens: this.lexer.inlineTokens(f) }; + } + let R = h.slice(2, -2); + return { type: "strong", raw: h, text: R, tokens: this.lexer.inlineTokens(R) }; + } + } + } + codespan(e) { + let t = this.rules.inline.code.exec(e); + if (t) { + let n = t[2].replace(this.rules.other.newLineCharGlobal, " "), s = this.rules.other.nonSpaceChar.test(n), r = this.rules.other.startingSpaceChar.test(n) && this.rules.other.endingSpaceChar.test(n); + return s && r && (n = n.substring(1, n.length - 1)), { type: "codespan", raw: t[0], text: n }; + } + } + br(e) { + let t = this.rules.inline.br.exec(e); + if (t) return { type: "br", raw: t[0] }; + } + del(e, t, n = "") { + let s = this.rules.inline.delLDelim.exec(e); + if (!s) return; + if (!(s[1] || "") || !n || this.rules.inline.punctuation.exec(n)) { + let i = [...s[0]].length - 1, o, u, a = i, c = this.rules.inline.delRDelim; + for (c.lastIndex = 0, t = t.slice(-1 * e.length + i); (s = c.exec(t)) !== null; ) { + if (o = s[1] || s[2] || s[3] || s[4] || s[5] || s[6], !o || (u = [...o].length, u !== i)) continue; + if (s[3] || s[4]) { + a += u; + continue; + } + if (a -= u, a > 0) continue; + u = Math.min(u, u + a); + let p = [...s[0]][0].length, k = e.slice(0, i + s.index + p + u), h = k.slice(i, -i); + return { type: "del", raw: k, text: h, tokens: this.lexer.inlineTokens(h) }; + } + } + } + autolink(e) { + let t = this.rules.inline.autolink.exec(e); + if (t) { + let n, s; + return t[2] === "@" ? (n = t[1], s = "mailto:" + n) : (n = t[1], s = n), { type: "link", raw: t[0], text: n, href: s, tokens: [{ type: "text", raw: n, text: n }] }; + } + } + url(e) { + let t; + if (t = this.rules.inline.url.exec(e)) { + let n, s; + if (t[2] === "@") n = t[0], s = "mailto:" + n; + else { + let r; + do + r = t[0], t[0] = this.rules.inline._backpedal.exec(t[0])?.[0] ?? ""; + while (r !== t[0]); + n = t[0], t[1] === "www." ? s = "http://" + t[0] : s = t[0]; + } + return { type: "link", raw: t[0], text: n, href: s, tokens: [{ type: "text", raw: n, text: n }] }; + } + } + inlineText(e) { + let t = this.rules.inline.text.exec(e); + if (t) { + let n = this.lexer.state.inRawBlock; + return { type: "text", raw: t[0], text: t[0], escaped: n }; + } + } + }; + var x = class l { + constructor(e) { + __publicField(this, "tokens"); + __publicField(this, "options"); + __publicField(this, "state"); + __publicField(this, "inlineQueue"); + __publicField(this, "tokenizer"); + this.tokens = [], this.tokens.links = /* @__PURE__ */ Object.create(null), this.options = e || T, this.options.tokenizer = this.options.tokenizer || new w(), this.tokenizer = this.options.tokenizer, this.tokenizer.options = this.options, this.tokenizer.lexer = this, this.inlineQueue = [], this.state = { inLink: false, inRawBlock: false, top: true }; + let t = { other: m, block: D.normal, inline: A.normal }; + this.options.pedantic ? (t.block = D.pedantic, t.inline = A.pedantic) : this.options.gfm && (t.block = D.gfm, this.options.breaks ? t.inline = A.breaks : t.inline = A.gfm), this.tokenizer.rules = t; + } + static get rules() { + return { block: D, inline: A }; + } + static lex(e, t) { + return new l(t).lex(e); + } + static lexInline(e, t) { + return new l(t).inlineTokens(e); + } + lex(e) { + e = e.replace(m.carriageReturn, ` +`), this.blockTokens(e, this.tokens); + for (let t = 0; t < this.inlineQueue.length; t++) { + let n = this.inlineQueue[t]; + this.inlineTokens(n.src, n.tokens); + } + return this.inlineQueue = [], this.tokens; + } + blockTokens(e, t = [], n = false) { + this.tokenizer.lexer = this, this.options.pedantic && (e = e.replace(m.tabCharGlobal, " ").replace(m.spaceLine, "")); + let s = 1 / 0; + for (; e; ) { + if (e.length < s) s = e.length; + else { + this.infiniteLoopError(e.charCodeAt(0)); + break; + } + let r; + if (this.options.extensions?.block?.some((o) => (r = o.call({ lexer: this }, e, t)) ? (e = e.substring(r.raw.length), t.push(r), true) : false)) continue; + if (r = this.tokenizer.space(e)) { + e = e.substring(r.raw.length); + let o = t.at(-1); + r.raw.length === 1 && o !== void 0 ? o.raw += ` +` : t.push(r); + continue; + } + if (r = this.tokenizer.code(e)) { + e = e.substring(r.raw.length); + let o = t.at(-1); + o?.type === "paragraph" || o?.type === "text" ? (o.raw += (o.raw.endsWith(` +`) ? "" : ` +`) + r.raw, o.text += ` +` + r.text, this.inlineQueue.at(-1).src = o.text) : t.push(r); + continue; + } + if (r = this.tokenizer.fences(e)) { + e = e.substring(r.raw.length), t.push(r); + continue; + } + if (r = this.tokenizer.heading(e)) { + e = e.substring(r.raw.length), t.push(r); + continue; + } + if (r = this.tokenizer.hr(e)) { + e = e.substring(r.raw.length), t.push(r); + continue; + } + if (r = this.tokenizer.blockquote(e)) { + e = e.substring(r.raw.length), t.push(r); + continue; + } + if (r = this.tokenizer.list(e)) { + e = e.substring(r.raw.length), t.push(r); + continue; + } + if (r = this.tokenizer.html(e)) { + e = e.substring(r.raw.length), t.push(r); + continue; + } + if (r = this.tokenizer.def(e)) { + e = e.substring(r.raw.length); + let o = t.at(-1); + o?.type === "paragraph" || o?.type === "text" ? (o.raw += (o.raw.endsWith(` +`) ? "" : ` +`) + r.raw, o.text += ` +` + r.raw, this.inlineQueue.at(-1).src = o.text) : this.tokens.links[r.tag] || (this.tokens.links[r.tag] = { href: r.href, title: r.title }, t.push(r)); + continue; + } + if (r = this.tokenizer.table(e)) { + e = e.substring(r.raw.length), t.push(r); + continue; + } + if (r = this.tokenizer.lheading(e)) { + e = e.substring(r.raw.length), t.push(r); + continue; + } + let i = e; + if (this.options.extensions?.startBlock) { + let o = 1 / 0, u = e.slice(1), a; + this.options.extensions.startBlock.forEach((c) => { + a = c.call({ lexer: this }, u), typeof a == "number" && a >= 0 && (o = Math.min(o, a)); + }), o < 1 / 0 && o >= 0 && (i = e.substring(0, o + 1)); + } + if (this.state.top && (r = this.tokenizer.paragraph(i))) { + let o = t.at(-1); + n && o?.type === "paragraph" ? (o.raw += (o.raw.endsWith(` +`) ? "" : ` +`) + r.raw, o.text += ` +` + r.text, this.inlineQueue.pop(), this.inlineQueue.at(-1).src = o.text) : t.push(r), n = i.length !== e.length, e = e.substring(r.raw.length); + continue; + } + if (r = this.tokenizer.text(e)) { + e = e.substring(r.raw.length); + let o = t.at(-1); + o?.type === "text" ? (o.raw += (o.raw.endsWith(` +`) ? "" : ` +`) + r.raw, o.text += ` +` + r.text, this.inlineQueue.pop(), this.inlineQueue.at(-1).src = o.text) : t.push(r); + continue; + } + if (e) { + this.infiniteLoopError(e.charCodeAt(0)); + break; + } + } + return this.state.top = true, t; + } + inline(e, t = []) { + return this.inlineQueue.push({ src: e, tokens: t }), t; + } + inlineTokens(e, t = []) { + this.tokenizer.lexer = this; + let n = e, s = null; + if (this.tokens.links) { + let a = Object.keys(this.tokens.links); + if (a.length > 0) for (; (s = this.tokenizer.rules.inline.reflinkSearch.exec(n)) !== null; ) a.includes(s[0].slice(s[0].lastIndexOf("[") + 1, -1)) && (n = n.slice(0, s.index) + "[" + "a".repeat(s[0].length - 2) + "]" + n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex)); + } + for (; (s = this.tokenizer.rules.inline.anyPunctuation.exec(n)) !== null; ) n = n.slice(0, s.index) + "++" + n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex); + let r; + for (; (s = this.tokenizer.rules.inline.blockSkip.exec(n)) !== null; ) r = s[2] ? s[2].length : 0, n = n.slice(0, s.index + r) + "[" + "a".repeat(s[0].length - r - 2) + "]" + n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); + n = this.options.hooks?.emStrongMask?.call({ lexer: this }, n) ?? n; + let i = false, o = "", u = 1 / 0; + for (; e; ) { + if (e.length < u) u = e.length; + else { + this.infiniteLoopError(e.charCodeAt(0)); + break; + } + i || (o = ""), i = false; + let a; + if (this.options.extensions?.inline?.some((p) => (a = p.call({ lexer: this }, e, t)) ? (e = e.substring(a.raw.length), t.push(a), true) : false)) continue; + if (a = this.tokenizer.escape(e)) { + e = e.substring(a.raw.length), t.push(a); + continue; + } + if (a = this.tokenizer.tag(e)) { + e = e.substring(a.raw.length), t.push(a); + continue; + } + if (a = this.tokenizer.link(e)) { + e = e.substring(a.raw.length), t.push(a); + continue; + } + if (a = this.tokenizer.reflink(e, this.tokens.links)) { + e = e.substring(a.raw.length); + let p = t.at(-1); + a.type === "text" && p?.type === "text" ? (p.raw += a.raw, p.text += a.text) : t.push(a); + continue; + } + if (a = this.tokenizer.emStrong(e, n, o)) { + e = e.substring(a.raw.length), t.push(a); + continue; + } + if (a = this.tokenizer.codespan(e)) { + e = e.substring(a.raw.length), t.push(a); + continue; + } + if (a = this.tokenizer.br(e)) { + e = e.substring(a.raw.length), t.push(a); + continue; + } + if (a = this.tokenizer.del(e, n, o)) { + e = e.substring(a.raw.length), t.push(a); + continue; + } + if (a = this.tokenizer.autolink(e)) { + e = e.substring(a.raw.length), t.push(a); + continue; + } + if (!this.state.inLink && (a = this.tokenizer.url(e))) { + e = e.substring(a.raw.length), t.push(a); + continue; + } + let c = e; + if (this.options.extensions?.startInline) { + let p = 1 / 0, k = e.slice(1), h; + this.options.extensions.startInline.forEach((R) => { + h = R.call({ lexer: this }, k), typeof h == "number" && h >= 0 && (p = Math.min(p, h)); + }), p < 1 / 0 && p >= 0 && (c = e.substring(0, p + 1)); + } + if (a = this.tokenizer.inlineText(c)) { + e = e.substring(a.raw.length), a.raw.slice(-1) !== "_" && (o = a.raw.slice(-1)), i = true; + let p = t.at(-1); + p?.type === "text" ? (p.raw += a.raw, p.text += a.text) : t.push(a); + continue; + } + if (e) { + this.infiniteLoopError(e.charCodeAt(0)); + break; + } + } + return t; + } + infiniteLoopError(e) { + let t = "Infinite loop on byte: " + e; + if (this.options.silent) console.error(t); + else throw new Error(t); + } + }; + var y = class { + constructor(e) { + __publicField(this, "options"); + __publicField(this, "parser"); + this.options = e || T; + } + space(e) { + return ""; + } + code({ text: e, lang: t, escaped: n }) { + let s = (t || "").match(m.notSpaceStart)?.[0], r = e.replace(m.endingNewline, "") + ` +`; + return s ? '

' + (n ? r : O(r, true)) + `
+` : "
" + (n ? r : O(r, true)) + `
+`; + } + blockquote({ tokens: e }) { + return `
+${this.parser.parse(e)}
+`; + } + html({ text: e }) { + return e; + } + def(e) { + return ""; + } + heading({ tokens: e, depth: t }) { + return `${this.parser.parseInline(e)} +`; + } + hr(e) { + return `
+`; + } + list(e) { + let t = e.ordered, n = e.start, s = ""; + for (let o = 0; o < e.items.length; o++) { + let u = e.items[o]; + s += this.listitem(u); + } + let r = t ? "ol" : "ul", i = t && n !== 1 ? ' start="' + n + '"' : ""; + return "<" + r + i + `> +` + s + " +`; + } + listitem(e) { + return `
  • ${this.parser.parse(e.tokens)}
  • +`; + } + checkbox({ checked: e }) { + return " '; + } + paragraph({ tokens: e }) { + return `

    ${this.parser.parseInline(e)}

    +`; + } + table(e) { + let t = "", n = ""; + for (let r = 0; r < e.header.length; r++) n += this.tablecell(e.header[r]); + t += this.tablerow({ text: n }); + let s = ""; + for (let r = 0; r < e.rows.length; r++) { + let i = e.rows[r]; + n = ""; + for (let o = 0; o < i.length; o++) n += this.tablecell(i[o]); + s += this.tablerow({ text: n }); + } + return s && (s = `${s}`), ` + +` + t + ` +` + s + `
    +`; + } + tablerow({ text: e }) { + return ` +${e} +`; + } + tablecell(e) { + let t = this.parser.parseInline(e.tokens), n = e.header ? "th" : "td"; + return (e.align ? `<${n} align="${e.align}">` : `<${n}>`) + t + ` +`; + } + strong({ tokens: e }) { + return `${this.parser.parseInline(e)}`; + } + em({ tokens: e }) { + return `${this.parser.parseInline(e)}`; + } + codespan({ text: e }) { + return `${O(e, true)}`; + } + br(e) { + return "
    "; + } + del({ tokens: e }) { + return `${this.parser.parseInline(e)}`; + } + link({ href: e, title: t, tokens: n }) { + let s = this.parser.parseInline(n), r = V(e); + if (r === null) return s; + e = r; + let i = '
    ", i; + } + image({ href: e, title: t, text: n, tokens: s }) { + s && (n = this.parser.parseInline(s, this.parser.textRenderer)); + let r = V(e); + if (r === null) return O(n); + e = r; + let i = `${O(n)} { + let o = r[i].flat(1 / 0); + n = n.concat(this.walkTokens(o, t)); + }) : r.tokens && (n = n.concat(this.walkTokens(r.tokens, t))); + } + } + return n; + } + use(...e) { + let t = this.defaults.extensions || { renderers: {}, childTokens: {} }; + return e.forEach((n) => { + let s = { ...n }; + if (s.async = this.defaults.async || s.async || false, n.extensions && (n.extensions.forEach((r) => { + if (!r.name) throw new Error("extension name required"); + if ("renderer" in r) { + let i = t.renderers[r.name]; + i ? t.renderers[r.name] = function(...o) { + let u = r.renderer.apply(this, o); + return u === false && (u = i.apply(this, o)), u; + } : t.renderers[r.name] = r.renderer; + } + if ("tokenizer" in r) { + if (!r.level || r.level !== "block" && r.level !== "inline") throw new Error("extension level must be 'block' or 'inline'"); + let i = t[r.level]; + i ? i.unshift(r.tokenizer) : t[r.level] = [r.tokenizer], r.start && (r.level === "block" ? t.startBlock ? t.startBlock.push(r.start) : t.startBlock = [r.start] : r.level === "inline" && (t.startInline ? t.startInline.push(r.start) : t.startInline = [r.start])); + } + "childTokens" in r && r.childTokens && (t.childTokens[r.name] = r.childTokens); + }), s.extensions = t), n.renderer) { + let r = this.defaults.renderer || new y(this.defaults); + for (let i in n.renderer) { + if (!(i in r)) throw new Error(`renderer '${i}' does not exist`); + if (["options", "parser"].includes(i)) continue; + let o = i, u = n.renderer[o], a = r[o]; + r[o] = (...c) => { + let p = u.apply(r, c); + return p === false && (p = a.apply(r, c)), p || ""; + }; + } + s.renderer = r; + } + if (n.tokenizer) { + let r = this.defaults.tokenizer || new w(this.defaults); + for (let i in n.tokenizer) { + if (!(i in r)) throw new Error(`tokenizer '${i}' does not exist`); + if (["options", "rules", "lexer"].includes(i)) continue; + let o = i, u = n.tokenizer[o], a = r[o]; + r[o] = (...c) => { + let p = u.apply(r, c); + return p === false && (p = a.apply(r, c)), p; + }; + } + s.tokenizer = r; + } + if (n.hooks) { + let r = this.defaults.hooks || new P(); + for (let i in n.hooks) { + if (!(i in r)) throw new Error(`hook '${i}' does not exist`); + if (["options", "block"].includes(i)) continue; + let o = i, u = n.hooks[o], a = r[o]; + P.passThroughHooks.has(i) ? r[o] = (c) => { + if (this.defaults.async && P.passThroughHooksRespectAsync.has(i)) return (async () => { + let k = await u.call(r, c); + return a.call(r, k); + })(); + let p = u.call(r, c); + return a.call(r, p); + } : r[o] = (...c) => { + if (this.defaults.async) return (async () => { + let k = await u.apply(r, c); + return k === false && (k = await a.apply(r, c)), k; + })(); + let p = u.apply(r, c); + return p === false && (p = a.apply(r, c)), p; + }; + } + s.hooks = r; + } + if (n.walkTokens) { + let r = this.defaults.walkTokens, i = n.walkTokens; + s.walkTokens = function(o) { + let u = []; + return u.push(i.call(this, o)), r && (u = u.concat(r.call(this, o))), u; + }; + } + this.defaults = { ...this.defaults, ...s }; + }), this; + } + setOptions(e) { + return this.defaults = { ...this.defaults, ...e }, this; + } + lexer(e, t) { + return x.lex(e, t ?? this.defaults); + } + parser(e, t) { + return b.parse(e, t ?? this.defaults); + } + parseMarkdown(e) { + return (n, s) => { + let r = { ...s }, i = { ...this.defaults, ...r }, o = this.onError(!!i.silent, !!i.async); + if (this.defaults.async === true && r.async === false) return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.")); + if (typeof n > "u" || n === null) return o(new Error("marked(): input parameter is undefined or null")); + if (typeof n != "string") return o(new Error("marked(): input parameter is of type " + Object.prototype.toString.call(n) + ", string expected")); + if (i.hooks && (i.hooks.options = i, i.hooks.block = e), i.async) return (async () => { + let u = i.hooks ? await i.hooks.preprocess(n) : n, c = await (i.hooks ? await i.hooks.provideLexer(e) : e ? x.lex : x.lexInline)(u, i), p = i.hooks ? await i.hooks.processAllTokens(c) : c; + i.walkTokens && await Promise.all(this.walkTokens(p, i.walkTokens)); + let h = await (i.hooks ? await i.hooks.provideParser(e) : e ? b.parse : b.parseInline)(p, i); + return i.hooks ? await i.hooks.postprocess(h) : h; + })().catch(o); + try { + i.hooks && (n = i.hooks.preprocess(n)); + let a = (i.hooks ? i.hooks.provideLexer(e) : e ? x.lex : x.lexInline)(n, i); + i.hooks && (a = i.hooks.processAllTokens(a)), i.walkTokens && this.walkTokens(a, i.walkTokens); + let p = (i.hooks ? i.hooks.provideParser(e) : e ? b.parse : b.parseInline)(a, i); + return i.hooks && (p = i.hooks.postprocess(p)), p; + } catch (u) { + return o(u); + } + }; + } + onError(e, t) { + return (n) => { + if (n.message += ` +Please report this to https://github.com/markedjs/marked.`, e) { + let s = "

    An error occurred:

    " + O(n.message + "", true) + "
    "; + return t ? Promise.resolve(s) : s; + } + if (t) return Promise.reject(n); + throw n; + }; + } + }; + var z = new q(); + function g(l3, e) { + return z.parse(l3, e); + } + g.options = g.setOptions = function(l3) { + return z.setOptions(l3), g.defaults = z.defaults, N(g.defaults), g; + }; + g.getDefaults = M; + g.defaults = T; + g.use = function(...l3) { + return z.use(...l3), g.defaults = z.defaults, N(g.defaults), g; + }; + g.walkTokens = function(l3, e) { + return z.walkTokens(l3, e); + }; + g.parseInline = z.parseInline; + g.Parser = b; + g.parser = b.parse; + g.Renderer = y; + g.TextRenderer = L; + g.Lexer = x; + g.lexer = x.lex; + g.Tokenizer = w; + g.Hooks = P; + g.parse = g; + var Ft = g.options; + var Ut = g.setOptions; + var Kt = g.use; + var Wt = g.walkTokens; + var Xt = g.parseInline; + var Vt = b.parse; + var Yt = x.lex; + + // node_modules/get-east-asian-width/lookup-data.js + var ambiguousMinimalCodePoint = 161; + var ambiguousMaximumCodePoint = 1114109; + var ambiguousRanges = [161, 161, 164, 164, 167, 168, 170, 170, 173, 174, 176, 180, 182, 186, 188, 191, 198, 198, 208, 208, 215, 216, 222, 225, 230, 230, 232, 234, 236, 237, 240, 240, 242, 243, 247, 250, 252, 252, 254, 254, 257, 257, 273, 273, 275, 275, 283, 283, 294, 295, 299, 299, 305, 307, 312, 312, 319, 322, 324, 324, 328, 331, 333, 333, 338, 339, 358, 359, 363, 363, 462, 462, 464, 464, 466, 466, 468, 468, 470, 470, 472, 472, 474, 474, 476, 476, 593, 593, 609, 609, 708, 708, 711, 711, 713, 715, 717, 717, 720, 720, 728, 731, 733, 733, 735, 735, 768, 879, 913, 929, 931, 937, 945, 961, 963, 969, 1025, 1025, 1040, 1103, 1105, 1105, 8208, 8208, 8211, 8214, 8216, 8217, 8220, 8221, 8224, 8226, 8228, 8231, 8240, 8240, 8242, 8243, 8245, 8245, 8251, 8251, 8254, 8254, 8308, 8308, 8319, 8319, 8321, 8324, 8364, 8364, 8451, 8451, 8453, 8453, 8457, 8457, 8467, 8467, 8470, 8470, 8481, 8482, 8486, 8486, 8491, 8491, 8531, 8532, 8539, 8542, 8544, 8555, 8560, 8569, 8585, 8585, 8592, 8601, 8632, 8633, 8658, 8658, 8660, 8660, 8679, 8679, 8704, 8704, 8706, 8707, 8711, 8712, 8715, 8715, 8719, 8719, 8721, 8721, 8725, 8725, 8730, 8730, 8733, 8736, 8739, 8739, 8741, 8741, 8743, 8748, 8750, 8750, 8756, 8759, 8764, 8765, 8776, 8776, 8780, 8780, 8786, 8786, 8800, 8801, 8804, 8807, 8810, 8811, 8814, 8815, 8834, 8835, 8838, 8839, 8853, 8853, 8857, 8857, 8869, 8869, 8895, 8895, 8978, 8978, 9312, 9449, 9451, 9547, 9552, 9587, 9600, 9615, 9618, 9621, 9632, 9633, 9635, 9641, 9650, 9651, 9654, 9655, 9660, 9661, 9664, 9665, 9670, 9672, 9675, 9675, 9678, 9681, 9698, 9701, 9711, 9711, 9733, 9734, 9737, 9737, 9742, 9743, 9756, 9756, 9758, 9758, 9792, 9792, 9794, 9794, 9824, 9825, 9827, 9829, 9831, 9834, 9836, 9837, 9839, 9839, 9886, 9887, 9919, 9919, 9926, 9933, 9935, 9939, 9941, 9953, 9955, 9955, 9960, 9961, 9963, 9969, 9972, 9972, 9974, 9977, 9979, 9980, 9982, 9983, 10045, 10045, 10102, 10111, 11094, 11097, 12872, 12879, 57344, 63743, 65024, 65039, 65533, 65533, 127232, 127242, 127248, 127277, 127280, 127337, 127344, 127373, 127375, 127376, 127387, 127404, 917760, 917999, 983040, 1048573, 1048576, 1114109]; + var fullwidthMinimalCodePoint = 12288; + var fullwidthMaximumCodePoint = 65510; + var fullwidthRanges = [12288, 12288, 65281, 65376, 65504, 65510]; + var wideMinimalCodePoint = 4352; + var wideMaximumCodePoint = 262141; + var wideRanges = [4352, 4447, 8986, 8987, 9001, 9002, 9193, 9196, 9200, 9200, 9203, 9203, 9725, 9726, 9748, 9749, 9776, 9783, 9800, 9811, 9855, 9855, 9866, 9871, 9875, 9875, 9889, 9889, 9898, 9899, 9917, 9918, 9924, 9925, 9934, 9934, 9940, 9940, 9962, 9962, 9970, 9971, 9973, 9973, 9978, 9978, 9981, 9981, 9989, 9989, 9994, 9995, 10024, 10024, 10060, 10060, 10062, 10062, 10067, 10069, 10071, 10071, 10133, 10135, 10160, 10160, 10175, 10175, 11035, 11036, 11088, 11088, 11093, 11093, 11904, 11929, 11931, 12019, 12032, 12245, 12272, 12287, 12289, 12350, 12353, 12438, 12441, 12543, 12549, 12591, 12593, 12686, 12688, 12773, 12783, 12830, 12832, 12871, 12880, 42124, 42128, 42182, 43360, 43388, 44032, 55203, 63744, 64255, 65040, 65049, 65072, 65106, 65108, 65126, 65128, 65131, 94176, 94180, 94192, 94198, 94208, 101589, 101631, 101662, 101760, 101874, 110576, 110579, 110581, 110587, 110589, 110590, 110592, 110882, 110898, 110898, 110928, 110930, 110933, 110933, 110948, 110951, 110960, 111355, 119552, 119638, 119648, 119670, 126980, 126980, 127183, 127183, 127374, 127374, 127377, 127386, 127488, 127490, 127504, 127547, 127552, 127560, 127568, 127569, 127584, 127589, 127744, 127776, 127789, 127797, 127799, 127868, 127870, 127891, 127904, 127946, 127951, 127955, 127968, 127984, 127988, 127988, 127992, 128062, 128064, 128064, 128066, 128252, 128255, 128317, 128331, 128334, 128336, 128359, 128378, 128378, 128405, 128406, 128420, 128420, 128507, 128591, 128640, 128709, 128716, 128716, 128720, 128722, 128725, 128728, 128732, 128735, 128747, 128748, 128756, 128764, 128992, 129003, 129008, 129008, 129292, 129338, 129340, 129349, 129351, 129535, 129648, 129660, 129664, 129674, 129678, 129734, 129736, 129736, 129741, 129756, 129759, 129770, 129775, 129784, 131072, 196605, 196608, 262141]; + + // node_modules/get-east-asian-width/utilities.js + var isInRange = (ranges, codePoint) => { + let low = 0; + let high = Math.floor(ranges.length / 2) - 1; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const i = mid * 2; + if (codePoint < ranges[i]) { + high = mid - 1; + } else if (codePoint > ranges[i + 1]) { + low = mid + 1; + } else { + return true; + } + } + return false; + }; + + // node_modules/get-east-asian-width/lookup.js + var commonCjkCodePoint = 19968; + var [wideFastPathStart, wideFastPathEnd] = /* @__PURE__ */ findWideFastPathRange(wideRanges); + function findWideFastPathRange(ranges) { + let fastPathStart = ranges[0]; + let fastPathEnd = ranges[1]; + for (let index = 0; index < ranges.length; index += 2) { + const start = ranges[index]; + const end = ranges[index + 1]; + if (commonCjkCodePoint >= start && commonCjkCodePoint <= end) { + return [start, end]; + } + if (end - start > fastPathEnd - fastPathStart) { + fastPathStart = start; + fastPathEnd = end; + } + } + return [fastPathStart, fastPathEnd]; + } + var isAmbiguous = (codePoint) => { + if (codePoint < ambiguousMinimalCodePoint || codePoint > ambiguousMaximumCodePoint) { + return false; + } + return isInRange(ambiguousRanges, codePoint); + }; + var isFullWidth = (codePoint) => { + if (codePoint < fullwidthMinimalCodePoint || codePoint > fullwidthMaximumCodePoint) { + return false; + } + return isInRange(fullwidthRanges, codePoint); + }; + var isWide = (codePoint) => { + if (codePoint >= wideFastPathStart && codePoint <= wideFastPathEnd) { + return true; + } + if (codePoint < wideMinimalCodePoint || codePoint > wideMaximumCodePoint) { + return false; + } + return isInRange(wideRanges, codePoint); + }; + + // node_modules/get-east-asian-width/index.js + function validate(codePoint) { + if (!Number.isSafeInteger(codePoint)) { + throw new TypeError(`Expected a code point, got \`${typeof codePoint}\`.`); + } + } + function eastAsianWidth(codePoint, { ambiguousAsWide = false } = {}) { + validate(codePoint); + if (isFullWidth(codePoint) || isWide(codePoint) || ambiguousAsWide && isAmbiguous(codePoint)) { + return 2; + } + return 1; + } + + // packages/tui/src/utils.ts + var graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" }); + var wordSegmenter = new Intl.Segmenter(void 0, { granularity: "word" }); + function couldBeEmoji(segment) { + const cp = segment.codePointAt(0); + return cp >= 126976 && cp <= 130047 || // Emoji and Pictograph + cp >= 8960 && cp <= 9215 || // Misc technical + cp >= 9728 && cp <= 10175 || // Misc symbols, dingbats + cp >= 11088 && cp <= 11093 || // Specific stars/circles + segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector) + segment.length > 2; + } + var zeroWidthRegex = new RegExp("^(?:\\p{Default_Ignorable_Code_Point}|\\p{Control}|\\p{Mark}|\\p{Surrogate})+$", "v"); + var leadingNonPrintingRegex = new RegExp("^[\\p{Default_Ignorable_Code_Point}\\p{Control}\\p{Format}\\p{Mark}\\p{Surrogate}]+", "v"); + var nonPrintingCharRegex = new RegExp("^(?:\\p{Default_Ignorable_Code_Point}|\\p{Control}|\\p{Format}|\\p{Mark}|\\p{Surrogate})$", "v"); + var markCharRegex = new RegExp("^\\p{Mark}$", "v"); + var terminalSpacingMarkRegex = new RegExp("^(?:[\\p{Spacing_Mark}--[\\u1734\\u302E\\u302F]]|[\\u065F\\u0F7F\\u102B\\u102C\\u1031\\u1033-\\u1035\\u1038\\u103A-\\u103E])+$", "v"); + var rgiEmojiRegex = new RegExp("^\\p{RGI_Emoji}$", "v"); + var WIDTH_CACHE_SIZE = 512; + var widthCache = /* @__PURE__ */ new Map(); + var cjkBreakRegex = /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u; + function isPrintableAscii(str) { + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code < 32 || code > 126) { + return false; + } + } + return true; + } + function graphemeWidth(segment) { + if (segment === " ") { + return 3; + } + if (terminalSpacingMarkRegex.test(segment)) { + return [...segment].length; + } + if (zeroWidthRegex.test(segment)) { + return 0; + } + if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) { + return 2; + } + const base = segment.replace(leadingNonPrintingRegex, ""); + const cp = base.codePointAt(0); + if (cp === void 0) { + return 0; + } + if (cp >= 127462 && cp <= 127487) { + return 2; + } + let width = eastAsianWidth(cp); + let followsMark = false; + const chars = [...base]; + for (const char of chars.slice(1)) { + if (terminalSpacingMarkRegex.test(char)) { + width += 1; + followsMark = false; + } else if (markCharRegex.test(char)) { + followsMark = true; + } else if (!nonPrintingCharRegex.test(char)) { + const c = char.codePointAt(0); + if (followsMark || c >= 65280 && c <= 65519) { + width += eastAsianWidth(c); + } else if (c === 3635 || c === 3763) { + width += 1; + } + followsMark = false; + } + } + return width; + } + function visibleWidth(str) { + if (str.length === 0) { + return 0; + } + if (isPrintableAscii(str)) { + return str.length; + } + const cached = widthCache.get(str); + if (cached !== void 0) { + return cached; + } + let clean = str; + if (str.includes(" ")) { + clean = clean.replace(/\t/g, " "); + } + if (clean.includes("\x1B")) { + let stripped = ""; + let i = 0; + while (i < clean.length) { + const ansi = extractAnsiCode(clean, i); + if (ansi) { + i += ansi.length; + continue; + } + stripped += clean[i]; + i++; + } + clean = stripped; + } + let width = 0; + for (const { segment } of graphemeSegmenter.segment(clean)) { + width += graphemeWidth(segment); + } + if (widthCache.size >= WIDTH_CACHE_SIZE) { + const firstKey = widthCache.keys().next().value; + if (firstKey !== void 0) { + widthCache.delete(firstKey); + } + } + widthCache.set(str, width); + return width; + } + function extractAnsiCode(str, pos) { + if (pos >= str.length || str[pos] !== "\x1B") return null; + const next = str[pos + 1]; + if (next === "[") { + let j2 = pos + 2; + while (j2 < str.length && !/[mGKHJ]/.test(str[j2])) j2++; + if (j2 < str.length) return { code: str.substring(pos, j2 + 1), length: j2 + 1 - pos }; + return null; + } + if (next === "]") { + let j2 = pos + 2; + while (j2 < str.length) { + if (str[j2] === "\x07") return { code: str.substring(pos, j2 + 1), length: j2 + 1 - pos }; + if (str[j2] === "\x1B" && str[j2 + 1] === "\\") return { code: str.substring(pos, j2 + 2), length: j2 + 2 - pos }; + j2++; + } + return null; + } + if (next === "_") { + let j2 = pos + 2; + while (j2 < str.length) { + if (str[j2] === "\x07") return { code: str.substring(pos, j2 + 1), length: j2 + 1 - pos }; + if (str[j2] === "\x1B" && str[j2 + 1] === "\\") return { code: str.substring(pos, j2 + 2), length: j2 + 2 - pos }; + j2++; + } + return null; + } + return null; + } + function parseOsc8Hyperlink(ansiCode) { + if (!ansiCode.startsWith("\x1B]8;")) { + return void 0; + } + const terminator = ansiCode.endsWith("\x07") ? "\x07" : "\x1B\\"; + const body = ansiCode.slice(4, terminator === "\x07" ? -1 : -2); + const separatorIndex = body.indexOf(";"); + if (separatorIndex === -1) { + return void 0; + } + const params = body.slice(0, separatorIndex); + const url = body.slice(separatorIndex + 1); + if (!url) { + return null; + } + return { params, url, terminator }; + } + function formatOsc8Hyperlink(hyperlink2) { + return `\x1B]8;${hyperlink2.params};${hyperlink2.url}${hyperlink2.terminator}`; + } + function formatOsc8Close(terminator) { + return `\x1B]8;;${terminator}`; + } + var AnsiCodeTracker = class { + constructor() { + // Track individual attributes separately so we can reset them specifically + this.bold = false; + this.dim = false; + this.italic = false; + this.underline = false; + this.blink = false; + this.inverse = false; + this.hidden = false; + this.strikethrough = false; + this.fgColor = null; + // Stores the full code like "31" or "38;5;240" + this.bgColor = null; + // Stores the full code like "41" or "48;5;240" + this.activeHyperlink = null; + } + process(ansiCode) { + const hyperlink2 = parseOsc8Hyperlink(ansiCode); + if (hyperlink2 !== void 0) { + this.activeHyperlink = hyperlink2; + return; + } + if (!ansiCode.endsWith("m")) { + return; + } + const match = ansiCode.match(/\x1b\[([\d;]*)m/); + if (!match) return; + const params = match[1]; + if (params === "" || params === "0") { + this.reset(); + return; + } + const parts = params.split(";"); + let i = 0; + while (i < parts.length) { + const code = Number.parseInt(parts[i], 10); + if (code === 38 || code === 48) { + if (parts[i + 1] === "5" && parts[i + 2] !== void 0) { + const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`; + if (code === 38) { + this.fgColor = colorCode; + } else { + this.bgColor = colorCode; + } + i += 3; + continue; + } else if (parts[i + 1] === "2" && parts[i + 4] !== void 0) { + const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`; + if (code === 38) { + this.fgColor = colorCode; + } else { + this.bgColor = colorCode; + } + i += 5; + continue; + } + } + switch (code) { + case 0: + this.reset(); + break; + case 1: + this.bold = true; + break; + case 2: + this.dim = true; + break; + case 3: + this.italic = true; + break; + case 4: + this.underline = true; + break; + case 5: + this.blink = true; + break; + case 7: + this.inverse = true; + break; + case 8: + this.hidden = true; + break; + case 9: + this.strikethrough = true; + break; + case 21: + this.bold = false; + break; + case 22: + this.bold = false; + this.dim = false; + break; + case 23: + this.italic = false; + break; + case 24: + this.underline = false; + break; + case 25: + this.blink = false; + break; + case 27: + this.inverse = false; + break; + case 28: + this.hidden = false; + break; + case 29: + this.strikethrough = false; + break; + case 39: + this.fgColor = null; + break; + case 49: + this.bgColor = null; + break; + default: + if (code >= 30 && code <= 37 || code >= 90 && code <= 97) { + this.fgColor = String(code); + } else if (code >= 40 && code <= 47 || code >= 100 && code <= 107) { + this.bgColor = String(code); + } + break; + } + i++; + } + } + reset() { + this.bold = false; + this.dim = false; + this.italic = false; + this.underline = false; + this.blink = false; + this.inverse = false; + this.hidden = false; + this.strikethrough = false; + this.fgColor = null; + this.bgColor = null; + } + /** Clear all state for reuse. */ + clear() { + this.reset(); + this.activeHyperlink = null; + } + getActiveCodes() { + const codes = []; + if (this.bold) codes.push("1"); + if (this.dim) codes.push("2"); + if (this.italic) codes.push("3"); + if (this.underline) codes.push("4"); + if (this.blink) codes.push("5"); + if (this.inverse) codes.push("7"); + if (this.hidden) codes.push("8"); + if (this.strikethrough) codes.push("9"); + if (this.fgColor) codes.push(this.fgColor); + if (this.bgColor) codes.push(this.bgColor); + let result = codes.length > 0 ? `\x1B[${codes.join(";")}m` : ""; + if (this.activeHyperlink) { + result += formatOsc8Hyperlink(this.activeHyperlink); + } + return result; + } + hasActiveCodes() { + return this.bold || this.dim || this.italic || this.underline || this.blink || this.inverse || this.hidden || this.strikethrough || this.fgColor !== null || this.bgColor !== null || this.activeHyperlink !== null; + } + /** + * Get reset codes for attributes that need to be turned off at line end. + * Underline must be closed to prevent bleeding into padding. + * Active OSC 8 hyperlinks must be closed and re-opened on the next line. + * Returns empty string if no attributes need closing. + */ + getLineEndReset() { + let result = ""; + if (this.underline) { + result += "\x1B[24m"; + } + if (this.activeHyperlink) { + result += formatOsc8Close(this.activeHyperlink.terminator); + } + return result; + } + }; + function updateTrackerFromText(text, tracker) { + let i = 0; + while (i < text.length) { + const ansiResult = extractAnsiCode(text, i); + if (ansiResult) { + tracker.process(ansiResult.code); + i += ansiResult.length; + } else { + i++; + } + } + } + function splitIntoTokensWithAnsi(text) { + const tokens = []; + let current = ""; + let pendingAnsi = ""; + let currentKind = null; + let i = 0; + const flushCurrent = () => { + if (!current) { + return; + } + tokens.push(current); + current = ""; + currentKind = null; + }; + while (i < text.length) { + const ansiResult = extractAnsiCode(text, i); + if (ansiResult) { + pendingAnsi += ansiResult.code; + i += ansiResult.length; + continue; + } + let end = i; + while (end < text.length && !extractAnsiCode(text, end)) { + end++; + } + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const segmentIsSpace = segment === " "; + if (!segmentIsSpace && cjkBreakRegex.test(segment)) { + flushCurrent(); + const token = pendingAnsi + segment; + pendingAnsi = ""; + tokens.push(token); + continue; + } + const segmentKind = segmentIsSpace ? "space" : "word"; + if (current && currentKind !== segmentKind) { + flushCurrent(); + } + if (pendingAnsi) { + current += pendingAnsi; + pendingAnsi = ""; + } + currentKind = segmentKind; + current += segment; + } + i = end; + } + if (pendingAnsi) { + if (current) { + current += pendingAnsi; + } else if (tokens.length > 0) { + tokens[tokens.length - 1] += pendingAnsi; + } else { + current = pendingAnsi; + } + } + if (current) { + tokens.push(current); + } + return tokens; + } + function wrapTextWithAnsi(text, width) { + if (!text) { + return [""]; + } + const inputLines = text.split(/\r\n|\r|\n/); + const result = []; + const tracker = new AnsiCodeTracker(); + for (const inputLine of inputLines) { + const prefix = result.length > 0 ? tracker.getActiveCodes() : ""; + const wrappedLines = wrapSingleLine(prefix + inputLine, width); + for (const wrappedLine of wrappedLines) { + result.push(wrappedLine); + } + updateTrackerFromText(inputLine, tracker); + } + return result.length > 0 ? result : [""]; + } + function wrapSingleLine(line, width) { + if (!line) { + return [""]; + } + const visibleLength = visibleWidth(line); + if (visibleLength <= width) { + return [line]; + } + const wrapped = []; + const tracker = new AnsiCodeTracker(); + const tokens = splitIntoTokensWithAnsi(line); + let currentLine = ""; + let currentVisibleLength = 0; + for (const token of tokens) { + const tokenVisibleLength = visibleWidth(token); + const isWhitespace = token.trim() === ""; + if (tokenVisibleLength > width && !isWhitespace) { + if (currentLine) { + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + currentLine += lineEndReset; + } + wrapped.push(currentLine); + currentLine = ""; + currentVisibleLength = 0; + } + const broken = breakLongWord(token, width, tracker); + for (let i = 0; i < broken.length - 1; i++) { + wrapped.push(broken[i]); + } + currentLine = broken[broken.length - 1]; + currentVisibleLength = visibleWidth(currentLine); + continue; + } + const totalNeeded = currentVisibleLength + tokenVisibleLength; + if (totalNeeded > width && currentVisibleLength > 0) { + let lineToWrap = currentLine.trimEnd(); + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + lineToWrap += lineEndReset; + } + wrapped.push(lineToWrap); + if (isWhitespace) { + currentLine = tracker.getActiveCodes(); + currentVisibleLength = 0; + } else { + currentLine = tracker.getActiveCodes() + token; + currentVisibleLength = tokenVisibleLength; + } + } else { + currentLine += token; + currentVisibleLength += tokenVisibleLength; + } + updateTrackerFromText(token, tracker); + } + if (currentLine) { + wrapped.push(currentLine); + } + return wrapped.length > 0 ? wrapped.map((line2) => line2.trimEnd()) : [""]; + } + function breakLongWord(word, width, tracker) { + const lines = []; + let currentLine = tracker.getActiveCodes(); + let currentWidth = 0; + let i = 0; + const segments = []; + while (i < word.length) { + const ansiResult = extractAnsiCode(word, i); + if (ansiResult) { + segments.push({ type: "ansi", value: ansiResult.code }); + i += ansiResult.length; + } else { + let end = i; + while (end < word.length) { + const nextAnsi = extractAnsiCode(word, end); + if (nextAnsi) break; + end++; + } + const textPortion = word.slice(i, end); + for (const seg of graphemeSegmenter.segment(textPortion)) { + segments.push({ type: "grapheme", value: seg.segment }); + } + i = end; + } + } + for (const seg of segments) { + if (seg.type === "ansi") { + currentLine += seg.value; + tracker.process(seg.value); + continue; + } + const grapheme = seg.value; + if (!grapheme) continue; + const graphemeWidth2 = visibleWidth(grapheme); + if (currentWidth + graphemeWidth2 > width) { + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + currentLine += lineEndReset; + } + lines.push(currentLine); + currentLine = tracker.getActiveCodes(); + currentWidth = 0; + } + currentLine += grapheme; + currentWidth += graphemeWidth2; + } + if (currentLine) { + lines.push(currentLine); + } + return lines.length > 0 ? lines : [""]; + } + function applyBackgroundToLine(line, width, bgFn) { + const visibleLen = visibleWidth(line); + const paddingNeeded = Math.max(0, width - visibleLen); + const padding = " ".repeat(paddingNeeded); + const withPadding = line + padding; + return bgFn(withPadding); + } + var pooledStyleTracker = new AnsiCodeTracker(); + + // packages/tui/src/latex.ts + var SYMBOLS = { + alpha: "\u03B1", + beta: "\u03B2", + gamma: "\u03B3", + delta: "\u03B4", + epsilon: "\u03F5", + varepsilon: "\u03B5", + zeta: "\u03B6", + eta: "\u03B7", + theta: "\u03B8", + vartheta: "\u03D1", + iota: "\u03B9", + kappa: "\u03BA", + varkappa: "\u03F0", + lambda: "\u03BB", + mu: "\u03BC", + nu: "\u03BD", + xi: "\u03BE", + pi: "\u03C0", + varpi: "\u03D6", + rho: "\u03C1", + varrho: "\u03F1", + sigma: "\u03C3", + varsigma: "\u03C2", + tau: "\u03C4", + upsilon: "\u03C5", + phi: "\u03D5", + varphi: "\u03C6", + chi: "\u03C7", + psi: "\u03C8", + omega: "\u03C9", + Gamma: "\u0393", + Delta: "\u0394", + Theta: "\u0398", + Lambda: "\u039B", + Xi: "\u039E", + Pi: "\u03A0", + Sigma: "\u03A3", + Upsilon: "\u03A5", + Phi: "\u03A6", + Psi: "\u03A8", + Omega: "\u03A9", + pm: "\xB1", + mp: "\u2213", + times: "\xD7", + div: "\xF7", + cdot: "\xB7", + ast: "\u2217", + star: "\u22C6", + circ: "\u2218", + bullet: "\u2022", + oplus: "\u2295", + ominus: "\u2296", + otimes: "\u2297", + oslash: "\u2298", + odot: "\u2299", + bigcirc: "\u25CB", + dagger: "\u2020", + ddagger: "\u2021", + amalg: "\u2A3F", + uplus: "\u228E", + sqcap: "\u2293", + sqcup: "\u2294", + triangleleft: "\u25C1", + triangleright: "\u25B7", + wr: "\u2240", + cap: "\u2229", + cup: "\u222A", + bigcap: "\u22C2", + bigcup: "\u22C3", + bigwedge: "\u22C0", + bigvee: "\u22C1", + bigsqcup: "\u2A06", + biguplus: "\u2A04", + bigoplus: "\u2A01", + bigotimes: "\u2A02", + bigodot: "\u2A00", + setminus: "\u2216", + in: "\u2208", + notin: "\u2209", + ni: "\u220B", + subset: "\u2282", + supset: "\u2283", + subseteq: "\u2286", + supseteq: "\u2287", + sqsubset: "\u228F", + sqsupset: "\u2290", + sqsubseteq: "\u2291", + sqsupseteq: "\u2292", + prec: "\u227A", + preceq: "\u227C", + succ: "\u227B", + succeq: "\u227D", + ll: "\u226A", + gg: "\u226B", + le: "\u2264", + leq: "\u2264", + leqslant: "\u2264", + ge: "\u2265", + geq: "\u2265", + geqslant: "\u2265", + ne: "\u2260", + neq: "\u2260", + equiv: "\u2261", + approx: "\u2248", + sim: "\u223C", + simeq: "\u2243", + cong: "\u2245", + asymp: "\u224D", + doteq: "\u2250", + propto: "\u221D", + parallel: "\u2225", + perp: "\u22A5", + mid: "\u2223", + vdash: "\u22A2", + dashv: "\u22A3", + models: "\u22A8", + Vdash: "\u22A9", + Vvdash: "\u22AA", + nvdash: "\u22AC", + nvDash: "\u22AD", + forall: "\u2200", + exists: "\u2203", + nexists: "\u2204", + neg: "\xAC", + land: "\u2227", + wedge: "\u2227", + lor: "\u2228", + vee: "\u2228", + to: "\u2192", + rightarrow: "\u2192", + longrightarrow: "\u2192", + leftarrow: "\u2190", + longleftarrow: "\u2190", + gets: "\u2190", + leftrightarrow: "\u2194", + longleftrightarrow: "\u2194", + hookleftarrow: "\u21A9", + hookrightarrow: "\u21AA", + twoheadleftarrow: "\u219E", + twoheadrightarrow: "\u21A0", + leftharpoonup: "\u21BC", + leftharpoondown: "\u21BD", + rightharpoonup: "\u21C0", + rightharpoondown: "\u21C1", + rightleftharpoons: "\u21CC", + leftrightharpoons: "\u21CB", + nearrow: "\u2197", + searrow: "\u2198", + swarrow: "\u2199", + nwarrow: "\u2196", + rightsquigarrow: "\u21DD", + leadsto: "\u21DD", + Rightarrow: "\u21D2", + Longrightarrow: "\u21D2", + Leftarrow: "\u21D0", + Longleftarrow: "\u21D0", + Leftrightarrow: "\u21D4", + Longleftrightarrow: "\u21D4", + implies: "\u21D2", + iff: "\u21D4", + mapsto: "\u21A6", + longmapsto: "\u21A6", + uparrow: "\u2191", + downarrow: "\u2193", + partial: "\u2202", + nabla: "\u2207", + int: "\u222B", + iint: "\u222C", + iiint: "\u222D", + oint: "\u222E", + sum: "\u2211", + prod: "\u220F", + coprod: "\u2210", + infty: "\u221E", + emptyset: "\u2205", + varnothing: "\u2205", + angle: "\u2220", + therefore: "\u2234", + because: "\u2235", + aleph: "\u2135", + beth: "\u2136", + gimel: "\u2137", + daleth: "\u2138", + top: "\u22A4", + bot: "\u22A5", + triangle: "\u25B3", + square: "\u25A1", + lozenge: "\u25CA", + checkmark: "\u2713", + complement: "\u2201", + wp: "\u2118", + prime: "\u2032", + ldots: "\u2026", + dots: "\u2026", + cdots: "\u22EF", + vdots: "\u22EE", + ddots: "\u22F1", + ell: "\u2113", + hbar: "\u210F", + Im: "\u2111", + Re: "\u211C", + langle: "\u27E8", + rangle: "\u27E9", + vert: "|", + lvert: "|", + rvert: "|", + Vert: "\u2016", + lVert: "\u2016", + rVert: "\u2016", + lbrace: "{", + rbrace: "}", + backslash: "\\", + lfloor: "\u230A", + rfloor: "\u230B", + lceil: "\u2308", + rceil: "\u2309", + colon: ":" + }; + var NAMED_OPERATORS = /* @__PURE__ */ new Set([ + "arccos", + "arcsin", + "arctan", + "arg", + "cos", + "cosh", + "cot", + "coth", + "csc", + "deg", + "det", + "dim", + "exp", + "gcd", + "hom", + "inf", + "ker", + "lg", + "lim", + "liminf", + "limsup", + "ln", + "log", + "max", + "min", + "Pr", + "sec", + "sin", + "sinh", + "sup", + "tan", + "tanh" + ]); + var LIMIT_OPERATORS = /* @__PURE__ */ new Set([ + "argmax", + "argmin", + "inf", + "injlim", + "lim", + "liminf", + "limsup", + "max", + "min", + "projlim", + "sup" + ]); + var DISPLAY_LIMIT_SYMBOLS = /* @__PURE__ */ new Set([ + "bigcap", + "bigcup", + "bigodot", + "bigoplus", + "bigotimes", + "bigsqcup", + "biguplus", + "bigvee", + "bigwedge", + "coprod", + "int", + "iint", + "iiint", + "oint", + "prod", + "sum" + ]); + var RELATION_COMMANDS = /* @__PURE__ */ new Set([ + "Leftarrow", + "Leftrightarrow", + "Longleftarrow", + "Longleftrightarrow", + "Longrightarrow", + "Rightarrow", + "Vdash", + "Vvdash", + "approx", + "asymp", + "cong", + "dashv", + "doteq", + "downarrow", + "equiv", + "ge", + "geq", + "geqslant", + "gets", + "gg", + "hookleftarrow", + "hookrightarrow", + "iff", + "implies", + "in", + "leadsto", + "le", + "leftarrow", + "leftharpoondown", + "leftharpoonup", + "leftrightarrow", + "leftrightharpoons", + "leq", + "leqslant", + "ll", + "longleftarrow", + "longleftrightarrow", + "longmapsto", + "longrightarrow", + "mapsto", + "mid", + "models", + "ne", + "nearrow", + "neq", + "ni", + "notin", + "nvdash", + "nvDash", + "nwarrow", + "parallel", + "perp", + "prec", + "preceq", + "propto", + "rightharpoondown", + "rightharpoonup", + "rightleftharpoons", + "rightarrow", + "rightsquigarrow", + "searrow", + "sim", + "simeq", + "sqsubset", + "sqsubseteq", + "sqsupset", + "sqsupseteq", + "subset", + "subseteq", + "succ", + "succeq", + "supset", + "supseteq", + "swarrow", + "to", + "triangleleft", + "triangleright", + "twoheadleftarrow", + "twoheadrightarrow", + "uparrow", + "vdash" + ]); + var NEGATED_SYMBOLS = { + "<": "\u226E", + ">": "\u226F", + "=": "\u2260", + "\u2208": "\u2209", + "\u220B": "\u220C", + "\u2223": "\u2224", + "\u2225": "\u2226", + "\u223C": "\u2241", + "\u2243": "\u2244", + "\u2245": "\u2247", + "\u2248": "\u2249", + "\u2261": "\u2262", + "\u2264": "\u2270", + "\u2265": "\u2271", + "\u227A": "\u2280", + "\u227B": "\u2281", + "\u2282": "\u2284", + "\u2283": "\u2285", + "\u2286": "\u2288", + "\u2287": "\u2289", + "\u22A2": "\u22AC", + "\u22A8": "\u22AD", + "\u2194": "\u21AE", + "\u2190": "\u219A", + "\u2192": "\u219B", + "\u21D2": "\u21CF", + "\u21D0": "\u21CD", + "\u21D4": "\u21CE", + "\u227C": "\u22E0", + "\u227D": "\u22E1" + }; + var BLACKBOARD = { + C: "\u2102", + H: "\u210D", + N: "\u2115", + P: "\u2119", + Q: "\u211A", + R: "\u211D", + Z: "\u2124" + }; + var SUPERSCRIPTS = { + "0": "\u2070", + "1": "\xB9", + "2": "\xB2", + "3": "\xB3", + "4": "\u2074", + "5": "\u2075", + "6": "\u2076", + "7": "\u2077", + "8": "\u2078", + "9": "\u2079", + "+": "\u207A", + "-": "\u207B", + "=": "\u207C", + "(": "\u207D", + ")": "\u207E", + a: "\u1D43", + b: "\u1D47", + c: "\u1D9C", + d: "\u1D48", + e: "\u1D49", + f: "\u1DA0", + g: "\u1D4D", + h: "\u02B0", + i: "\u2071", + j: "\u02B2", + k: "\u1D4F", + l: "\u02E1", + m: "\u1D50", + n: "\u207F", + o: "\u1D52", + p: "\u1D56", + r: "\u02B3", + s: "\u02E2", + t: "\u1D57", + u: "\u1D58", + v: "\u1D5B", + w: "\u02B7", + x: "\u02E3", + y: "\u02B8", + z: "\u1DBB" + }; + var SUBSCRIPTS = { + "0": "\u2080", + "1": "\u2081", + "2": "\u2082", + "3": "\u2083", + "4": "\u2084", + "5": "\u2085", + "6": "\u2086", + "7": "\u2087", + "8": "\u2088", + "9": "\u2089", + "+": "\u208A", + "-": "\u208B", + "=": "\u208C", + "(": "\u208D", + ")": "\u208E", + a: "\u2090", + e: "\u2091", + h: "\u2095", + i: "\u1D62", + j: "\u2C7C", + k: "\u2096", + l: "\u2097", + m: "\u2098", + n: "\u2099", + o: "\u2092", + p: "\u209A", + r: "\u1D63", + s: "\u209B", + t: "\u209C", + u: "\u1D64", + v: "\u1D65", + x: "\u2093" + }; + var SPACING_COMMANDS = /* @__PURE__ */ new Set([ + ",", + ":", + ";", + " ", + ">", + "enspace", + "enskip", + "medspace", + "quad", + "qquad", + "thickspace", + "thinspace" + ]); + var NEGATIVE_SPACING_COMMANDS = /* @__PURE__ */ new Set(["!", "negmedspace", "negthickspace", "negthinspace"]); + var NEGATIVE_SPACE = "\0"; + var IGNORED_COMMANDS = /* @__PURE__ */ new Set([ + "displaystyle", + "limits", + "nolimits", + "scriptstyle", + "scriptscriptstyle", + "textstyle" + ]); + var SIZE_COMMANDS = /* @__PURE__ */ new Set([ + "big", + "Big", + "bigg", + "Bigg", + "bigl", + "Bigl", + "biggl", + "Biggl", + "bigr", + "Bigr", + "biggr", + "Biggr" + ]); + var PLAIN_WRAPPERS = /* @__PURE__ */ new Set([ + "emph", + "mathcal", + "mathbf", + "mathfrak", + "mathit", + "mathrm", + "mathnormal", + "mathscr", + "mathsf", + "mathtt", + "mathup", + "mbox", + "overbrace", + "pmb", + "smash", + "substack", + "text", + "textbf", + "textit", + "textmd", + "textnormal", + "textrm", + "textsc", + "textsf", + "textsl", + "texttt", + "textup", + "underbrace", + "bm", + "boldsymbol" + ]); + var ACCENTS = { + acute: "\u0301", + bar: "\u0305", + breve: "\u0306", + check: "\u030C", + ddot: "\u0308", + dot: "\u0307", + grave: "\u0300", + hat: "\u0302", + mathring: "\u030A", + overleftarrow: "\u20D6", + overleftrightarrow: "\u20E1", + overline: "\u0305", + overrightarrow: "\u20D7", + tilde: "\u0303", + underline: "\u0332", + vec: "\u20D7", + widehat: "\u0302", + widetilde: "\u0303" + }; + function replaceCharacters(value, replacements) { + let result = ""; + for (const character of value) { + const replacement = replacements[character]; + if (replacement === void 0) { + return void 0; + } + result += replacement; + } + return result; + } + function formatScript(value, kind) { + value = value.trim(); + const replacements = kind === "sub" ? SUBSCRIPTS : SUPERSCRIPTS; + const unicode = replaceCharacters(value.replace(/\s*([=+-])\s*/g, "$1"), replacements); + if (unicode !== void 0) { + return unicode; + } + const prefix = kind === "sub" ? "_" : "^"; + if (Array.from(value).length === 1 || kind === "sub" && /^[A-Za-z]+$/.test(value)) { + return `${prefix}${value}`; + } + return `${prefix}(${value})`; + } + function formatFraction(numerator, denominator) { + numerator = numerator.trim(); + denominator = denominator.trim(); + const simpleNumerator = /^[\p{L}\p{N}.]+$/u.test(numerator); + const simpleDenominator = /^[\p{N}.]+$/u.test(denominator) || Array.from(denominator).length === 1; + return `${simpleNumerator ? numerator : `(${numerator})`}/${simpleDenominator ? denominator : `(${denominator})`}`; + } + function formatRoot(value, symbol = "\u221A") { + value = value.trim(); + return /^[\p{L}\p{N}.]+$/u.test(value) ? `${symbol}${value}` : `${symbol}(${value})`; + } + var NAMED_OPERATOR_START = "\u{F0004}"; + var NAMED_OPERATOR_END = "\u{F0005}"; + var NAMED_OPERATOR_LEFT_SPACING_PATTERN = /(?<=[\p{L}\p{N})\]}\u{f0001}])\u{f0004}/gu; + var NAMED_OPERATOR_RIGHT_SPACING_PATTERN = /\u{f0005}(?=[\p{L}\p{N}√\u{f0000}])/gu; + function normalizeOutput(value) { + return value.replace(NAMED_OPERATOR_LEFT_SPACING_PATTERN, " ").replaceAll(NAMED_OPERATOR_START, "").replace(NAMED_OPERATOR_RIGHT_SPACING_PATTERN, " ").replaceAll(NAMED_OPERATOR_END, "").split("\n").map((line) => line.replace(/[ \t]+/g, " ").trim()).filter((line, index, lines) => line.length > 0 || index > 0 && index < lines.length - 1).join("\n").trim(); + } + var LAYOUT_MARKER_START = "\u{F0000}"; + var LAYOUT_MARKER_END = "\u{F0001}"; + var LAYOUT_MARKER_PATTERN = /\u{f0000}(\d+)\u{f0001}/gu; + var TRAILING_LAYOUT_MARKER_PATTERN = /\u{f0000}(\d+)\u{f0001}$/u; + var PROTECTED_SPACE = "\u{F0002}"; + function padLayoutLine(line, width, centered = false) { + const padding = Math.max(0, width - visibleWidth(line)); + const left = centered ? Math.floor(padding / 2) : 0; + return `${" ".repeat(left)}${line}${" ".repeat(padding - left)}`; + } + function joinLayouts(layouts) { + if (layouts.length === 0) { + return { lines: [""], width: 0, baseline: 0 }; + } + const baseline = Math.max(...layouts.map((layout) => layout.baseline)); + const below = Math.max(...layouts.map((layout) => layout.lines.length - layout.baseline - 1)); + const lines = []; + for (let row = 0; row <= baseline + below; row++) { + let line = ""; + for (const layout of layouts) { + const sourceRow = row - baseline + layout.baseline; + line += sourceRow >= 0 && sourceRow < layout.lines.length ? padLayoutLine(layout.lines[sourceRow] ?? "", layout.width) : " ".repeat(layout.width); + } + lines.push(line.trimEnd()); + } + return { + lines, + width: layouts.reduce((width, layout) => width + layout.width, 0), + baseline + }; + } + function renderLayout(source, nodes) { + const renderedLines = []; + let firstBaseline = 0; + for (const sourceLine of source.split("\n")) { + const layouts = []; + let position = 0; + let previousNode; + for (const match of sourceLine.matchAll(LAYOUT_MARKER_PATTERN)) { + const index = match.index; + const node = nodes[Number(match[1])]; + if (!node) { + continue; + } + if (index > position) { + const sliced = sourceLine.slice(position, index); + const trimmed = (previousNode ? sliced.trimStart() : sliced).trimEnd(); + const preserveLeadingSpace = previousNode?.type === "matrix" && /^\s/.test(sliced); + const preserveTrailingSpace = node.type === "matrix" && /\s$/.test(sliced); + const text = trimmed ? `${preserveLeadingSpace ? " " : ""}${trimmed}${preserveTrailingSpace ? " " : ""}` : preserveLeadingSpace || preserveTrailingSpace ? " " : ""; + layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); + } + if (node.type === "fraction") { + const numerator = renderLayout(node.numerator, nodes); + const denominator = renderLayout(node.denominator, nodes); + const contentWidth = Math.max(numerator.width, denominator.width, 1); + const width = contentWidth + 2; + layouts.push({ + lines: [ + ...numerator.lines.map((line) => padLayoutLine(line, width, true)), + ` ${"\u2500".repeat(contentWidth)} `, + ...denominator.lines.map((line) => padLayoutLine(line, width, true)) + ], + width, + baseline: numerator.lines.length + }); + } else if (node.type === "operator") { + const contentWidth = Math.max( + visibleWidth(node.operator), + node.lower === void 0 ? 0 : visibleWidth(node.lower), + node.upper === void 0 ? 0 : visibleWidth(node.upper) + ); + const lines = []; + if (node.upper !== void 0) { + lines.push(`${padLayoutLine(node.upper, contentWidth, true)} `); + } + lines.push(`${padLayoutLine(node.operator, contentWidth, true)} `); + if (node.lower !== void 0) { + lines.push(`${padLayoutLine(node.lower, contentWidth, true)} `); + } + layouts.push({ + lines, + width: contentWidth + 1, + baseline: node.upper === void 0 ? 0 : 1 + }); + } else { + const width = Math.max(0, ...node.lines.map((line) => visibleWidth(line))); + layouts.push({ + lines: node.lines.map((line) => padLayoutLine(line, width)), + width, + baseline: node.baseline + }); + } + position = index + match[0].length; + previousNode = node; + } + if (position < sourceLine.length) { + const sliced = sourceLine.slice(position); + const trimmed = previousNode ? sliced.trimStart() : sliced; + const text = previousNode?.type === "matrix" && /^\s/.test(sliced) ? ` ${trimmed}` : trimmed; + layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); + } + const lineLayout = joinLayouts(layouts); + if (renderedLines.length === 0) { + firstBaseline = lineLayout.baseline; + } + renderedLines.push(...lineLayout.lines); + } + return { + lines: renderedLines, + width: Math.max(0, ...renderedLines.map((line) => visibleWidth(line))), + baseline: firstBaseline + }; + } + var LatexParser = class _LatexParser { + constructor(source, layoutNodes, display) { + this.position = 0; + this.supported = true; + this.stackFractions = true; + this.source = source; + this.layoutNodes = layoutNodes; + this.display = display; + } + render() { + const rendered = this.parseSequence(); + if (!this.supported || this.position !== this.source.length) { + return void 0; + } + return normalizeOutput(rendered); + } + parseSequence(endCharacter) { + let result = ""; + while (this.position < this.source.length) { + const character = this.source[this.position]; + if (endCharacter && character === endCharacter) { + this.position++; + return result; + } + if (character === "}") { + this.supported = false; + return result; + } + if (character === "{") { + this.position++; + result += this.parseSequence("}"); + continue; + } + if (character === "\\") { + const command = this.parseCommand(); + if (command === NEGATIVE_SPACE) { + result = result.trimEnd(); + if (result.endsWith(NAMED_OPERATOR_END)) { + result = result.slice(0, -NAMED_OPERATOR_END.length); + } + } else { + result += command; + } + continue; + } + if (character === "^" || character === "_") { + this.position++; + result = result.trimEnd(); + const script = formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup"); + if (result.endsWith(NAMED_OPERATOR_END)) { + result = `${result.slice(0, -NAMED_OPERATOR_END.length)}${script}${NAMED_OPERATOR_END}`; + } else { + result += script; + } + continue; + } + if (/\s/.test(character)) { + result += this.parseWhitespace(); + continue; + } + if (character === "=" || character === "<" || character === ">") { + result = `${result.trimEnd()} ${character} `; + this.position++; + continue; + } + if (character === "&") { + this.position++; + continue; + } + if (character === "~") { + this.position++; + result += " "; + continue; + } + if (character === ".") { + const marker = TRAILING_LAYOUT_MARKER_PATTERN.exec(result); + const node = marker ? this.layoutNodes[Number(marker[1])] : void 0; + if (node?.type === "matrix") { + const lastLine = node.lines.length - 1; + node.lines[lastLine] = `${node.lines[lastLine] ?? ""}${character}`; + this.position++; + continue; + } + } + result += character; + this.position++; + } + if (endCharacter) { + this.supported = false; + } + return result; + } + parseWhitespace() { + while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) { + this.position++; + } + return " "; + } + parseCommand() { + this.position++; + if (this.position >= this.source.length) { + this.supported = false; + return ""; + } + let command = ""; + const first = this.source[this.position] ?? ""; + if (first === "\n" || first === "\r") { + this.position++; + if (first === "\r" && this.source[this.position] === "\n") { + this.position++; + } + return " "; + } + if (/[A-Za-z]/.test(first)) { + const start = this.position; + while (this.position < this.source.length && /[A-Za-z]/.test(this.source[this.position] ?? "")) { + this.position++; + } + command = this.source.slice(start, this.position); + } else { + command = first; + this.position++; + } + if (command === "\\") { + return "\n"; + } + if (SPACING_COMMANDS.has(command)) { + return " "; + } + if (NEGATIVE_SPACING_COMMANDS.has(command)) { + return NEGATIVE_SPACE; + } + if (IGNORED_COMMANDS.has(command)) { + return ""; + } + if (command === "{" || command === "}" || command === "$" || command === "%" || command === "#" || command === "_" || command === "&") { + return command; + } + if (command === "|") { + return "\u2016"; + } + if (command === "not") { + const value = this.parseRequiredArgument(false).trim(); + const negated = NEGATED_SYMBOLS[value]; + if (negated !== void 0) { + return ` ${negated} `; + } + const characters = Array.from(value); + if (characters.length === 0) { + this.supported = false; + return ""; + } + return ` ${characters[0]}\u0338${characters.slice(1).join("")} `; + } + if (LIMIT_OPERATORS.has(command)) { + return this.parseOperator(command, "bracket", true, true); + } + const symbol = SYMBOLS[command]; + if (symbol !== void 0) { + if (DISPLAY_LIMIT_SYMBOLS.has(command)) { + return this.parseOperator(symbol, "script", true); + } + return command === "cdot" || command === "times" || RELATION_COMMANDS.has(command) ? ` ${symbol} ` : symbol; + } + if (NAMED_OPERATORS.has(command)) { + return `${NAMED_OPERATOR_START}${command}${NAMED_OPERATOR_END}`; + } + if (SIZE_COMMANDS.has(command)) { + return ""; + } + if (command === "left" || command === "middle" || command === "right") { + if (this.source[this.position] === ".") { + this.position++; + } + return ""; + } + if (command === "frac" || command === "dfrac" || command === "tfrac") { + const shouldStack = this.display && this.stackFractions && command !== "tfrac"; + const numerator = this.parseRequiredArgument(!shouldStack); + const denominator = this.parseRequiredArgument(!shouldStack); + if (shouldStack) { + const index = this.layoutNodes.push({ + type: "fraction", + numerator: normalizeOutput(numerator), + denominator: normalizeOutput(denominator) + }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + return formatFraction(numerator, denominator); + } + if (command === "sqrt") { + const degree = this.parseOptionalArgument()?.trim(); + const value = this.parseRequiredArgument(); + if (degree === void 0 || degree === "2") { + return formatRoot(value); + } + if (degree === "3") { + return formatRoot(value, "\u221B"); + } + if (degree === "4") { + return formatRoot(value, "\u221C"); + } + return `${formatScript(degree, "sup")}${formatRoot(value)}`; + } + if (command === "boxed" || command === "fbox") { + return `[${this.parseRequiredArgument().trim()}]`; + } + if (command === "binom" || command === "dbinom" || command === "tbinom") { + return `(${this.parseRequiredArgument()} choose ${this.parseRequiredArgument()})`; + } + const accent = ACCENTS[command]; + if (accent !== void 0) { + const value = this.parseRequiredArgument(); + return Array.from(value).length === 1 ? `${value}${accent}` : `${command}(${value})`; + } + if (command === "mathbb") { + const value = this.parseRequiredArgument(); + return Array.from(value, (character) => BLACKBOARD[character] ?? character).join(""); + } + if (command === "operatorname") { + const starred = this.source[this.position] === "*"; + if (starred) { + this.position++; + } + const operator = normalizeOutput(this.parseRequiredArgument()).trim(); + return this.parseOperator(operator, "bracket", starred, true); + } + if (command === "mod" || command === "bmod") { + return " mod "; + } + if (command === "pmod" || command === "pod") { + const value = this.parseRequiredArgument().trim(); + return command === "pmod" ? ` (mod ${value})` : ` (${value})`; + } + if (command === "overset" || command === "stackrel") { + const upper = this.parseRequiredArgument(); + const value = this.parseRequiredArgument().trim(); + return `${value}${formatScript(upper, "sup")}`; + } + if (command === "underset") { + const lower = this.parseRequiredArgument(); + const value = this.parseRequiredArgument().trim(); + return `${value}${formatScript(lower, "sub")}`; + } + if (PLAIN_WRAPPERS.has(command)) { + const value = this.parseRequiredArgument(); + return command.startsWith("text") || command === "mbox" ? value : value.trim(); + } + if (command === "begin") { + return this.parseEnvironment(); + } + if (command === "end") { + this.supported = false; + return ""; + } + this.supported = false; + return `\\${command}`; + } + parseOperator(operator, inlineLowerStyle, displayLimits, spaced = false) { + let useDisplayLimits = displayLimits; + let modifierPosition = this.position; + while (modifierPosition < this.source.length && /[ \t]/.test(this.source[modifierPosition] ?? "")) { + modifierPosition++; + } + const modifier = /^\\(limits|nolimits)(?![A-Za-z])/.exec(this.source.slice(modifierPosition)); + if (modifier) { + useDisplayLimits = modifier[1] === "limits"; + this.position = modifierPosition + modifier[0].length; + } + let lower; + let upper; + while (true) { + let scriptPosition = this.position; + while (scriptPosition < this.source.length && /[ \t]/.test(this.source[scriptPosition] ?? "")) { + scriptPosition++; + } + const kind = this.source[scriptPosition]; + if (kind !== "_" && kind !== "^") { + break; + } + this.position = scriptPosition + 1; + const value = normalizeOutput(this.parseRequiredArgument(false)).replaceAll(" ", ""); + if (kind === "_") { + if (lower !== void 0) { + this.supported = false; + } + lower = value; + } else { + if (upper !== void 0) { + this.supported = false; + } + upper = value; + } + } + if (this.display && useDisplayLimits && (lower !== void 0 || upper !== void 0)) { + const index = this.layoutNodes.push({ type: "operator", operator, lower, upper }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + let rendered = operator; + if (lower !== void 0) { + rendered += inlineLowerStyle === "bracket" ? `[${lower}]` : formatScript(lower, "sub"); + } + if (upper !== void 0) { + rendered += formatScript(upper, "sup"); + } + return spaced ? ` ${rendered} ` : rendered; + } + parseRequiredArgument(stackFractions = true) { + const previousStackFractions = this.stackFractions; + this.stackFractions = previousStackFractions && stackFractions; + const value = this.parseRequiredArgumentValue(); + this.stackFractions = previousStackFractions; + return value; + } + parseRequiredArgumentValue() { + while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) { + this.position++; + } + if (this.position >= this.source.length) { + this.supported = false; + return ""; + } + if (this.source[this.position] === "{") { + this.position++; + return this.parseSequence("}"); + } + if (this.source[this.position] === "\\") { + return this.parseCommand(); + } + const value = this.source[this.position] ?? ""; + this.position++; + return value; + } + parseOptionalArgument() { + while (this.position < this.source.length && /[ \t]/.test(this.source[this.position] ?? "")) { + this.position++; + } + if (this.source[this.position] !== "[") { + return void 0; + } + const end = this.source.indexOf("]", this.position + 1); + if (end < 0) { + this.supported = false; + return void 0; + } + const value = this.source.slice(this.position + 1, end); + this.position = end + 1; + return this.renderNested(value); + } + readRawGroup() { + while (this.position < this.source.length && /[ \t]/.test(this.source[this.position] ?? "")) { + this.position++; + } + if (this.source[this.position] !== "{") { + this.supported = false; + return void 0; + } + const start = ++this.position; + let depth = 1; + while (this.position < this.source.length) { + const character = this.source[this.position]; + if (character === "\\") { + this.position += 2; + continue; + } + if (character === "{") depth++; + if (character === "}") depth--; + if (depth === 0) { + const value = this.source.slice(start, this.position); + this.position++; + return value; + } + this.position++; + } + this.supported = false; + return void 0; + } + splitEnvironmentRows(body) { + return body.split(/\\\\(?:\[[^\]\n]*\])?/); + } + parseEnvironment() { + const environment = this.readRawGroup(); + if (!environment) { + return ""; + } + const endMarker = `\\end{${environment}}`; + const end = this.source.indexOf(endMarker, this.position); + if (end < 0) { + this.supported = false; + return ""; + } + const body = this.source.slice(this.position, end); + this.position = end + endMarker.length; + if (environment === "equation" || environment === "equation*" || environment === "displaymath") { + return this.renderNested(body).trim(); + } + if (environment === "aligned" || environment === "align" || environment === "align*" || environment === "alignedat" || environment === "alignat" || environment === "alignat*" || environment === "gather" || environment === "gathered" || environment === "multline" || environment === "multline*" || environment === "split") { + const alignedAt = ["alignedat", "alignat", "alignat*"].includes(environment); + const alignedBody = alignedAt ? body.replace(/^\s*\{[^}]*\}/, "") : body; + return this.splitEnvironmentRows(alignedBody).map((row) => { + const cells = row.split("&"); + const source = alignedAt ? Array.from( + { length: Math.ceil(cells.length / 2) }, + (_2, index) => cells.slice(index * 2, index * 2 + 2).join("") + ).join(" ") : cells.join(""); + return this.renderNested(source).trim(); + }).filter(Boolean).join("\n"); + } + if (environment === "cases" || environment === "cases*") { + const rows = this.splitEnvironmentRows(body).map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())).filter((row) => row.some(Boolean)); + return rows.map((row, index) => { + const value = (row[0] ?? "").replace(/,\s*$/, ""); + const condition = row[1] ?? ""; + const delimiter = index === 0 ? "\u23A7" : index === rows.length - 1 ? "\u23A9" : "\u23A8"; + const conditionPrefix = /^(?:if|when|for|otherwise)\b/i.test(condition) ? " " : " if "; + return `${delimiter} ${value}${condition ? `${conditionPrefix}${condition}` : ""}`; + }).join("\n"); + } + if (["array", "matrix", "smallmatrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix"].includes(environment)) { + const matrixBody = environment === "array" ? body.replace(/^\s*\{[^}]*\}/, "") : body; + return this.renderMatrix(environment, matrixBody); + } + this.supported = false; + return body; + } + renderMatrix(environment, body) { + const matrix = this.splitEnvironmentRows(body).map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())).filter((row) => row.some(Boolean)); + const columnCount = Math.max(0, ...matrix.map((row) => row.length)); + const columnWidths = Array.from( + { length: columnCount }, + (_2, column) => Math.max(0, ...matrix.map((row) => visibleWidth(row[column] ?? ""))) + ); + const rows = matrix.map( + (row) => Array.from({ length: columnCount }, (_2, column) => { + const cell = row[column] ?? ""; + return `${cell}${PROTECTED_SPACE.repeat(Math.max(0, (columnWidths[column] ?? 0) - visibleWidth(cell)))}`; + }).join(" \u2502 ") + ); + let lines; + if (environment === "array" || environment === "matrix" || environment === "smallmatrix") { + lines = rows; + } else { + const delimiters = { + pmatrix: ["\u239B", "\u239E", "\u239C", "\u239F", "\u239D", "\u23A0"], + bmatrix: ["\u23A1", "\u23A4", "\u23A2", "\u23A5", "\u23A3", "\u23A6"], + Bmatrix: ["\u23A7", "\u23AB", "\u23A8", "\u23AC", "\u23A9", "\u23AD"], + vmatrix: ["\u2502", "\u2502", "\u2502", "\u2502", "\u2502", "\u2502"], + Vmatrix: ["\u2551", "\u2551", "\u2551", "\u2551", "\u2551", "\u2551"] + }; + const delimiter = delimiters[environment]; + if (!delimiter) { + this.supported = false; + return rows.join("\n"); + } + lines = rows.map((row, index2) => { + const left = index2 === 0 ? delimiter[0] : index2 === rows.length - 1 ? delimiter[4] : delimiter[2]; + const right = index2 === 0 ? delimiter[1] : index2 === rows.length - 1 ? delimiter[5] : delimiter[3]; + return `${left} ${row} ${right}`; + }); + } + if (lines.length <= 1) { + return lines[0] ?? ""; + } + const index = this.layoutNodes.push({ type: "matrix", lines, baseline: 0 }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + renderNested(source, stackFractions = true) { + const rendered = new _LatexParser(source, this.layoutNodes, this.display && stackFractions).render(); + if (rendered === void 0) { + this.supported = false; + return source; + } + return rendered; + } + }; + function renderLatex(source, options = {}) { + const layoutNodes = []; + const rendered = new LatexParser(source, layoutNodes, options.display === true).render(); + if (rendered === void 0) { + return void 0; + } + if (layoutNodes.length === 0) { + return rendered.replaceAll(PROTECTED_SPACE, " "); + } + const lines = renderLayout(rendered, layoutNodes).lines; + const indentation = Math.min( + ...lines.filter((line) => line.trim()).map((line) => line.length - line.trimStart().length) + ); + return lines.map((line) => line.slice(indentation).trimEnd()).join("\n").trimEnd().replaceAll(PROTECTED_SPACE, " "); + } + + // ../../../tmp/qtest/shim/terminal-image.ts + var getCapabilities = () => ({ images: false, hyperlinks: false }); + var hyperlink = (text, _url) => text; + var isImageLine = () => false; + + // packages/tui/src/components/markdown.ts + var STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/; + var StrictStrikethroughTokenizer = class extends w { + del(src) { + const match = STRICT_STRIKETHROUGH_REGEX.exec(src); + if (!match) { + return void 0; + } + const text = match[2]; + return { + type: "del", + raw: match[0], + text, + tokens: this.lexer.inlineTokens(text) + }; + } + }; + function isEscaped(source, index) { + let backslashes = 0; + for (let position = index - 1; position >= 0 && source[position] === "\\"; position--) { + backslashes++; + } + return backslashes % 2 === 1; + } + function findClosingDelimiter(source, closing, start) { + let index = source.indexOf(closing, start); + while (index >= 0 && isEscaped(source, index)) { + index = source.indexOf(closing, index + closing.length); + } + return index; + } + function looksLikePendingDollarMath(source) { + return /\\[A-Za-z]+|[_^=+*/<>()[\]|±≤≥≠≈∈→⇒∞∫∑√-]/.test(source); + } + function tokenizeInlineLatex(source) { + let opening = ""; + let closing = ""; + if (source.startsWith("$$")) { + opening = "$$"; + closing = "$$"; + } else if (source.startsWith("\\(")) { + opening = "\\("; + closing = "\\)"; + } else if (source.startsWith("\\[")) { + opening = "\\["; + closing = "\\]"; + } else if (source.startsWith("$") && !/^\$\s/.test(source)) { + opening = "$"; + closing = "$"; + } else { + return void 0; + } + const closingIndex = findClosingDelimiter(source, closing, opening.length); + if (closingIndex >= 0 && opening === "$" && (/\s$/.test(source.slice(opening.length, closingIndex)) || /^\d/.test(source.slice(closingIndex + 1)) || /^[A-Z_][A-Z0-9_]*(?:[^A-Za-z0-9_\s])?$/.test(source.slice(opening.length, closingIndex)) && /^[A-Za-z_][A-Za-z0-9_]*/.test(source.slice(closingIndex + 1)) || source.slice(opening.length, closingIndex).includes("`"))) { + return void 0; + } + if (closingIndex < 0) { + const pendingSource = source.slice(opening.length); + if (opening.startsWith("\\") || looksLikePendingDollarMath(pendingSource)) { + return { type: "latex", raw: source, text: pendingSource, pending: true }; + } + return void 0; + } + const text = source.slice(opening.length, closingIndex); + if (!text || text.includes("\n")) { + return void 0; + } + const raw = source.slice(0, closingIndex + closing.length); + return { type: "latex", raw, text }; + } + function tokenizeBlockLatex(source) { + const dollarMatch = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*?)\$\$[ \t]*(?:\n|$)/.exec(source); + if (dollarMatch?.[1]) { + return { type: "latexBlock", raw: dollarMatch[0], text: dollarMatch[1].trim() }; + } + const bracketMatch = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*?)\\\][ \t]*(?:\n|$)/.exec(source); + if (bracketMatch?.[1]) { + return { type: "latexBlock", raw: bracketMatch[0], text: bracketMatch[1].trim() }; + } + const pendingBracket = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*)$/.exec(source); + if (pendingBracket) { + return { type: "latexBlock", raw: pendingBracket[0], text: pendingBracket[1], pending: true }; + } + const pendingDollar = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*)$/.exec(source); + if (pendingDollar?.[1] && looksLikePendingDollarMath(pendingDollar[1])) { + return { type: "latexBlock", raw: pendingDollar[0], text: pendingDollar[1], pending: true }; + } + return void 0; + } + var LATEX_MARKDOWN_EXTENSIONS = [ + { + name: "latexBlock", + level: "block", + start(source) { + const match = /(?:^|\n) {0,3}(?:\$\$|\\\[)/.exec(source); + return match ? match.index + (match[0].startsWith("\n") ? 1 : 0) : void 0; + }, + tokenizer: tokenizeBlockLatex + }, + { + name: "latex", + level: "inline", + start(source) { + const indices = [source.indexOf("$"), source.indexOf("\\("), source.indexOf("\\[")].filter( + (index) => index >= 0 + ); + return indices.length > 0 ? Math.min(...indices) : void 0; + }, + tokenizer: tokenizeInlineLatex + } + ]; + function trimPartialClosingFences(tokens) { + const token = tokens[tokens.length - 1]; + if (token?.type === "list") { + trimPartialClosingFences(token.items[token.items.length - 1]?.tokens ?? []); + return; + } + if (token?.type === "blockquote") { + trimPartialClosingFences(token.tokens ?? []); + return; + } + if (token?.type !== "code") { + return; + } + const marker = /^(`{3,}|~{3,})/.exec(token.raw)?.[1]; + const lastLine = token.raw.split("\n").pop(); + if (!marker || !lastLine || lastLine.length >= marker.length || lastLine !== marker[0]?.repeat(lastLine.length)) { + return; + } + token.text = token.text.slice(0, -lastLine.length).replace(/\n$/, ""); + } + var markdownParser = new q(); + markdownParser.setOptions({ + tokenizer: new StrictStrikethroughTokenizer() + }); + markdownParser.use({ extensions: [...LATEX_MARKDOWN_EXTENSIONS] }); + var Markdown = class { + constructor(text, paddingX, paddingY, theme, defaultTextStyle, options) { + this.text = text; + this.paddingX = paddingX; + this.paddingY = paddingY; + this.theme = theme; + this.defaultTextStyle = defaultTextStyle; + this.options = options ? { ...options } : {}; + } + setText(text) { + this.text = text; + this.invalidate(); + } + invalidate() { + this.cachedText = void 0; + this.cachedWidth = void 0; + this.cachedLines = void 0; + } + render(width) { + if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { + return this.cachedLines; + } + const contentWidth = Math.max(1, width - this.paddingX * 2); + const text = this.options.transform?.(this.text, contentWidth) ?? this.text; + if (!text || text.trim() === "") { + const result2 = []; + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result2; + return result2; + } + const normalizedText = text.replace(/\t/g, " "); + const tokens = markdownParser.lexer(normalizedText); + trimPartialClosingFences(tokens); + const renderedLines = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const nextToken = tokens[i + 1]; + const tokenLines = this.renderToken(token, contentWidth, nextToken?.type); + for (const tokenLine of tokenLines) { + renderedLines.push(tokenLine); + } + } + const wrappedLines = []; + for (const line of renderedLines) { + if (isImageLine(line)) { + wrappedLines.push(line); + } else { + for (const wrappedLine of wrapTextWithAnsi(line, contentWidth)) { + wrappedLines.push(wrappedLine); + } + } + } + const leftMargin = " ".repeat(this.paddingX); + const rightMargin = " ".repeat(this.paddingX); + const bgFn = this.defaultTextStyle?.bgColor; + const contentLines = []; + for (const line of wrappedLines) { + if (isImageLine(line)) { + contentLines.push(line); + continue; + } + const lineWithMargins = leftMargin + line + rightMargin; + if (bgFn) { + contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn)); + } else { + const visibleLen = visibleWidth(lineWithMargins); + const paddingNeeded = Math.max(0, width - visibleLen); + contentLines.push(lineWithMargins + " ".repeat(paddingNeeded)); + } + } + const emptyLine = " ".repeat(width); + const emptyLines = []; + for (let i = 0; i < this.paddingY; i++) { + const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine; + emptyLines.push(line); + } + const result = emptyLines.concat(contentLines, emptyLines); + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + return result.length > 0 ? result : [""]; + } + /** + * Apply default text style to a string. + * This is the base styling applied to all text content. + * NOTE: Background color is NOT applied here - it's applied at the padding stage + * to ensure it extends to the full line width. + */ + applyDefaultStyle(text) { + if (!this.defaultTextStyle) { + return text; + } + let styled = text; + if (this.defaultTextStyle.color) { + styled = this.defaultTextStyle.color(styled); + } + if (this.defaultTextStyle.bold) { + styled = this.theme.bold(styled); + } + if (this.defaultTextStyle.italic) { + styled = this.theme.italic(styled); + } + if (this.defaultTextStyle.strikethrough) { + styled = this.theme.strikethrough(styled); + } + if (this.defaultTextStyle.underline) { + styled = this.theme.underline(styled); + } + return styled; + } + getDefaultStylePrefix() { + if (!this.defaultTextStyle) { + return ""; + } + if (this.defaultStylePrefix !== void 0) { + return this.defaultStylePrefix; + } + const sentinel = "\0"; + let styled = sentinel; + if (this.defaultTextStyle.color) { + styled = this.defaultTextStyle.color(styled); + } + if (this.defaultTextStyle.bold) { + styled = this.theme.bold(styled); + } + if (this.defaultTextStyle.italic) { + styled = this.theme.italic(styled); + } + if (this.defaultTextStyle.strikethrough) { + styled = this.theme.strikethrough(styled); + } + if (this.defaultTextStyle.underline) { + styled = this.theme.underline(styled); + } + const sentinelIndex = styled.indexOf(sentinel); + this.defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ""; + return this.defaultStylePrefix; + } + getStylePrefix(styleFn) { + const sentinel = "\0"; + const styled = styleFn(sentinel); + const sentinelIndex = styled.indexOf(sentinel); + return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ""; + } + getDefaultInlineStyleContext() { + return { + applyText: (text) => this.applyDefaultStyle(text), + stylePrefix: this.getDefaultStylePrefix() + }; + } + renderToken(token, width, nextTokenType, styleContext) { + const lines = []; + switch (token.type) { + case "heading": { + const headingLevel = token.depth; + const headingPrefix = `${"#".repeat(headingLevel)} `; + let headingStyleFn; + if (headingLevel === 1) { + headingStyleFn = (text) => this.theme.heading(this.theme.bold(this.theme.underline(text))); + } else { + headingStyleFn = (text) => this.theme.heading(this.theme.bold(text)); + } + const headingStyleContext = { + applyText: headingStyleFn, + stylePrefix: this.getStylePrefix(headingStyleFn) + }; + const headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext); + const styledHeading = headingLevel >= 3 ? headingStyleFn(headingPrefix) + headingText : headingText; + lines.push(styledHeading); + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); + } + break; + } + case "paragraph": { + const paragraphText = this.renderInlineTokens(token.tokens || [], styleContext); + lines.push(paragraphText); + if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") { + lines.push(""); + } + break; + } + case "text": + lines.push(this.renderInlineTokens([token], styleContext)); + break; + case "latexBlock": { + const latexToken = token; + const rendered = !latexToken.pending && this.options.renderLatex !== false ? renderLatex(latexToken.text, { display: true }) ?? latexToken.raw.trim() : latexToken.raw.trim(); + for (const line of rendered.split("\n")) { + lines.push(this.applyDefaultStyle(line)); + } + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); + } + break; + } + case "code": { + const indent = this.theme.codeBlockIndent ?? " "; + lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); + if (this.theme.highlightCode) { + const highlightedLines = this.theme.highlightCode(token.text, token.lang); + for (const hlLine of highlightedLines) { + lines.push(`${indent}${hlLine}`); + } + } else { + const codeLines = token.text.split("\n"); + for (const codeLine of codeLines) { + lines.push(`${indent}${this.theme.codeBlock(codeLine)}`); + } + } + lines.push(this.theme.codeBlockBorder("```")); + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); + } + break; + } + case "list": { + const listLines = this.renderList(token, 0, width, styleContext); + lines.push(...listLines); + break; + } + case "table": { + const tableLines = this.renderTable(token, width, nextTokenType, styleContext); + lines.push(...tableLines); + break; + } + case "blockquote": { + const quoteStyle = (text) => this.theme.quote(this.theme.italic(text)); + const quoteStylePrefix = this.getStylePrefix(quoteStyle); + const applyQuoteStyle = (line) => { + if (!quoteStylePrefix) { + return quoteStyle(line); + } + const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1B[0m${quoteStylePrefix}`); + return quoteStyle(lineWithReappliedStyle); + }; + const quoteContentWidth = Math.max(1, width - 2); + const quoteInlineStyleContext = { + applyText: (text) => text, + stylePrefix: quoteStylePrefix + }; + const quoteTokens = token.tokens || []; + const renderedQuoteLines = []; + for (let i = 0; i < quoteTokens.length; i++) { + const quoteToken = quoteTokens[i]; + const nextQuoteToken = quoteTokens[i + 1]; + renderedQuoteLines.push( + ...this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext) + ); + } + while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") { + renderedQuoteLines.pop(); + } + for (const quoteLine of renderedQuoteLines) { + const styledLine = applyQuoteStyle(quoteLine); + const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth); + for (const wrappedLine of wrappedLines) { + lines.push(this.theme.quoteBorder("\u2502 ") + wrappedLine); + } + } + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); + } + break; + } + case "hr": + lines.push(this.theme.hr("\u2500".repeat(Math.min(width, 80)))); + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); + } + break; + case "html": + if ("raw" in token && typeof token.raw === "string") { + lines.push(this.applyDefaultStyle(token.raw.trim())); + } + break; + case "space": + lines.push(""); + break; + default: + if ("text" in token && typeof token.text === "string") { + lines.push(token.text); + } + } + return lines; + } + renderInlineTokens(tokens, styleContext) { + let result = ""; + const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext(); + const { applyText, stylePrefix } = resolvedStyleContext; + const applyTextWithNewlines = (text) => { + const segments = text.split("\n"); + return segments.map((segment) => applyText(segment)).join("\n"); + }; + for (const token of tokens) { + switch (token.type) { + case "latex": { + const latexToken = token; + const rendered = !latexToken.pending && this.options.renderLatex !== false ? renderLatex(latexToken.text) ?? latexToken.raw : latexToken.raw; + result += applyTextWithNewlines(rendered); + break; + } + case "escape": + result += applyTextWithNewlines(this.options.preserveBackslashEscapes ? token.raw : token.text); + break; + case "text": + if (token.tokens && token.tokens.length > 0) { + result += this.renderInlineTokens(token.tokens, resolvedStyleContext); + } else { + result += applyTextWithNewlines(token.text); + } + break; + case "paragraph": + result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + break; + case "strong": { + const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.bold(boldContent) + stylePrefix; + break; + } + case "em": { + const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.italic(italicContent) + stylePrefix; + break; + } + case "codespan": + result += this.theme.code(token.text) + stylePrefix; + break; + case "link": { + const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + const styledLink = this.theme.link(this.theme.underline(linkText)); + if (getCapabilities().hyperlinks) { + result += hyperlink(styledLink, token.href) + stylePrefix; + } else { + const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href; + if (token.text === token.href || token.text === hrefForComparison) { + result += styledLink + stylePrefix; + } else { + result += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix; + } + } + break; + } + case "br": + result += "\n"; + break; + case "del": { + const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.strikethrough(delContent) + stylePrefix; + break; + } + case "html": + if ("raw" in token && typeof token.raw === "string") { + result += applyTextWithNewlines(token.raw); + } + break; + default: + if ("text" in token && typeof token.text === "string") { + result += applyTextWithNewlines(token.text); + } + } + } + while (stylePrefix && result.endsWith(stylePrefix)) { + result = result.slice(0, -stylePrefix.length); + } + return result; + } + getOrderedListMarker(item) { + const match = /^(?: {0,3})(\d{1,9}[.)])[ \t]+/.exec(item.raw); + return match ? `${match[1]} ` : void 0; + } + getUnorderedListMarker(item) { + const match = /^(?: {0,3})([-+*])(?:[ \t]+|(?=\r?\n|$))/.exec(item.raw); + return match ? `${match[1]} ` : void 0; + } + /** + * Render a list with proper nesting support + */ + renderList(token, depth, width, styleContext) { + const lines = []; + const indent = " ".repeat(depth); + const startNumber = typeof token.start === "number" ? token.start : 1; + for (let i = 0; i < token.items.length; i++) { + const item = token.items[i]; + const isLastItem = i === token.items.length - 1; + const bullet = token.ordered ? this.options.preserveOrderedListMarkers ? this.getOrderedListMarker(item) ?? `${startNumber + i}. ` : `${startNumber + i}. ` : this.options.preserveOrderedListMarkers ? this.getUnorderedListMarker(item) ?? "- " : "- "; + const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : ""; + const marker = bullet + taskMarker; + const firstPrefix = indent + this.theme.listBullet(marker); + const continuationPrefix = indent + " ".repeat(visibleWidth(marker)); + const itemWidth = Math.max(1, width - visibleWidth(firstPrefix)); + let renderedAnyLine = false; + for (const itemToken of item.tokens) { + if (itemToken.type === "list") { + lines.push(...this.renderList(itemToken, depth + 1, width, styleContext)); + renderedAnyLine = true; + continue; + } + const itemLines = this.renderToken(itemToken, itemWidth, void 0, styleContext); + for (const line of itemLines) { + for (const wrappedLine of wrapTextWithAnsi(line, itemWidth)) { + const linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix; + lines.push(linePrefix + wrappedLine); + renderedAnyLine = true; + } + } + } + if (!renderedAnyLine) { + lines.push(firstPrefix); + } + if (token.loose && !isLastItem) { + lines.push(""); + } + } + return lines; + } + /** + * Get the visible width of the longest word in a string. + */ + getLongestWordWidth(text, maxWidth) { + const words = text.split(/\s+/).filter((word) => word.length > 0); + let longest = 0; + for (const word of words) { + longest = Math.max(longest, visibleWidth(word)); + } + if (maxWidth === void 0) { + return longest; + } + return Math.min(longest, maxWidth); + } + /** + * Wrap a table cell to fit into a column. + * + * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled + * consistently with the rest of the renderer. + */ + wrapCellText(text, maxWidth, stylePrefix = "") { + const lines = wrapTextWithAnsi(text, Math.max(1, maxWidth)); + return lines.map((line, index) => { + const styleReset = index < lines.length - 1 ? "\x1B[22;23;24;25;27;28;29;39m" : ""; + return `${line}${styleReset}${stylePrefix}`; + }); + } + /** + * Render a table with width-aware cell wrapping. + * Cells that don't fit are wrapped to multiple lines. + */ + renderTable(token, availableWidth, nextTokenType, styleContext) { + const lines = []; + const numCols = token.header.length; + if (numCols === 0) { + return lines; + } + const borderOverhead = 3 * numCols + 1; + const availableForCells = availableWidth - borderOverhead; + if (availableForCells < numCols) { + const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : []; + if (nextTokenType && nextTokenType !== "space") { + fallbackLines.push(""); + } + return fallbackLines; + } + const maxUnbrokenWordWidth = 30; + const naturalWidths = []; + const minWordWidths = []; + for (let i = 0; i < numCols; i++) { + const headerText = this.renderInlineTokens(token.header[i].tokens || [], styleContext); + naturalWidths[i] = visibleWidth(headerText); + minWordWidths[i] = Math.max(1, this.getLongestWordWidth(headerText, maxUnbrokenWordWidth)); + } + for (const row of token.rows) { + for (let i = 0; i < row.length; i++) { + const cellText = this.renderInlineTokens(row[i].tokens || [], styleContext); + naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText)); + minWordWidths[i] = Math.max( + minWordWidths[i] || 1, + this.getLongestWordWidth(cellText, maxUnbrokenWordWidth) + ); + } + } + let minColumnWidths = minWordWidths; + let minCellsWidth = minColumnWidths.reduce((a, b2) => a + b2, 0); + if (minCellsWidth > availableForCells) { + minColumnWidths = new Array(numCols).fill(1); + const remaining = availableForCells - numCols; + if (remaining > 0) { + const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0); + const growth = minWordWidths.map((width) => { + const weight = Math.max(0, width - 1); + return totalWeight > 0 ? Math.floor(weight / totalWeight * remaining) : 0; + }); + for (let i = 0; i < numCols; i++) { + minColumnWidths[i] += growth[i] ?? 0; + } + const allocated = growth.reduce((total, width) => total + width, 0); + let leftover = remaining - allocated; + for (let i = 0; leftover > 0 && i < numCols; i++) { + minColumnWidths[i]++; + leftover--; + } + } + minCellsWidth = minColumnWidths.reduce((a, b2) => a + b2, 0); + } + const totalNaturalWidth = naturalWidths.reduce((a, b2) => a + b2, 0) + borderOverhead; + let columnWidths; + if (totalNaturalWidth <= availableWidth) { + columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index])); + } else { + const totalGrowPotential = naturalWidths.reduce((total, width, index) => { + return total + Math.max(0, width - minColumnWidths[index]); + }, 0); + const extraWidth = Math.max(0, availableForCells - minCellsWidth); + columnWidths = minColumnWidths.map((minWidth, index) => { + const naturalWidth = naturalWidths[index]; + const minWidthDelta = Math.max(0, naturalWidth - minWidth); + let grow = 0; + if (totalGrowPotential > 0) { + grow = Math.floor(minWidthDelta / totalGrowPotential * extraWidth); + } + return minWidth + grow; + }); + const allocated = columnWidths.reduce((a, b2) => a + b2, 0); + let remaining = availableForCells - allocated; + while (remaining > 0) { + let grew = false; + for (let i = 0; i < numCols && remaining > 0; i++) { + if (columnWidths[i] < naturalWidths[i]) { + columnWidths[i]++; + remaining--; + grew = true; + } + } + if (!grew) { + break; + } + } + } + const topBorderCells = columnWidths.map((w2) => "\u2500".repeat(w2)); + lines.push(`\u250C\u2500${topBorderCells.join("\u2500\u252C\u2500")}\u2500\u2510`); + const headerCellLines = token.header.map((cell, i) => { + const text = this.renderInlineTokens(cell.tokens || [], styleContext); + return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix); + }); + const headerLineCount = Math.max(...headerCellLines.map((c) => c.length)); + for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) { + const rowParts = headerCellLines.map((cellLines, colIdx) => { + const text = cellLines[lineIdx] || ""; + const padded = text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text))); + return this.theme.bold(padded); + }); + lines.push(`\u2502 ${rowParts.join(" \u2502 ")} \u2502`); + } + const separatorCells = columnWidths.map((w2) => "\u2500".repeat(w2)); + const separatorLine = `\u251C\u2500${separatorCells.join("\u2500\u253C\u2500")}\u2500\u2524`; + lines.push(separatorLine); + for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) { + const row = token.rows[rowIndex]; + const rowCellLines = row.map((cell, i) => { + const text = this.renderInlineTokens(cell.tokens || [], styleContext); + return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix); + }); + const rowLineCount = Math.max(...rowCellLines.map((c) => c.length)); + for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) { + const rowParts = rowCellLines.map((cellLines, colIdx) => { + const text = cellLines[lineIdx] || ""; + return text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text))); + }); + lines.push(`\u2502 ${rowParts.join(" \u2502 ")} \u2502`); + } + if (rowIndex < token.rows.length - 1) { + lines.push(separatorLine); + } + } + const bottomBorderCells = columnWidths.map((w2) => "\u2500".repeat(w2)); + lines.push(`\u2514\u2500${bottomBorderCells.join("\u2500\u2534\u2500")}\u2500\u2518`); + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); + } + return lines; + } + }; + + // packages/tui/bundle-bench.ts + var sty = (n) => (t) => `\x1B[${n}m${t}\x1B[0m`; + function bench(nPoints, count, reps) { + const theme = { + heading: sty(36), + bold: sty(1), + italic: sty(3), + code: sty(33), + codeBlock: sty(33), + codeBlockBorder: sty(33), + quote: sty(3), + quoteBorder: sty(3), + hr: sty(3), + listBullet: sty(1), + link: sty(34), + linkUrl: sty(34), + strikethrough: sty(3), + underline: sty(1) + }; + let text = "## Section\n\nHere is an explanation with **bold** and `inline code` plus a [link](http://x).\n\n"; + for (let i = 0; i < nPoints; i++) text += `- point ${i} with some trailing prose that wraps at narrow widths +`; + text += "\n```ts\nconst x = 1;\n```\n"; + const comps = Array.from({ length: count }, () => new Markdown(text, 0, 0, theme)); + for (let i = 0; i < 20; i++) comps[0].render(40 + i % 20); + const t0 = Date.now(); + for (let r = 0; r < reps; r++) for (const c of comps) c.render(40 + r % 60); + return (Date.now() - t0) / reps; + } + return __toCommonJS(bundle_bench_exports); +})(); + +globalThis.__bench = __m.bench; \ No newline at end of file diff --git a/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/membrane.ts b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/membrane.ts new file mode 100644 index 000000000..d8a2f211e --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/membrane.ts @@ -0,0 +1,197 @@ +import ivm from "isolated-vm"; + +/** + * A membrane between the host realm and an isolated-vm isolate. + * + * ── The safety argument, which is structural, not a convention ────────────── + * + * isolated-vm CAN be used unsafely. The documented footgun is handing the guest + * a live handle to a host object — via `derefInto()`, or by returning a + * `Reference` from a host call — after which the guest can walk that object's + * prototype chain into the host realm. + * + * This membrane makes that impossible by construction rather than by care: + * + * 1. `encode()` is the ONLY way a host value reaches the guest, and it is + * `JSON.stringify` with a replacer. Its output is a string. A string + * cannot carry a reference. + * 2. Host callables do not cross. They are replaced by an integer id into + * `hostTable`, which lives entirely host-side. The guest receives a number. + * 3. Exactly ONE `Reference` is ever given to the guest — `__invokeRef`, the + * single call-in point — and its `deref()` throws cross-isolate ("Cannot + * dereference this from current isolate"). Its `derefInto()` yields an + * inert `Dereference` marker: not callable, no host globals. + * 4. `derefInto()` is used exactly once, on the guest's OWN global. No host + * object is ever an argument to it. + * + * The consequence: the guest's entire view of the host is `{ number, string }`. + * There is no object graph to walk, so there is nothing to walk it into. This is + * the same property the QuickJS membrane has, and it is why the choice between + * them is about performance and packaging rather than about safety. + * + * Verified by `src/escape-audit.ts`. + */ +export async function createCompartment({ memoryLimit = 256 } = {}) { + const isolate = new ivm.Isolate({ memoryLimit }); + const ctx = await isolate.createContext(); + await ctx.global.set("global", ctx.global.derefInto()); // guest's own global only + + const hostTable = new Map(); + let nextHostId = 1; + const exposeHost = (o) => { const id = nextHostId++; hostTable.set(id, o); return id; }; + + const guestProxies = new Map(); + const finalizer = new FinalizationRegistry((id) => { + guestProxies.delete(id); + if (!released) ctx.evalSync(`delete __refs[${JSON.stringify(String(id))}]`); + }); + let released = false; + + // Async: `applySync` is synchronous, so a host Promise cannot itself cross. + // Instead the host returns a token, the guest builds a real Promise against + // it, and the host settles that Promise by calling back in when it resolves. + let nextPending = 1; + function encodePending(promise) { + const token = nextPending++; + promise.then( + (v) => settle(token, true, v === undefined ? "" : encode(v)), + (e) => settle(token, false, JSON.stringify(String(e?.message ?? e))), + ); + return JSON.stringify({ __pending: token }); + } + function settle(token, ok, json) { + if (released) return; + ctx.evalSync(`__settle(${token}, ${ok}, ${JSON.stringify(json)})`); + } + + function encode(v) { + if (v && typeof v.then === "function") return encodePending(v); + return JSON.stringify(v, (_k, val) => { + if (typeof val === "function") return { __ref: exposeHost(val) }; + if (val && typeof val === "object" && !Array.isArray(val) && hasMethods(val)) + return { __obj: exposeHost(val), keys: methodNames(val) }; + return val; + }); + } + const decode = (json) => JSON.parse(json, (_k, v) => reviveGuest(v)); + + function intern(id, build) { + const hit = guestProxies.get(id)?.deref(); + if (hit !== undefined) return hit; + const p = build(); + guestProxies.set(id, new WeakRef(p)); + finalizer.register(p, id); + return p; + } + function reviveGuest(v) { + if (!v || typeof v !== "object") return v; + if (typeof v.__guestRef === "number") { const r = v.__guestRef; return intern(r, () => (...a) => callGuestRef(r, a)); } + if (typeof v.__guestObj === "number") { + const r = v.__guestObj; + return intern(r, () => { const o = {}; for (const k of v.keys) o[k] = (...a) => callGuestRef(r, [k, a]); return o; }); + } + return v; + } + + function callGuestRef(ref, args, timeout = 5000) { + const out = ctx.evalSync( + `__refs[${JSON.stringify(String(ref))}](${JSON.stringify(JSON.stringify(args))})`, + { timeout }, + ); + return out === "" || out === undefined ? undefined : decode(out); + } + + // The single host entry point. A Reference, applied synchronously with copied + // arguments — no host object graph is ever exposed. + const invoke = new ivm.Reference((idJson, pathJson, argsJson) => { + const path = JSON.parse(pathJson); + let target = hostTable.get(JSON.parse(idJson)); + for (let i = 0; i < path.length - 1; i++) target = target[path[i]]; + const result = path.length ? target[path[path.length - 1]](...decode(argsJson)) : target(...decode(argsJson)); + return result === undefined ? "" : encode(result); + }); + await ctx.global.set("__invokeRef", invoke); + + await ctx.eval(` + globalThis.__refs = {}; globalThis.__nextRef = 1; + globalThis.__outgoing = new WeakMap(); + globalThis.__methodNames = function (o) { + var out = [], p = o; + while (p && p !== Object.prototype) { + for (const k of Object.getOwnPropertyNames(p)) + if (k !== "constructor" && typeof o[k] === "function" && out.indexOf(k) < 0) out.push(k); + p = Object.getPrototypeOf(p); + } + return out; + }; + globalThis.__mkRef = function (fn) { + var prior = __outgoing.get(fn); if (prior !== undefined) return { __guestRef: prior }; + var id = __nextRef++; __outgoing.set(fn, id); + __refs[String(id)] = function (j) { var r = fn.apply(null, JSON.parse(j)); return r === undefined ? "" : __encode(r); }; + return { __guestRef: id }; + }; + globalThis.__mkObjRef = function (obj) { + var prior = __outgoing.get(obj); + if (prior !== undefined) return { __guestObj: prior, keys: __methodNames(obj) }; + var id = __nextRef++; __outgoing.set(obj, id); + __refs[String(id)] = function (j) { var c = JSON.parse(j); var r = obj[c[0]].apply(obj, c[1]); return r === undefined ? "" : __encode(r); }; + return { __guestObj: id, keys: __methodNames(obj) }; + }; + globalThis.__encode = function (v) { + return JSON.stringify(v, function (k, val) { + if (typeof val === "function") return __mkRef(val); + if (val && typeof val === "object" && !Array.isArray(val) && __methodNames(val).length) return __mkObjRef(val); + return val; + }); + }; + globalThis.__pending = {}; + globalThis.__settle = function (token, ok, json) { + var d = __pending[token]; + if (!d) return; + delete __pending[token]; + var value = json === "" ? undefined : JSON.parse(json); + ok ? d.resolve(__revive(value)) : d.reject(new Error(value)); + }; + globalThis.__revive = function (v) { + if (v && typeof v === "object" && typeof v.__pending === "number") { + var token = v.__pending; + return new Promise(function (res, rej) { __pending[token] = { resolve: res, reject: rej }; }); + } + if (v && typeof v === "object") { + if (typeof v.__ref === "number") return __wrap(v.__ref, []); + if (typeof v.__obj === "number") return __wrap(v.__obj, []); + for (var k in v) v[k] = __revive(v[k]); + } + return v; + }; + globalThis.__wrap = function (id, path) { + return new Proxy(function () {}, { + get: function (t, prop) { + if (typeof prop !== "string") return undefined; + if (prop === "then" || prop === "toJSON" || prop === "inspect") return undefined; + if (prop === "length" || prop === "name") return t[prop]; + return __wrap(id, path.concat([prop])); + }, + apply: function (_t, _this, args) { + var out = __invokeRef.applySync(undefined, [JSON.stringify(id), JSON.stringify(path), __encode(args)]); + return out === "" ? undefined : __revive(JSON.parse(out)); + }, + }); + }; + `); + + return { + async endow(name, obj) { await ctx.eval(`globalThis[${JSON.stringify(name)}] = __wrap(${exposeHost(obj)}, []);`); }, + load: (src, timeout = 30000) => ctx.evalSync(src, { timeout }), + debugRefCount: () => ({ hostTable: hostTable.size, guestRefs: ctx.evalSync(`Object.keys(__refs).length`) }), + dispose() { + released = true; + // An isolate that hit its memory limit is already disposed, and calling + // dispose() again throws "Isolate is already disposed". + try { isolate.dispose(); } catch { /* already gone */ } + }, + }; +} +const RESERVED = new Set(["constructor"]); +const methodNames = (o) => { const out = []; for (let p = o; p && p !== Object.prototype; p = Object.getPrototypeOf(p)) for (const k of Object.getOwnPropertyNames(p)) if (!RESERVED.has(k) && typeof o[k] === "function" && !out.includes(k)) out.push(k); return out; }; +const hasMethods = (o) => methodNames(o).length > 0; diff --git a/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/property-test.ts b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/property-test.ts new file mode 100644 index 000000000..a9574edaf --- /dev/null +++ b/packages/agent/docs/mobile-handoff/02-plugins/02-sandbox/src/property-test.ts @@ -0,0 +1,137 @@ +/** + * Property tests for the membrane. + * + * Every bug found by hand while building it failed SILENTLY — wrong output, no + * throw. That is the worst possible failure mode for a boundary, and the reason + * these exist. Run with: node --experimental-strip-types --expose-gc property-test.ts + */ +import { createCompartment } from "./membrane.ts"; + +let pass = 0; +const failures: string[] = []; +const check = (name: string, ok: boolean, detail = "") => { + if (ok) pass++; + else failures.push(`${name}${detail ? ` — ${detail}` : ""}`); +}; + +/** Random JsonValue. The membrane claims to round-trip exactly these. */ +function randomJson(depth = 0): unknown { + const r = Math.random(); + if (depth > 3 || r < 0.25) return Math.floor(Math.random() * 1000) - 500; + if (r < 0.35) return Math.random() * 1e6 - 5e5; + if (r < 0.45) return Math.random() < 0.5; + if (r < 0.5) return null; + if (r < 0.65) { + const alphabet = ['a', 'z', '0', ' ', '"', '\\', '\n', '\u00e9', '\u4e2d', '\u{1f600}']; + let s = ""; + const n = Math.floor(Math.random() * 12); + for (let i = 0; i < n; i++) s += alphabet[Math.floor(Math.random() * alphabet.length)]; + return s; + } + if (r < 0.8) return Array.from({ length: Math.floor(Math.random() * 5) }, () => randomJson(depth + 1)); + const o: Record = {}; + for (const k of ["a", "b", "c", "d", "__proto__x", "0", "length"]) { + if (Math.random() < 0.5) o[k] = randomJson(depth + 1); + } + return o; +} + +const c = await createCompartment(); + +// ── 1. Value round-trip, both directions ────────────────────────────────── +const echoed: unknown[] = []; +await c.endow("__h", { echo: (v: unknown) => v, sink: (v: unknown) => { echoed.push(v); } }); +c.load(`globalThis.roundTrip = function (v) { return __h.echo(v); }; + globalThis.sendBack = function (v) { __h.sink(v); };`); + +for (let i = 0; i < 400; i++) { + const value = randomJson(); + const json = JSON.stringify(value); + if (json === undefined) continue; + // host -> guest -> host + c.load(`globalThis.__t = roundTrip(${json});`); + c.load(`sendBack(__t);`); + const got = echoed.pop(); + check("value round-trip", JSON.stringify(got) === json, `${json} became ${JSON.stringify(got)}`); +} + +// ── 2. Identity: the same guest object must map to the same host proxy ──── +const seen: unknown[] = []; +await c.endow("__id", { take: (o: unknown) => { seen.push(o); } }); +c.load(`globalThis.shared = { m: function () { return 1; } }; + globalThis.sendShared = function () { __id.take(shared); __id.take(shared); };`); +c.load(`sendShared();`); +check("identity preserved across crossings", seen[0] === seen[1]); +c.load(`sendShared();`); +check("identity stable across separate calls", seen[0] === seen[2]); + +// ── 3. Callables survive and remain callable ────────────────────────────── +let held: any; +await c.endow("__f", { keep: (fn: unknown) => { held = fn; } }); +c.load(`__f.keep(function (a, b) { return a + b; });`); +check("guest function callable from host", held(2, 3) === 5, `got ${held?.(2, 3)}`); + +// ── 4. Errors cross with name and message ───────────────────────────────── +c.load(`__f.keep(function () { throw new TypeError("boom"); });`); +let msg = ""; +try { held(); } catch (e: any) { msg = e.message; } +check("guest throw reaches host", msg.includes("boom"), `got "${msg}"`); + +// ── 5. Reference release: crossings must not leak ───────────────────────── +await c.endow("__l", { drop: (_fn: unknown) => {} }); +c.load(`globalThis.churn = function () { for (var i = 0; i < 500; i++) __l.drop(function () { return i; }); };`); +c.load(`churn();`); +const beforeGc = c.debugRefCount().guestRefs; +globalThis.gc?.(); +await new Promise((r) => setTimeout(r, 60)); +globalThis.gc?.(); +await new Promise((r) => setTimeout(r, 60)); +const afterGc = c.debugRefCount().guestRefs; +check("dropped references are released", afterGc < beforeGc / 2, `${beforeGc} -> ${afterGc}`); + +// ── 5b. Prototype pollution must not cross ──────────────────────────────── +// JSON.parse is safe (it makes __proto__ an own property), but a membrane that +// assigns parsed keys onto a fresh object is not. +const before = ({} as any).polluted; +c.load(`__h.sink(JSON.parse('{"__proto__":{"polluted":true}}'));`); +check("guest cannot pollute host Object.prototype", ({} as any).polluted === before); +await c.endow("__p", { give: () => JSON.parse('{"__proto__":{"hostPolluted":true}}') }); +await c.endow("__q", { show: (v: string) => check("host cannot pollute guest prototype", v === "undefined", v) }); +c.load(`var got = __p.give(); __q.show(String(({}).hostPolluted));`); + +// ── 6. Interrupt budget, and the VM survives it ─────────────────────────── +// Direct: the budget on this call bounds it. +{ + const t0 = Date.now(); + try { c.load(`while (true) {}`, 100); } catch { /* expected */ } + check("runaway guest code is interrupted", Date.now() - t0 < 600, `took ${Date.now() - t0}ms`); +} +// Re-entrant: a guest function invoked BY THE HOST gets `callGuestRef`'s own +// budget, not the outer one. Budgets do not nest — see README "Limitations". +c.load(`globalThis.spin = function () { while (true) {} };`); +await c.endow("__s", { run: (fn: any) => { try { fn(); return "returned"; } catch { return "threw"; } } }); +{ + const t0 = Date.now(); + try { c.load(`globalThis.__x = __s.run(spin);`, 50); } catch { /* expected */ } + const took = Date.now() - t0; + check("re-entrant call uses its OWN budget, not the outer one", took > 1000, + `took ${took}ms — if this is now <1s the nesting behaviour changed`); +} +c.load(`globalThis.__alive = 1 + 1;`); +check("VM usable after an interrupt", true); + +// ── 7. Async, and clean teardown with a pending promise ─────────────────── +await c.endow("__a", { never: () => new Promise(() => {}), soon: () => Promise.resolve("v") }); +c.load(`globalThis.out = null; __a.soon().then(function (v) { out = v; });`); +await new Promise((r) => setTimeout(r, 30)); +await c.endow("__o", { show: (v: string) => check("async host result reaches guest", v === '"v"', v) }); +c.load(`__o.show(JSON.stringify(out));`); +c.load(`__a.never().then(function () {}, function () {});`); // deliberately unsettled + +let disposeOk = true; +try { c.dispose(); } catch { disposeOk = false; } +check("dispose with a pending promise does not crash", disposeOk); + +console.log(`\n${pass} passed, ${failures.length} failed`); +for (const f of failures.slice(0, 10)) console.log(" FAIL", f); +process.exit(failures.length === 0 ? 0 : 1); diff --git a/packages/agent/docs/mobile-handoff/README.md b/packages/agent/docs/mobile-handoff/README.md new file mode 100644 index 000000000..79d144339 --- /dev/null +++ b/packages/agent/docs/mobile-handoff/README.md @@ -0,0 +1,90 @@ +# pi — design handoff + +Work in numbered order. Each unit is self-contained and independently testable; +later units consume earlier ones. + +``` +01-harness/ + 01-delta/ op vocabulary, tracker, applier, codec [CODE + 60 tests] + 02-scopes/ storage scopes, list tags, JSONL encoding [SPEC + type check] + 03-execenv/ bounded Shell output, capture, spill [CODE + 29 tests] + 04-tool-output/ the ToolOutput sink [SPEC ONLY] + 05-assistant-output/ assistant partials, symmetric with 04 [SPEC ONLY] +02-plugins/ + 01-facets/ the facet system [SPEC ONLY] + 02-sandbox/ isolated-vm membrane [CODE + 412 tests] +``` + +Base everything on a clean checkout of `origin/dev`. + +## Read this first + +**Three units ship working code. Four are specifications.** The table above says +which. Do not assume a doc describes something that exists. + +**`01-delta/FINDINGS.md` is not optional.** The shipped delta implementation has a +known unbounded-memory defect (D1) and a known 94.5%-of-runtime hotspot (D2), +neither of which is fixed in the code. `04-tool-output` depends on D1 being fixed +first. Read it before touching `delta-impl.ts`. + +**If a doc and the code disagree, the code wins** — fix the doc and say so in the +commit. + +**Port the tests before the implementation.** Each group's comment explains the +failure it guards against, and several of those failures are silent: wrong output, +no exception. + +**Benchmark with `node --experimental-strip-types`, never through a transpiler.** +Measuring this module through `tsx` inflates results 2.6x. `FINDINGS.md` D5 lists +five more measurement traps, each of which produced a confident wrong conclusion. + +## Unit status + +| unit | ships | state | +| --- | --- | --- | +| **01-delta** | impl, 60 tests, bench, examples, spec | works; **two known defects, see FINDINGS.md** | +| **02-scopes** | spec + `scopes.variance.ts` | spec aligned to code; not implemented. The variance check compiles. | +| **03-execenv** | impl, rewritten bash, 2 diffs, spec | ran green (29 tests). **Its checkpoint policy is wrong** — see `04-tool-output/rate-limiting.md` §5 | +| **04-tool-output** | spec + design notes | **not built.** The piece every measurement of the op encoding depends on | +| **05-assistant-output** | spec | not built. Same shape as 04; do it after | +| **02-plugins/01-facets** | spec, ~1800 lines | not built. §14 rewritten to match the sandbox PoC | +| **02-plugins/02-sandbox** | working PoC, 412 assertions | `npm install && npm run audit` | + +## Suggested order + +1. **`01-delta` D1** — interleaving. Unblocks everything in 04. +2. **`01-delta` D2** — the explicit append API. 94.5% of tracker time. +3. **`03-execenv`** — land the shipped code, minus the checkpoint policy and the + two redundant knobs (`rate-limiting.md` §5). +4. **`02-scopes`** — the compiler finds the call sites for you once `Write` is + invariant. +5. **`04-tool-output`** — decide the cadence question first (`rate-limiting.md` §4.3 + lists what is undecided), then build. +6. **05**, then **02-plugins**. + +## Live bugs on `origin/dev`, independent of this design + +- `drive/tools.ts:257` — `clearReplayCheckpoint` deletes `pendingToolOutput` before + re-executing a replay-safe tool. Memos exist so a replayed tool skips work, and + skipped work emits nothing, so output for memoised work is lost today. Seed from + it instead (`harness-tools.md` §7.4). +- `runtime/progress.ts:44` — `commitWrite(item)` captures the value at call time + and writes fire-and-forget, so an older checkpoint can land after a newer one. +- memo and checkpoint are two transactions (`drive/tools.ts:112` vs + `progress.ts:44`). They must be one (`harness-tools.md` §7.5). + +**Expected test churn:** nine tests assert the old cleanup write set and will fail +once `retireScope` replaces the per-address deletes. That is the change landing. + +## Environment + +- Node 22+ for every shipped `.ts` file. They run under + `node --experimental-strip-types` with no build step and no dependencies. +- In the pi repo, tests run from the package: + `cd packages/agent && npx vitest run --config vitest.harness.config.ts`. + The root vitest config does **not** alias `@earendil-works/pi-ai`; the + per-package harness config does. +- Typecheck with `npx tsgo --noEmit` from the repo root. **Baseline is ~788 + pre-existing errors**, almost all in `packages/ai/test`. Count only: + `grep "error TS" | grep -E "packages/(agent|session-backends)/src"`. +- `packages/ai` cannot be built offline — model data is fetched at build time.