@zhuxixi/pi-agent-board 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +6 -3
  3. package/VERIFY.md +2 -1
  4. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
  5. package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
  6. package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
  7. package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
  8. package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
  9. package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
  10. package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
  11. package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
  12. package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
  13. package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
  14. package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
  15. package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
  16. package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
  17. package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
  18. package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
  19. package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
  20. package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
  21. package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
  22. package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
  23. package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
  24. package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
  25. package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
  26. package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
  27. package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
  28. package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
  29. package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
  30. package/package.json +3 -2
  31. package/runner/job-runner-legacy.mjs +68 -0
  32. package/runner/job-runner.mjs +371 -67
  33. package/runner/pty-runner-legacy.mjs +50 -0
  34. package/runner/pty-runner.mjs +685 -58
  35. package/runner/state-coordinator.mjs +429 -0
  36. package/runner/state-runner.mjs +90 -15
  37. package/scripts/run-perf-gate.mjs +40 -0
  38. package/src/commands/agent-board.ts +8 -8
  39. package/src/commands/attach-flow.ts +5 -5
  40. package/src/commands/bg.ts +2 -1
  41. package/src/core/control-protocol.mjs +482 -0
  42. package/src/core/coordinator-client.mjs +313 -0
  43. package/src/core/coordinator-journal.mjs +282 -0
  44. package/src/core/coordinator-protocol.mjs +12 -0
  45. package/src/core/editor-state-reporter.mjs +11 -1
  46. package/src/core/foreground-preview-cache.mjs +117 -0
  47. package/src/core/host-protocol.mjs +24 -0
  48. package/src/core/launch.mjs +15 -0
  49. package/src/core/locks.mjs +68 -14
  50. package/src/core/paths.mjs +48 -0
  51. package/src/core/pid.mjs +32 -1
  52. package/src/core/pty-attach-jiggle-controller.mjs +83 -6
  53. package/src/core/pty-attach-reconnect.mjs +13 -6
  54. package/src/core/pty-attach-render.mjs +50 -0
  55. package/src/core/state-commands.mjs +699 -0
  56. package/src/core/status-consistency.mjs +98 -0
  57. package/src/core/store.mjs +59 -13
  58. package/src/core/terminal-attach-client.mjs +803 -0
  59. package/src/core/terminal-attach-protocol.mjs +252 -0
  60. package/src/core/terminal-model.mjs +222 -0
  61. package/src/core/terminal-snapshot.mjs +440 -0
  62. package/src/core/types.mjs +2 -0
  63. package/src/index.ts +12 -4
  64. package/src/runtime/service.mjs +694 -121
  65. package/src/ui/dashboard.ts +67 -92
  66. package/src/ui/pty-attach.ts +298 -72
  67. package/src/core/pty-input.mjs +0 -47
