Awareness — the producer observes, kolu remembers
The converged awareness architecture (PR #1621), simplified across a long design review. One complect underlies every hard thing in R9 — the sensor engine DERIVES awareness by mutating a host record it also reads back as memory. The fix splits observing from remembering. A host PRODUCER emits one TerminalSnapshot = {cwd, git, pr, agent, foreground} — exactly what it can re-observe, and nothing it cannot. kolu stores TerminalState = that last-seen TerminalSnapshot plus TWO facts a host can never observe and only kolu may write — lastActivityAt (recency, on kolu's clock) and lastAgentCommand (the line to replay). The "fold" is last-write-wins for the five snapshot fields plus those two rules. Restore resumes an agent only if it was still live at sleep/restart (quit-to-shell wakes to a bare shell), which collapses agentSession into the frozen agent. Lie-when-dead is handled by the active/sleeping discriminant, not by dropping fields. This note pins the types, the API, and the flows.
The de-entanglement that makes R9 mostly evaporate. Every hard thing in the finale — the two assemblers, the createPulam boundary, the fold-clobber — traces to one complect: the sensor engine derives awareness by mutating a host record it also reads back as memory. Split observing from remembering and it dissolves. This note pins the types, the API, and the flows so the boundary can be evaluated before any code; every claim cites the code it restructures.
Landed (S1 + S2 → R9.0): #1626, then hardened by a 4-reviewer architecture-first-principles + perfection-review gauntlet. As shipped, the restore target is a fold-produced discriminated value on the authored record — RestoreTarget = none | { exact, command, agent } | { legacyMostRecent, command } (restoreTargetOf, consumed by resumeFormFor) — rather than a bare optional identity, so an absent field can never be misread as “resume most-recent”. Quit-to-shell → none → bare shell by construction for new records (the strict #1492 behavior); migrated pre-1.29 records keep their old most-recent resume as a named legacyMostRecent. Deferred (tracked, not shipped): the active drain-at-sleep — beginSleep freezes the last fold-written target and does not drain a final settle, so a launch-then-sleep inside the agent settle window freezes a stale none and wakes to a false bare shell (narrow, self-corrects on re-launch). The recency frame phase is now value-based (compare the re-resolved identity to the saved target), not the original wall-clock window. Follow-up fix (#1726): making recency identity-only froze it for a stable session — a terminal running one week-old session forever never changed identity, so its “last activity” stopped moving even while it produced output. Recency now bumps on a same-identity output tick too, throttled to RECENCY_THROTTLE_MS (60s) so the ~1s agent-detail firehose can’t recreate the per-tick write noise #1626 removed; the identity bump stays and the two compose. The recencyBaseline (seeded to the survivor’s identity) is kept — it’s what keeps the adopt re-observation from counting as new activity, and, being identity- not count-based, it holds across the survivor’s multi-emit settle burst and never swallows a later genuinely-new agent launch. The throttle clock is floored at the sensor run’s start (FoldCtx.runStartedAt), so that settle burst — clustered at run start — coalesces against it instead of false-bumping the (possibly week-old) saved recency.
The knot today — observing is fused with remembering
The sensor engine does not return awareness. It takes a host AwarenessSink + a per-terminal AwarenessRecord, mutates record.meta through the sink, and reads record.meta back as its own prior state. sensors.ts:139-162 calls this the load-bearing apply-and-publish contract — a sink that publishes without mutating the record “would silently defeat every dedup/transition gate.”
So record.meta plays two roles at once:
- the engine’s prior-state for its gates — command dedup
record.meta.lastAgentCommand(sensors.ts:410), publish-if-changedagentInfoEqual(record.meta.agent, …)(sensors.ts:516), recencyshouldBumpRecencyForAgentChange(…, record.meta.lastActivityAt)(sensors.ts:519), session dedupagentSessionToPersist(record.meta.agentSession, …)(sensors.ts:526); - the host’s stored value — kolu aliases
record.meta = getTerminal(id)!.awareness(local.ts:700-704);pulampoints it at a cache (daemon.ts:209-217).
That fusion is the cause of everything downstream: two assemblers (local.ts:278 vs pulam/src/hooks.ts:39), the AwarenessSink foot-gun (mutate-before-publish or defeat the gates), and the fold-clobber class (a fresh/ephemeral derivation overwrites remembered history — the defect that killed the local-pulam-process #1614 and that the createPulam loop re-risked).
The cut: one question — can a host re-observe it?
There are not three categories. There is one question per field, and it sorts every field into exactly two places:
- Yes, a host can re-observe it → it belongs to the one value a producer emits,
TerminalSnapshot. Five fields. - No, it can’t be snapshot → it’s kolu’s to remember. Two fields.
// What a host PRODUCER emits — exactly what it can RE-OBSERVE. Local or remote, the SAME type.
type TerminalSnapshot = {
cwd: string;
git: GitInfo | null;
pr: PrResult;
agent: AgentInfo | null; // the LIVE agent right now, or null when the user is at the shell
foreground: Foreground | null; // the live foreground process (vim, …)
};
// The two facts a host CANNOT observe — recency is a clock reading; the launch line is what the
// user typed. Irrecoverable from a screen, so kolu remembers them; written by kolu's fold ALONE.
type AgentMemory = {
lastActivityAt: number; // recency — a CLOCK reading. You can't see "active 3h ago" on a screen.
lastAgentCommand?: string; // the exact launch line to replay ("claude --model sonnet"). Absent if no agent ran.
};
// kolu's stored value: the last-seen TerminalSnapshot + the two remembered facts. NESTED, not merged,
// so the half published to the snapshots collection is `current.snapshot` — structurally WITHOUT
// the two memory fields, not a runtime strip.
type TerminalState = { snapshot: TerminalSnapshot; memory: AgentMemory };
TerminalSnapshot is the keystone type: a producer — local or remote — cannot construct lastActivityAt or lastAgentCommand, so a producer’s stream (however buggy, restarted, or hostile) cannot overwrite kolu’s two remembered facts, by the compiler. The persisted/live partition (schema.ts:139-176) was too coarse; the simpler structure is observable vs not, and the only fence that must be a type is that one — the producer’s emit type is TerminalSnapshot, full stop.
S1 — the producer is memoryless
It owns transient derivation working state (the agent watcher, latestInfo, the last-emitted live mirror, currentAgent, the git→PR wire, the screen-scrape poll, the adapter registry — all re-seeded empty each start) and emits per-field observations. It takes no seed and touches no host store. The standing five build the TerminalSnapshot; commandRun is a discrete mark that feeds kolu’s lastAgentCommand memory and the recent-agent MRU.
// The agent is the one field that resolves ASYNCHRONOUSLY — the session file lands a beat after
// the mark, over a settle window (`COMMAND_RUN_RECONCILE_DELAYS_MS = [0,75,300,1000]`, `sensors.ts:486`;
// `SCREEN_SCRAPE_POLL_MS = 1000`, `:492`). So a bare `agent: null` is ambiguous — "no agent" or "not
// resolved yet?" Make the null LAWFUL (round-6 fix): a producer mid-resolution emits `unknown` (kolu
// KEEPS its last value); `{ value }` is authoritative (kolu APPLIES it, even when null). The authority
// for `{ value: null }` is shell-idle — the foreground IS the shell (`sensors.ts:432,448`), i.e. the
// session ended (`sensors.ts:640-644`); a value can't be both "agent live" and "shell idle."
type Known<T> = "unknown" | { value: T };
type TerminalEvent =
| { kind: "cwd"; cwd: string }
| { kind: "git"; git: GitInfo | null } // also drives the recentRepo MRU (kolu side)
| { kind: "pr"; pr: PrResult }
| { kind: "foreground"; foreground: Foreground | null }
| { kind: "agent"; agent: Known<AgentInfo | null> } // async-resolved → lawful unknown/snapshot, never a bare null
| { kind: "commandRun"; command: string; replayed: boolean }; // feeds lastAgentCommand + recentAgent MRU
function startSensors(
id: TerminalId,
inputs: {
pid: number;
cwd: string; // spawn-time cwd; later cwd flows via signals
signals: SensorSignals; // the four taps — UNCHANGED
readScreenText?: (tailLines: number) => Promise<string>; // pure input for screen-scrape (#905)
log: Logger;
},
emit: (o: TerminalEvent) => void,
): () => void;
replayed is the one provenance flag on an observation, and it must be a required field (a flag-less event is a compile error). It is genuine content — kaval’s tap (sensors.ts:103-106) reports whether a command mark is a replay (scrollback re-emit on restart) or a live run; the producer does not guess it. There is no fromSnapshot: “is this a re-observation or a live change” is not a property of the event — it’s kolu’s subscription-phase fact, carried on the frame (the wire), never a per-event flag a producer asserts and kolu trusts.
Deleted: the AwarenessSink interface, both makeAwarenessSink impls, the AwarenessRecord type, the apply-and-publish read-back contract — and any seed. The git→PR channel and the adapter registry were always engine-internal and stay.
S2 — kolu remembers; the fold owns the two facts and the clock
kolu folds each observation into a new TerminalState. The five snapshot fields are last-write-wins; the only decisions are the two remembered fields — stamp lastActivityAt on a live agent-identity change (kolu’s clock), and keep lastAgentCommand from the latest commandRun. A producer can write none of that. (The identity-only recency shown below froze a stable session’s recency; it now bumps on a throttled same-identity output tick too — see the follow-up fix in the status note above.)
// Liveness + clock are kolu's, passed as VALUES (not a thunk the reducer may fire):
// live — true iff this came in a DELTA frame (a snapshot re-observation is not "new activity")
// at — kolu samples its own clock ONCE at intake, before folding
type FoldCtx = { live: boolean; at: number };
function fold(cur: TerminalState, o: TerminalEvent, ctx: FoldCtx): TerminalState {
const obs = (p: Partial<TerminalSnapshot>) => ({ ...cur, snapshot: { ...cur.snapshot, ...p } });
switch (o.kind) {
case "cwd": return obs({ cwd: o.cwd });
case "git": return obs({ git: o.git });
case "pr": return obs({ pr: o.pr }); // pr is true-when-dead — persisted like git
case "foreground": return obs({ foreground: o.foreground });
case "agent": {
if (o.agent === "unknown") return cur; // not re-resolved yet → KEEP kolu's last value (no clobber)
const agent = o.agent.value; // authoritative (incl. a shell-idle null = session ended)
const next = obs({ agent }); // last-write-wins
// RECENCY (decided: identity-only): bump iff a LIVE agent-IDENTITY change — start / finish /
// new session. ctx.live (the frame phase), not a producer flag, says "live"; kolu's clock stamps it.
const idChanged = cur.snapshot.agent?.kind !== agent?.kind
|| cur.snapshot.agent?.sessionId !== agent?.sessionId;
return ctx.live && idChanged
? { ...next, memory: { ...next.memory, lastActivityAt: ctx.at } }
: next;
}
case "commandRun": // the producer emits this ONLY for a recognized agent
return cur.memory.lastAgentCommand === o.command // command, already NORMALIZED (`sensors.ts:404-409`) — a
? cur // non-agent `ls` yields no event, so it can't clobber.
: { ...cur, memory: { ...cur.memory, lastAgentCommand: o.command } }; // dedup (`sensors.ts:410`): a replay is a no-op
}
}
// kolu's host recipe. Autosave (disk) arms ONLY when a RESTORE-RELEVANT value changes — the
// five snapshot fields minus the churny ones, plus the two memory fields. Agent DETAIL (state,
// tokens, summary) and `foreground` change every ~150 ms and are re-derived on (re)spawn, so they
// never touch disk: the firehose can't reach it, because what arms is a VALUE CHANGE, not a tick.
const restoreRelevant = (a: TerminalState) => ({
cwd: a.snapshot.cwd, git: a.snapshot.git, pr: a.snapshot.pr,
agentId: a.snapshot.agent && { kind: a.snapshot.agent.kind, sessionId: a.snapshot.agent.sessionId }, // IDENTITY only
...a.memory,
});
function watch(key: HostTerminalKey, terminal): () => void {
let current = seed(terminal); // kolu seeds from its durable record
const stop = subscribeSnapshots(key, terminal, (frame) => { // frame: snapshot | delta | gap (the wire)
if (frame.phase === "gap") { current = reseed(key); return; } // a detected gap re-snapshots — never a silent diverge
const ctx: FoldCtx = { live: frame.phase === "delta", at: Date.now() }; // kolu's clock, sampled once
for (const e of frame.events) {
const before = current;
current = fold(current, e, ctx); // a NEW value; nothing mutated
if (!shallowEqual(restoreRelevant(before), restoreRelevant(current))) armAutosave(); // value change, not tick
if (e.kind === "git" && e.git) trackRecentRepo(e.git.mainRepoRoot, e.git.repoName); // MRU, kolu side
if (e.kind === "commandRun" && !e.replayed) trackRecentAgent(e.command); // replay never MRUs
}
snapshots.upsert(key, current.snapshot); // the snapshots collection = TerminalSnapshot (memory absent by type)
authored.updateMemory(key, current.memory); // the ONE memory writer (a narrowed mutator)
});
return () => { stop(); /* late events after stop are ignored — the post-teardown no-op guard */ };
}
Persistence tiers — what lives where
The types say who may write a field; they don’t yet say where it survives. Three tiers, and the disk/RAM line is — honestly — the old cache/live split wearing a new coat (round-6 confirmed it doesn’t vanish; it moves from a field-typed split to the restoreRelevant projection):
| tier | what | why |
|---|---|---|
| server disk (survives kolu restart) | the restoreRelevant projection: cwd · git · pr · agent identity { kind, sessionId } · lastActivityAt · lastAgentCommand |
the minimum to re-seed and to honor model-B restore. Agent identity only — not the full AgentInfo (no lie-when-dead state/tokens on disk; the round-5 invariant survives the merge). Autosave arms on a change to this projection, so the ~150 ms firehose never reaches disk. |
| server RAM (re-derived on (re)spawn) | the rest of snapshot: full AgentInfo detail (state, summary, tokens, taskProgress) · foreground |
lie-when-dead and high-churn. A respawned PTY re-derives it in ~1 s; losing it on crash costs nothing, so it never earns a disk write. |
| browser localStorage (per-device, never server) | client view-state — attention/unread (useViewState.ts), zoom/layout, per-device prefs (persistedPref.ts) |
a viewing posture, not a fact about the terminal. It must not round-trip through the server (it’s device-local), and the server must never treat it as awareness. |
So the simplification deletes the names cache/live but not the underlying categories — it relocates them: cache→restoreRelevant, live→RAM-only. The honest claim is “persistence is the restoreRelevant projection,” not “everything in TerminalSnapshot persists.”
The wire — observations, not whole values
The collection serving a whole AwarenessValue (surface.ts:119-124) is retired as the awareness primitive. Producers serve an ordered, framed terminalEvents stream (host-scoped, snapshot-then-deltas, subscribe-before-serialize — the attach contract, router.ts:162-169, and the streaming.md rule) of TerminalSnapshot events. The wire carries the fold’s input, not its lossy output. There is no observedAt on the wire (a clock-skew temptation) — kolu samples its own clock at intake.
The stream is framed — the frame carries provenance and order so the observation vocabulary can stay field-level (the streaming.md-prescribed shape, and the round-5 hardening fix):
type TerminalFrame =
| { phase: "snapshot"; events: TerminalEvent[] } // ctx.live = false — a re-observation, never bumps recency
| { phase: "delta"; seq: number; events: TerminalEvent[] } // ctx.live = true; seq is monotonic per subscription
| { phase: "gap"; afterSeq: number }; // a detected coalescing/overflow gap → kolu re-snapshots
Two facts the frame owns that a per-event flag must not:
- snapshot vs delta is kolu’s subscription-phase fact, not a producer boolean. Putting it on the frame lets kolu derive
ctx.livefrom the frame it received — its own knowledge — and deletesfromSnapshot. The frame decides liveness only (does this bump recency). It does not decide what a null means — round 6 showed that’s a separate question (a snapshot null could be “no agent” or “not resolved yet”), and overloading the frame phase to answer it is the unsound rule the collapse died on. The meaning of an agent null rides theKnown<>field (unknown= keep,{ value: null }= authoritative absent), never the frame. (replayedlikewise stays on thecommandRunevent — a restarted tap can replay a mark into a delta, so the frame phase can’t stand in for it;inProcessPtyHost.ts:148-163.) - a per-subscription monotonic
seq(precedent:RepoChangePulse.seq,surface.ts:58-67): kolu asserts contiguity; a hole forces agapframe → re-snapshot, instead of a silently divergent fold. A counter is not a wall clock, so it imports nothing.
kolu consumes the framed stream through one subscribeSnapshots(key, …) seam regardless of transport: locally it wraps the in-process startSensors (the initial emit burst is the snapshot frame, later emits are deltas); remotely it is the ssh stream verbatim. So the framing — not the engine — carries phase/seq, and the engine stays a pure memoryless emitter. But ctx.live is not the frame phase alone: it is (phase === "delta") AND kolu’s durable agent-identity baseline check, derived in kolu’s consumer arm. A snapshot re-observation is never live; and a delta that merely re-resolves the adopted agent — which seeds null, the resume identity riding the authored restoreTarget, not the snapshot — must not bump recency either. The framer alone can’t decide liveness; only kolu, holding the durable record, can. (Settled by the R9.2/R9.3 debate, debates/r92-r93/.)
The payoff: kolu’s fold is one function for local and remote, and reconcileRemoteSnapshot disappears. The snapshot frame meets a pre-seeded current and folds with ctx.live = false (no spurious recency bump). adoptedAwareness (local.ts:340), wake, remote-connect, and remote-reconnect are the same “seed from memory, then fold” — orphanAwareness (local.ts:368) is the degenerate zero-memory seed.
How R9 collapses
A “host” is now dial a kaval → per terminal: run the memoryless producer → serve its observation stream. kolu is the one consumer that remembers: it folds every host’s stream (localhost in-process, remotes over ssh) into a host-keyed aggregate. “Two homes” was an artifact — there is one producer per host; localhost is the degenerate (in-process) host.
| phase | was | now |
|---|---|---|
| R9·lib | share a daemon-shaped assembly (createPulam) | dissolved — producer + fold are pure leaves; each host’s loop is ~8 lines |
| R9.0 | kolu cuts over its sink/loop; clobber risk | ✓ shipped (#1626) — producer in-process, folds onto authored+snapshots; clobber-free by type |
| remote awareness | unbuilt; “the remote-only fold” | re-sequenced (debates/r92-r93/): the producer-side prep is PR-3 (framer + the terminalEvents wire, a local no-op), and F-REMOTE subscribes the remote stream → the same fold. No reconcileRemoteSnapshot. Work-list below. |
| the rest | — | PR-1 (lifecycle) · PR-2 (fs/git) prep in parallel; F-REMOTE lights the complete tile; R10 the host-picker UX — see the finale |
Remote awareness — the build (PR-3 prep + F-REMOTE consume)
R9.0 shipped (#1626). Before building remote awareness it was assessed by a 4-reviewer
foundation debate (debates/r93-foundation/): the verdict is clean / transport-agnostic —
nothing in R9.0 must be undone. Verified against the merged diff:
- the fold is reused verbatim —
fold(state, event, ctx:{live, at})injects clock + liveness as values; it knows no transport; - the producer (
startSensors) “names no host” (sensors.ts:757) and already runs in the pulam daemon — a remote daemon calls it with its own inputs and serves theemitstream; reconcileRemoteSnapshotis genuinely unnecessary — seed-from-durable-record + the snapshot frame is the reconcile; the value-based recency baseline (the R9.0 substitute for the deletedAWARENESS_SNAPSHOT_WINDOW_MStimer) suppresses the re-observation bump.
So remote awareness is an additive transport arm, not a refactor — and the re-sequence (debates/r92-r93/) places the producer-side prep in PR-3 (a pure local no-op) and the consume in F-REMOTE (sharing the pulam dial F-REMOTE already holds for fs/git). Mapping the work-list below: items 1–3 are PR-3 (the framer, the wire, pulam serving events); items 4–5 are F-REMOTE (kolu subscribes + folds, HostTerminalKey). The work-list, given R9.0’s actual structure:
- Build the
TerminalFrametype (designed above —snapshot | delta(seq) | gap). R9.0 has onlyTerminalEvent; the framed stream was deferred here, by design. (PR-3) - A framer that turns the producer’s
emitsequence into frames — wraps the producer (which stays a pure emitter) and assignsphase/seqonly. It is not wherectx.liveis decided: liveness is(phase === "delta")AND kolu’s durable agent-identity baseline check, in kolu’s consumer arm (the framer can’t see kolu’srestoreTarget; the remote framer runs in pulam, which has no kolu memory). Deleting the baseline would spuriously bump recency on every restart — adopt re-seedsagent: nulland the agent re-resolves asynchronously as a delta. (PR-3 — pin the adopt case with a differential test.) - Pulam serves the framed event stream — not just its snapshot cache. ⚠️ The sharp,
non-obvious requirement (the debate’s headline): pulam today folds each
TerminalEventinto aTerminalSnapshotcache and serves that (perfect for pulam-tui/pulam-web, which need no recency). But that cache is insufficient for kolu-as-remote-consumer, becausecommandRunis a memory mark thatfoldSnapshotdrops — so the remote daemon must serve the raw event stream (a newterminalEventsstream beside thesnapshotscollection, contract bump3.0 → 3.1, additive), and kolu must not rebuild memory from the servedsnapshots. (PR-3) - kolu’s remote subscribe-and-fold arm — dial the remote pulam (it serves the terminal-workspace
surface — kaval serves only PTY;
getHostSession({binary:"pulam"}), thepulam-tuiprecedent, bakePULAM_AGENT_DRVS_JSON), mirror theterminalEventsover R7’s total dual, seedcurrentfrom kolu’s durable record, fold exactly as local; on a missingseq, force agap→ re-snapshot. (F-REMOTE) - Host-scoped keys — the branded
HostTerminalKey(designed in “Decided”), kolu-intake-only (the wire stays bareTerminalId), lands here, where two hosts’ opaque PTY ids can actually collide. (F-REMOTE)
Order: PR-3 (this awareness producer-prep) lands in parallel with PR-1 (lifecycle) and PR-2
(fs/git) — all three pure local no-ops — then F-REMOTE subscribes the remote terminalEvents on the
pulam mirror it already holds for fs/git and folds it through the same fold, lighting remote awareness as
part of the one complete-everything tile. See the finale.
Decided — the forks closed
- The activity rule (the one product judgment): recency (
lastActivityAt) bumps on a live agent-identity change — it starts, finishes, or a new session appears — and (since #1726) on a same-identity output tick, throttled toRECENCY_THROTTLE_MS, so a stable long-running session doesn’t freeze; a barethinking↔awaitingflip is subsumed by the throttled output arm. The “needs you” alert is unaffected — it rides liveagent.state/urgency, not recency. - Restore resumes only a still-live agent (collapses
agentSession): wake/session-restore resumes the conversation only if the agent was live at sleep/restart; quit-to-shell wakes to a bare shell. As shipped, the fold derives a discriminatedRestoreTarget(restoreTargetOf) —exactcarries the conversation id (#1495),noneis the bare shell — so the exact-conversation ref is a fold output, not a separate sticky field, andnone-vs-exactis a named discriminant the renderer can’t misread (a migrated pre-1.29 record’s most-recent resume is the third arm,legacyMostRecent). pris restore-relevant, persisted likegit: a forge fact keyed by the branch, true-when-dead, so it survives on the dormant tile and arms autosave — deleting the frozen-prspecial case (local.ts:871, the sleeping-armpr?) and thependingflash on restore.- The two memory fields live on
kolu.authored(so thesnapshotscollection isTerminalSnapshoteverywhere — kolu’s own and every producer’s). The fold is their one writer through a narrowedauthored.updateMemory(key, AgentMemory)mutator — mirroring the existing narrowedupdateClientMetadata(metadata.ts:166) — so “the fold writes only memory; client code never does” is typed, not remembered. Reader-join shape unchanged (authored ⋈ snapshots). - Dashboards (pulam-tui / pulam-web) → urgency-only — they read
TerminalSnapshot+ the urgency projection and need no fold. BecauseTerminalSnapshothas nolastActivityAt, a dashboard that still imports recency fails to compile (splitcompareAgentsintocompareAgentUrgency(host-safe) and a kolu-only recency tiebreak). - Host-scoped aggregation key — a branded
HostTerminalKeytype, not the prose pair(HostLocation, TerminalId): kaval ids are opaque (ptyHostSurface.ts:87), so a bareMap<TerminalId, …>still spells the cross-host orphan collision. kolu intake pairs the host-localTerminalIdwith itsHostLocationbefore the fold; aggregates key by it, so a fleet view cannot alias two hosts’ non-kolu-minted PTY ids.
Phasing & migration
- S1 → S2 → R9. S1 (the memoryless producer) is the one real lift — rewriting the freshness-critical sensors from mutate-via-sink to emit-observations, with the existing sensor tests as the safety net; it deletes the sink ×2, the record, the read-back contract, the seed, and the clobber. S2 (kolu’s fold + the two memory fields → authored + the framed
terminalEventsstream) follows, and deletes the awareness mutator APIs too, not only the sink:mutateAwarenessPersisted/mutateAwarenessLive(terminal-registry.ts:177-197) and the(m) => { m.x = … }callbacks they feed (metadata.ts:124-159) are the surviving mutable place — the commit seam replaces the registry value (entry.snapshot = next) instead of handing a caller a mutable object. After both, R9·lib is gone, R9.0 is an ~8-line loop, and remote awareness (F-REMOTE) is subscribe-and-fold. - Hard cutover, no knobs (the fail-fast rule). The two remembered facts are single-writer by type (
TerminalSnapshotcan’t spell them;TerminalStatenests them so the published slice can’t carry them), the clock is kolu’s by type (the producer has no memory field to stamp) and a sampled value not a thunk, and the autosave fence rides a restore-relevant value change — honestly “by a value check,” not “by type,” since aPartial<T>always admits{}(the one place the design can’t reach a pure type, made explicit rather than overclaimed). - What it retires: createPulam; the
AwarenessSink; the seed and the restore-caveat heuristic;reconcileRemoteSnapshot; the separateagentSessionfield; the “two homes, two assemblers” framing; and the fold-clobber as a class. #1614 stays dead for the right reason — the in-process producer can’t clobber, so no local pulam process is needed.
The one thing no type settles — and its containment
Exactly one judgment is a product choice, not a type: which agent transition counts as “activity” (two composing arms since #1726: an identity change always, plus a throttled same-identity output tick — see the follow-up fix at the top). It lives in the fold’s tested branches with no producer bypass. Almost everything else is unspellable by type; the single honest exception is the autosave fence, which rides a restore-relevant value change (a Partial<T> admits {}) — so it gets a test, not a type. Tests that make that real:
- type —
TerminalSnapshotcannot containlastActivityAt/lastAgentCommand;TerminalState.snapshotis what publishes to the snapshots collection, so memory can’t ride along; noreconcileRemoteSnapshot(AwarenessValue, AwarenessValue)API exists. - the headline fence — an
agenttoken tick that changes detail but no restore-relevant value (same identity, same session — the ~150 ms firehose) folds to an equalrestoreRelevantprojection and does not arm autosave. Remove the value-change check and this test fails (the firehose reaches disk). - a
null→detectedagent in adeltaframe bumps recency; the same in asnapshotframe (re-observation) does not — the frame phase, not a producer flag, decides; there is nofromSnapshotto mislabel. - restore: an agent live at sleep resumes its exact conversation (
frozen.agent.sessionId); an agent the user quit before sleep (agent: null) wakes to a bare shell. - a replayed
commandRunupdateslastAgentCommand(so wake can replay) but fires no recent-agent MRU bump. - a producer clock a year ahead never reorders kolu’s recency (kolu samples its own clock; the wire has no
observedAt). - a dropped delta (a hole in
seq) yields agapframe and a re-snapshot — never a silently divergent fold. - the same id on two
HostLocations keeps two aggregate entries (a bareTerminalIdcan’t index the fleet —HostTerminalKeyis required).