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