@@ -132,9 +132,9 @@ export async function attach(
132
132
 
133
133
  const plan = planAttachResolved(await service.resolveAttachTarget(viewId));
134
134
  if (plan.plan === "open-pty") {
135
- service.markVisited?.(viewId);
135
+ void service.markVisited?.(viewId)?.catch(() => {});
136
136
  const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
137
- service.markVisited?.(viewId);
137
+ void service.markVisited?.(viewId)?.catch(() => {});
138
138
  return { action: result.action === "closed" ? "closed" : "detached" };
139
139
  }
140
140
  if (plan.plan === "session-switch") {
@@ -144,7 +144,7 @@ export async function attach(
144
144
  return { action: "none" };
145
145
  }
146
146
  const name = latest.meta.name;
147
- service.markVisited?.(viewId);
147
+ void service.markVisited?.(viewId)?.catch(() => {});
148
148
  const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable");
149
149
  const result = await ctx.switchSession(latest.meta.sessionFile, {
150
150
  withSession: async (replaced) => {
@@ -212,8 +212,8 @@ export function installBackToDashboard(
212
212
  try {
213
213
  let selectedId = currentViewId(ctx, service);
214
214
  while (true) {
215
- if (selectedId) service.markVisited?.(selectedId);
216
- service.reconcile();
215
+ if (selectedId) void service.markVisited?.(selectedId)?.catch(() => {});
216
+ void service.reconcile().catch(() => {});
217
217
  const result = await openDashboard(ctx, service, { initialSelectedId: selectedId });
218
218
  if (result.action !== "attach") return;
219
219
  selectedId = result.viewId;
@@ -51,7 +51,8 @@ async function handleBgCommand(args: string, ctx: ExtensionCommandContext, opts:
51
51
  },
52
52
  });
53
53
  const model = modelRef(ctx.model as any);
54
- const adopted = service.adoptSession({
54
+ // adoptSession is async (routes through the view-state coordinator, issue #91).
55
+ const adopted = await service.adoptSession({
55
56
  sessionFile,
56
57
  cwd: ctx.cwd,
57
58
  model,
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Pure decision layer for the control-command lifecycle (issue #91 Phase 5, spec D4).
3
+ *
4
+ * Single source of truth for the command envelope shape, per-type ack-stage
5
+ * legality, retry/dedup rules, the resize latest-wins tracker, and the durable
6
+ * command journal's record shapes/GC/unresolved derivation. The runner shell
7
+ * (runner/pty-runner.mjs) owns all side effects — sockets, files, child writes,
8
+ * process lifecycle — and calls into this module for every decision; the UI
9
+ * client and the service's durable follow-up path consume the same table so
10
+ * both ends agree on semantics by construction.
11
+ *
12
+ * No fs, no net, no timers, no Date.now(): every timestamp is an explicit
13
+ * argument, so decisions are deterministic and exhaustively unit-testable
14
+ * (same pattern as state-commands.mjs from Phase 2).
15
+ *
16
+ * ## Envelope
17
+ *
18
+ * Reliable commands (`input` durable follow-up, `terminate`, `reconcile`) and
19
+ * transient controls (`resize`, keystroke `input`, `interrupt`, `detach`) all
20
+ * carry `{commandId, clientId, seq, viewId, instanceId}`. `seq` is a
21
+ * connection-scoped ordering aid ONLY — dedup and retry decisions key on the
22
+ * stable `commandId`, never on `seq` (spec D4, binding). A message without a
23
+ * `commandId` marker is a legacy message: `validateCommandEnvelope` reports it
24
+ * as passthrough, never as an error, so pre-phase-5 UIs keep working verbatim.
25
+ *
26
+ * ## Ack stages
27
+ *
28
+ * - `accepted` — durable commands only: the command is recorded in the durable
29
+ * command journal. Never emitted for transient controls.
30
+ * - `applied` — the underlying action ran; carries the ACTUAL applied value
31
+ * (resize: real PTY cols/rows post-clamp).
32
+ * - `observed` — structured observation evidence only. For terminate on the
33
+ * owned main the evidence is `runnerFinalizing: true` (the finishHost ladder
34
+ * destroys client sockets before the child exits, so a post-exit
35
+ * `exitConfirmed` ack would be undeliverable there); the legacy main sends
36
+ * `exitConfirmed` after the child exit is confirmed. Either field is
37
+ * terminal evidence for terminate. `resize` is NEVER observed (calling
38
+ * child.resize() does not mean the child finished rendering); `input` is
39
+ * never observed either (no stage may claim the child processed the bytes);
40
+ * `detach` has no observed
41
+ * stage (socket write success is not a child state change).
42
+ * - `superseded` — resize latest-wins terminal state, carries `byCommandId`.
43
+ */
44
+
45
+ /** Control command types governed by this lifecycle (spec D4 table). */
46
+ export const CONTROL_COMMAND_TYPES = Object.freeze([
47
+ "input",
48
+ "resize",
49
+ "interrupt",
50
+ "terminate",
51
+ "detach",
52
+ "reconcile",
53
+ ]);
54
+
55
+ /** Ack stages (spec D4). `superseded` is a terminal state, not a delivery stage. */
56
+ export const ACK_STAGES = Object.freeze(["accepted", "applied", "observed", "superseded"]);
57
+
58
+ /**
59
+ * Error taxonomy for `{type:"error", code, commandId?}` replies to enveloped
60
+ * control commands (spec D4; ruling 2 enumeration). Every code is terminal for
61
+ * the correlating client EXCEPT `host_starting` (bounded retry with fresh
62
+ * commandIds is the runner's documented starting-window contract). Clients
63
+ * must consume a taxonomy error carrying a commandId: clear the pending
64
+ * correlation and surface `cmdAck {stage:"error", code}` — a swallowed error
65
+ * leaves the command pending forever.
66
+ *
67
+ * - `envelope_invalid` — the envelope failed validation (missing/ill-typed
68
+ * fields, listed in `errors`). The command had NO effect. Retrying the same
69
+ * bytes is futile; this is a caller bug.
70
+ * - `instance_mismatch` — the command's instanceId is a foreign fence. The
71
+ * command had NO effect on this runner. `currentInstanceId` is the recovery
72
+ * signal (re-reconcile against it).
73
+ * - `host_starting` — the child is not ready (starting window). The command
74
+ * had NO effect. Retry with a FRESH commandId is the documented contract
75
+ * (the same commandId is not journaled, so reuse would also be safe, but
76
+ * fresh ids keep ack correlation unambiguous).
77
+ * - `journal_unavailable` — a durable accept was REFUSED because the command
78
+ * journal could not be written. Nothing was journaled, nothing applied;
79
+ * the accepted stage would have been a lie. Retry is safe (fresh accept).
80
+ * - `command_failed` — the runner-side action failed after (or without) an
81
+ * accept. Reconcile by commandId BEFORE retrying: if the command was
82
+ * journaled, a blind re-send returns only the cached stage and never
83
+ * re-applies — the honest resolution is reconcile-then-decide.
84
+ *
85
+ * Not listed here: an out-of-order `seq` is dropped REPLY-LESS (diagnostic
86
+ * only, `checkSeq` contract) — it is an ordering aid, never a command
87
+ * rejection, and carries no commandId to correlate.
88
+ */
89
+ export const CONTROL_ERROR_CODES = Object.freeze({
90
+ envelope_invalid: "envelope failed validation; command had no effect; caller bug",
91
+ instance_mismatch: "foreign instance fence; command had no effect; currentInstanceId is the recovery signal",
92
+ host_starting: "child not ready; command had no effect; bounded retry with fresh commandIds",
93
+ journal_unavailable: "durable accept refused (journal write failed); nothing journaled or applied; retry safe",
94
+ command_failed: "runner-side action failed; reconcile by commandId before retry",
95
+ });
96
+
97
+ /** Error codes after which a client-side retry chain must NOT continue. */
98
+ export const TERMINAL_ERROR_CODES = Object.freeze([
99
+ "envelope_invalid",
100
+ "instance_mismatch",
101
+ "journal_unavailable",
102
+ "command_failed",
103
+ ]);
104
+
105
+ /**
106
+ * Per-type delivery semantics — BINDING for runner (Task 2), UI client (Task 3)
107
+ * and service follow-up (Task 4). Stage legality is what classifyCommandAck
108
+ * enforces; `retry` names the rule retryPolicy implements.
109
+ */
110
+ export const COMMAND_SEMANTICS = Object.freeze({
111
+ input: Object.freeze({
112
+ durable: "conditional", // requestId/commandId-tagged = durable follow-up; bare = keystroke
113
+ stages: Object.freeze({ accepted: true, applied: true, observed: false, superseded: false }),
114
+ appliedValue: null, // child.write ran; no stage may claim the child processed the bytes
115
+ retry: "conditional", // keystroke: never replays; durable: reconcile-query-first, then dedup-protected
116
+ }),
117
+ resize: Object.freeze({
118
+ durable: "no",
119
+ stages: Object.freeze({ accepted: false, applied: true, observed: false, superseded: true }),
120
+ appliedValue: "pty_dims", // real PTY cols/rows post-clamp; never a render claim
121
+ retry: "latest_wins", // same commandId → cached result; new size → new commandId
122
+ }),
123
+ interrupt: Object.freeze({
124
+ durable: "no",
125
+ stages: Object.freeze({ accepted: false, applied: true, observed: false, superseded: false }),
126
+ appliedValue: null,
127
+ retry: "transient", // never after disconnect; a lost ESC is not worth a replay risk
128
+ }),
129
+ terminate: Object.freeze({
130
+ durable: "no",
131
+ stages: Object.freeze({ accepted: false, applied: true, observed: true, superseded: false }),
132
+ appliedValue: "termination_started",
133
+ retry: "idempotent", // repeats return current lifecycle state
134
+ }),
135
+ detach: Object.freeze({
136
+ durable: "no",
137
+ stages: Object.freeze({ accepted: false, applied: true, observed: false, superseded: false }),
138
+ appliedValue: "detach_accepted",
139
+ retry: "idempotent",
140
+ }),
141
+ reconcile: Object.freeze({
142
+ durable: "no",
143
+ stages: Object.freeze({ accepted: false, applied: false, observed: false, superseded: false }),
144
+ appliedValue: null, // reconcile answers with reconcile_result, not cmd_ack stages
145
+ retry: "readonly", // idempotent baseline read; retrying it is safe
146
+ }),
147
+ });
148
+
149
+ /**
150
+ * Classify a message's envelope status. The passthrough rule is absolute: a
151
+ * message WITHOUT a non-empty `commandId` is legacy — reported as
152
+ * `{enveloped: false}` with NO errors, never validated further, never errored,
153
+ * so pre-phase-5 clients are untouched regardless of what other fields they
154
+ * carry. Only a message that DOES carry a `commandId` is held to the full
155
+ * envelope contract; any gap there is a client bug worth surfacing.
156
+ */
157
+ export function validateCommandEnvelope(msg) {
158
+ if (!msg || typeof msg !== "object") return { enveloped: false, errors: ["msg_not_object"] };
159
+ if (typeof msg.commandId !== "string" || msg.commandId.length === 0) {
160
+ return { enveloped: false, errors: [] };
161
+ }
162
+ const errors = [];
163
+ if (!Number.isInteger(msg.seq) || msg.seq < 1) errors.push("seq_invalid");
164
+ if (typeof msg.clientId !== "string" || msg.clientId.length === 0) errors.push("clientid_missing");
165
+ if (typeof msg.viewId !== "string" || msg.viewId.length === 0) errors.push("viewid_missing");
166
+ if (typeof msg.instanceId !== "string" || msg.instanceId.length === 0) errors.push("instanceid_missing");
167
+ if (!CONTROL_COMMAND_TYPES.includes(msg.type)) errors.push("type_not_control");
168
+ return { enveloped: true, errors };
169
+ }
170
+
171
+ /**
172
+ * Build an enveloped control command. Throws on missing envelope fields, a
173
+ * non-control type, or payload keys colliding with envelope fields (`type`,
174
+ * `commandId`, `clientId`, `seq`, `viewId`, `instanceId`): all are programmer
175
+ * errors at the call site, not runtime input handling — a collision would
176
+ * silently corrupt the envelope, so it fails loud instead.
177
+ */
178
+ const ENVELOPE_OWNED_KEYS = Object.freeze([
179
+ "type",
180
+ "commandId",
181
+ "clientId",
182
+ "seq",
183
+ "viewId",
184
+ "instanceId",
185
+ ]);
186
+
187
+ export function encodeCommand(type, payload, envelope) {
188
+ if (!CONTROL_COMMAND_TYPES.includes(type)) {
189
+ throw new TypeError(`encodeCommand: unknown control type ${JSON.stringify(type)}`);
190
+ }
191
+ for (const field of ["commandId", "clientId", "viewId", "instanceId"]) {
192
+ if (typeof envelope?.[field] !== "string" || envelope[field].length === 0) {
193
+ throw new TypeError(`encodeCommand: ${field} must be a non-empty string`);
194
+ }
195
+ }
196
+ if (!Number.isInteger(envelope?.seq) || envelope.seq < 1) {
197
+ throw new TypeError("encodeCommand: seq must be an integer >= 1");
198
+ }
199
+ if (payload != null) {
200
+ const collisions = Object.keys(payload).filter((key) => ENVELOPE_OWNED_KEYS.includes(key));
201
+ if (collisions.length > 0) {
202
+ throw new TypeError(`encodeCommand: payload collides with envelope-owned keys: ${collisions.join(", ")}`);
203
+ }
204
+ }
205
+ return Object.freeze({
206
+ type,
207
+ commandId: envelope.commandId,
208
+ clientId: envelope.clientId,
209
+ seq: envelope.seq,
210
+ viewId: envelope.viewId,
211
+ instanceId: envelope.instanceId,
212
+ ...(payload ?? {}),
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Validate/classify an ack record against the command type's stage semantics.
218
+ * Returns `{ok: true, stage, ...}` or `{ok: false, reason}` — the runner emits
219
+ * only classified acks; the client classifies before trusting one.
220
+ */
221
+ export function classifyCommandAck(type, ack) {
222
+ const sem = COMMAND_SEMANTICS[type];
223
+ if (!sem) return { ok: false, reason: "unknown_type" };
224
+ if (!ack || typeof ack !== "object") return { ok: false, reason: "ack_not_object" };
225
+ const commandId = typeof ack.commandId === "string" && ack.commandId ? ack.commandId : null;
226
+ switch (ack.stage) {
227
+ case "accepted": {
228
+ if (!sem.stages.accepted) return { ok: false, reason: "accepted_not_applicable" };
229
+ // `accepted` exists only for durable delivery: it means "recorded in
230
+ // the durable journal". A bare keystroke input must never produce it.
231
+ if (type === "input" && ack.durable !== true) {
232
+ return { ok: false, reason: "accepted_requires_durable" };
233
+ }
234
+ return { ok: true, stage: "accepted", commandId };
235
+ }
236
+ case "applied": {
237
+ if (!sem.stages.applied) return { ok: false, reason: "applied_not_applicable" };
238
+ if (sem.appliedValue === "pty_dims") {
239
+ if (!Number.isInteger(ack.cols) || !Number.isInteger(ack.rows)) {
240
+ return { ok: false, reason: "applied_requires_dims" };
241
+ }
242
+ return { ok: true, stage: "applied", commandId, value: { cols: ack.cols, rows: ack.rows } };
243
+ }
244
+ return { ok: true, stage: "applied", commandId };
245
+ }
246
+ case "observed": {
247
+ if (!sem.stages.observed) return { ok: false, reason: "observed_not_applicable" };
248
+ // Structured evidence only (spec D4), never a timer or an assumption.
249
+ // Terminate accepts TWO evidence forms: a confirmed child exit, or the
250
+ // runner's own lifecycle-state confirmation — an owned runner finalizes
251
+ // in lockstep with the child and structurally cannot send after the
252
+ // exit lands, so its finalizing state is the best deliverable evidence.
253
+ if (type === "terminate" && ack.exitConfirmed !== true && ack.runnerFinalizing !== true) {
254
+ return { ok: false, reason: "observed_requires_exit_confirmation" };
255
+ }
256
+ return { ok: true, stage: "observed", commandId };
257
+ }
258
+ case "superseded": {
259
+ if (!sem.stages.superseded) return { ok: false, reason: "superseded_not_applicable" };
260
+ if (typeof ack.byCommandId !== "string" || ack.byCommandId.length === 0) {
261
+ return { ok: false, reason: "superseded_requires_by" };
262
+ }
263
+ if (ack.byCommandId === ack.commandId) return { ok: false, reason: "superseded_self" };
264
+ return { ok: true, stage: "superseded", commandId, byCommandId: ack.byCommandId };
265
+ }
266
+ default:
267
+ return { ok: false, reason: "unknown_stage" };
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Retry/dedup decision per command type given the observed history.
273
+ *
274
+ * `history` fields: `{durable?, disconnected?, timedOut?, query?}` where
275
+ * `query` is a reconcile/query outcome for a durable command: `"applied"`
276
+ * (already applied — done), `"accepted_unknown"` (accepted but the applying
277
+ * runner died before `applied` — the spec §10 window; NEVER auto-replay),
278
+ * `"unknown"` (the runner never saw this commandId — retry is safe because
279
+ * commandId dedup protects against double delivery).
280
+ */
281
+ export function retryPolicy(type, history = {}) {
282
+ switch (type) {
283
+ case "input": {
284
+ if (history.durable !== true) return { action: "never", reason: "keystroke_never_replays" };
285
+ if (history.query === "applied") return { action: "done", reason: "already_applied" };
286
+ if (history.query === "accepted_unknown") {
287
+ return { action: "ambiguous", reason: "accepted_write_window_lost" };
288
+ }
289
+ if (history.query === "unknown") return { action: "retry_same_command", reason: "dedup_protects" };
290
+ // Timeout or disconnect without a query result: the client cannot
291
+ // assume failure (spec D4) — reconcile first.
292
+ return { action: "query_then_decide", reason: "timeout_cannot_assume_failure" };
293
+ }
294
+ case "resize":
295
+ // The user asked for a size; a retry of the OLD command is pointless —
296
+ // send the current size as a NEW command (latest-wins supersedes).
297
+ return { action: "new_command", reason: "latest_wins" };
298
+ case "interrupt":
299
+ return { action: "never", reason: "transient_lost_interrupt_not_replayed" };
300
+ case "terminate":
301
+ case "detach":
302
+ return { action: "retry_same_command", reason: "idempotent" };
303
+ case "reconcile":
304
+ return { action: "retry_same_command", reason: "readonly_baseline_read" };
305
+ default:
306
+ return { action: "never", reason: "unknown_type" };
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Dedup key for control commands: the stable `commandId`, nothing else.
312
+ * `seq` is connection-scoped ordering and must never participate (spec D4).
313
+ * Returns null for legacy (envelope-less) messages — they are not deduped.
314
+ */
315
+ export function dedupKey(msg) {
316
+ return typeof msg?.commandId === "string" && msg.commandId.length > 0 ? msg.commandId : null;
317
+ }
318
+
319
+ /**
320
+ * Client-side resize latest-wins mirror. The runner is authoritative for
321
+ * supersession (receipt order on the socket); this tracker lets the UI know
322
+ * which of its own resizes are dead (superseded) and reuse results when the
323
+ * same commandId is re-requested.
324
+ *
325
+ * - `track({commandId, clientId, cols, rows})` → `{superseded: [{commandId,
326
+ * byCommandId}], duplicate}` — a NEW size from a client supersedes that
327
+ * client's still-unapplied pending resize; re-tracking the same commandId
328
+ * (send retry) is a duplicate, superseding nothing.
329
+ * - `applied(commandId, cols, rows)` → cache the runner's actual applied dims.
330
+ * - `resultFor(commandId)` → cached `{cols, rows}` for same-commandId
331
+ * re-requests, or undefined.
332
+ */
333
+ export const RESIZE_TRACKER_KEEP_DEFAULT = 256;
334
+
335
+ /**
336
+ * Latest-wins resize tracking with a FIFO retention bound (CR R1 advisory:
337
+ * the runner is a long-lived process — unbounded knownIds/results grew the
338
+ * map forever, ~100B per resize). Mirrors the durable journal's keep pattern:
339
+ * when the insertion-order log exceeds the bound, the OLDEST commandIds are
340
+ * forgotten everywhere (knownIds, results, and any pendingByClient entry that
341
+ * still points at one — its supersede chain ends with the eviction, which is
342
+ * fine: a 256-resize-old pending command is unreachable by construction).
343
+ *
344
+ * @param {{keep?: number}} [opts]
345
+ */
346
+ export function createResizeTracker({ keep = RESIZE_TRACKER_KEEP_DEFAULT } = {}) {
347
+ /** clientId → the single newest pending command (latest-wins). */
348
+ const pendingByClient = new Map();
349
+ /** every retained commandId (duplicate detection across clients) */
350
+ const knownIds = new Set();
351
+ /** commandId → applied dims */
352
+ const results = new Map();
353
+ /** insertion order for FIFO eviction */
354
+ const order = [];
355
+ const forgetOldest = () => {
356
+ while (order.length > keep) {
357
+ const oldest = order.shift();
358
+ knownIds.delete(oldest);
359
+ results.delete(oldest);
360
+ for (const [clientId, pending] of pendingByClient) {
361
+ if (pending.commandId === oldest) pendingByClient.delete(clientId);
362
+ }
363
+ }
364
+ };
365
+ return {
366
+ track({ commandId, clientId, cols, rows }) {
367
+ if (typeof commandId !== "string" || commandId.length === 0) {
368
+ throw new TypeError("resizeTracker.track: commandId required");
369
+ }
370
+ if (knownIds.has(commandId)) return { superseded: [], duplicate: true };
371
+ knownIds.add(commandId);
372
+ order.push(commandId);
373
+ const superseded = [];
374
+ const prev = pendingByClient.get(clientId);
375
+ if (prev) superseded.push({ commandId: prev.commandId, byCommandId: commandId });
376
+ pendingByClient.set(clientId, { commandId, cols, rows });
377
+ forgetOldest();
378
+ return { superseded, duplicate: false };
379
+ },
380
+ applied(commandId, cols, rows) {
381
+ results.set(commandId, { cols, rows });
382
+ for (const [clientId, pending] of pendingByClient) {
383
+ if (pending.commandId === commandId) pendingByClient.delete(clientId);
384
+ }
385
+ return { ok: true };
386
+ },
387
+ resultFor(commandId) {
388
+ return results.get(commandId);
389
+ },
390
+ size() {
391
+ return order.length;
392
+ },
393
+ };
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Durable command journal (record shapes + GC + unresolved derivation)
398
+ //
399
+ // The runner appends one JSONL line per lifecycle transition of a durable
400
+ // command: `{kind:"accepted", commandId, command, acceptedAt}` on accept and
401
+ // `{kind:"applied", commandId, appliedAt}` once `child.write` ran. On restart
402
+ // the journal is loaded and `journalUnresolved` derives the spec §10
403
+ // "accepted_unknown" set — commands the PREVIOUS runner accepted but whose
404
+ // applied outcome is unknown. These are NEVER auto-replayed (spec §10 binding
405
+ // rule); the service decides per its own queue semantics via reconcile.
406
+ // ---------------------------------------------------------------------------
407
+
408
+ export const JOURNAL_KEEP_DEFAULT = 256;
409
+
410
+ /**
411
+ * Per-connection sequence check (runner-side ordering aid, spec D4). `seq` must
412
+ * be an integer strictly greater than the last accepted seq on the connection;
413
+ * gaps are legal (clients may batch), repeats/regressions are not. Ordering
414
+ * ONLY — dedup keys on `commandId` and never on `seq`.
415
+ *
416
+ * @returns {{ok: true} | {ok: false, reason: "seq_invalid" | "seq_not_monotonic"}}
417
+ */
418
+ export function checkSeq(lastSeq, seq) {
419
+ if (!Number.isInteger(seq) || seq < 1) return { ok: false, reason: "seq_invalid" };
420
+ if (seq <= lastSeq) return { ok: false, reason: "seq_not_monotonic" };
421
+ return { ok: true };
422
+ }
423
+
424
+ /**
425
+ * Validate and append a journal record (pure: returns a new array). Throws on
426
+ * malformed records — a malformed journal line is a runner bug, not input.
427
+ */
428
+ export function journalAppendRecord(records, record) {
429
+ if (!record || typeof record !== "object") throw new TypeError("journal record must be an object");
430
+ if (record.kind !== "accepted" && record.kind !== "applied") {
431
+ throw new TypeError(`journal record kind must be "accepted"|"applied", got ${JSON.stringify(record.kind)}`);
432
+ }
433
+ if (typeof record.commandId !== "string" || record.commandId.length === 0) {
434
+ throw new TypeError("journal record requires a non-empty commandId");
435
+ }
436
+ if (record.kind === "accepted") {
437
+ if (typeof record.acceptedAt !== "number") throw new TypeError("accepted record requires numeric acceptedAt");
438
+ if (typeof record.command !== "string") throw new TypeError("accepted record requires a command string");
439
+ } else if (typeof record.appliedAt !== "number") {
440
+ throw new TypeError("applied record requires numeric appliedAt");
441
+ }
442
+ return [...records, record];
443
+ }
444
+
445
+ /**
446
+ * GC to the newest `keep` distinct commandIds, dropping WHOLE lifecycles.
447
+ * Invariant: every `commandId` in the result keeps ALL of its records or none.
448
+ * Record-count GC cannot give this guarantee — duplicate accepted records may
449
+ * legitimately follow a command's applied record, so which records survive a
450
+ * trim would depend on append order, and a surviving `accepted` beside a
451
+ dropped `applied` would resurrect a phantom "accepted_unknown" after restart.
452
+ * Group GC removes that dependence entirely.
453
+ */
454
+ export function journalGc(records, keep = JOURNAL_KEEP_DEFAULT) {
455
+ const lastIndexById = new Map();
456
+ records.forEach((record, index) => lastIndexById.set(record.commandId, index));
457
+ const keepIds = new Set(
458
+ [...lastIndexById.entries()]
459
+ .sort((a, b) => b[1] - a[1])
460
+ .slice(0, keep)
461
+ .map(([id]) => id),
462
+ );
463
+ return records.filter((record) => keepIds.has(record.commandId));
464
+ }
465
+
466
+ /**
467
+ * Derive the unresolved set: commands with an `accepted` but no `applied`
468
+ * record, in accept order, deduped by commandId. This is exactly the
469
+ * "accepted_unknown" set reconcile reports after a runner restart.
470
+ */
471
+ export function journalUnresolved(records) {
472
+ const applied = new Set(records.filter((r) => r.kind === "applied").map((r) => r.commandId));
473
+ const seen = new Set();
474
+ const out = [];
475
+ for (const record of records) {
476
+ if (record.kind !== "accepted") continue;
477
+ if (applied.has(record.commandId) || seen.has(record.commandId)) continue;
478
+ seen.add(record.commandId);
479
+ out.push({ commandId: record.commandId, command: record.command, acceptedAt: record.acceptedAt });
480
+ }
481
+ return out;
482
+ }