@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
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Process-local read-your-writes cache for foreground state projections
3
+ * (issue #113).
4
+ *
5
+ * Foreground turns are mirrored into state.json through the detached View
6
+ * State Coordinator: `message_end` sets `latestAssistantPreview` /
7
+ * `lastAgentActivityAt` and fires a fire-and-forget `sync_foreground` command;
8
+ * the coordinator journals + fsyncs before materializing the file. The next
9
+ * event (`agent_end`, ~7ms later) rebuilds its in-memory status from the
10
+ * still-stale state.json, derives the "Needs instructions" fallback summary and
11
+ * overwrites the fresher projection that was still in flight.
12
+ *
13
+ * This cache restores read-your-writes for the two fields whose legitimate
14
+ * transitions are "empty → non-empty" and "old non-empty → new non-empty",
15
+ * keyed on `lastAgentActivityAt` as the freshness signal (message_end stamps
16
+ * it with `now`):
17
+ *
18
+ * - `remember` keeps the strictly-freshest projection: an entry whose
19
+ * timestamp is older than the stored one never degrades it (the stale
20
+ * agent_end rebuild must not clobber a newer in-flight message_end value);
21
+ * an older or timestampless projection only fills gaps.
22
+ * - `backfill` adopts BOTH cached fields when the cached timestamp is strictly
23
+ * newer than the rebuilt status's (including a timestampless/legacy
24
+ * rebuild) — the disk snapshot is stale. Otherwise it fills only empty
25
+ * fields: the materialized file stays authoritative when it is newer or
26
+ * equally aged, so a value another writer persisted is never resurrected
27
+ * over by an older cached one.
28
+ *
29
+ * Module-level by necessity: `serviceFor()` creates a new service instance per
30
+ * call (src/index.ts), so a per-instance cache would be discarded between
31
+ * events.
32
+ */
33
+
34
+ /** @typedef {{ latestAssistantPreview: string, lastAgentActivityAt: number|null }} KnownForegroundFields */
35
+
36
+ /**
37
+ * @returns {{
38
+ * remember: (viewId: string, projection: { latestAssistantPreview?: unknown, lastAgentActivityAt?: unknown }) => void,
39
+ * backfill: (viewId: string, status: { latestAssistantPreview?: unknown, lastAgentActivityAt?: unknown }) => boolean,
40
+ * forget: (viewId: string) => boolean,
41
+ * clear: () => void,
42
+ * size: () => number,
43
+ * }}
44
+ */
45
+ export function createForegroundPreviewCache() {
46
+ /** @type {Map<string, KnownForegroundFields>} */
47
+ const known = new Map();
48
+
49
+ function remember(viewId, projection) {
50
+ if (!viewId || !projection) return;
51
+ const preview = projection.latestAssistantPreview;
52
+ const activityAt = projection.lastAgentActivityAt;
53
+ const hasPreview = typeof preview === "string" && preview.length > 0;
54
+ const hasActivity = activityAt != null;
55
+ if (!hasPreview && !hasActivity) return;
56
+ const entry = known.get(viewId) ?? { latestAssistantPreview: "", lastAgentActivityAt: null };
57
+ const isNewer = hasActivity && (entry.lastAgentActivityAt == null || activityAt > entry.lastAgentActivityAt);
58
+ if (isNewer) {
59
+ // A strictly fresher projection wins wholesale; an empty preview still
60
+ // never overwrites a known non-empty one.
61
+ if (hasPreview) entry.latestAssistantPreview = preview;
62
+ entry.lastAgentActivityAt = activityAt;
63
+ } else {
64
+ // Older or timestampless: gap-fill only, never degrade the entry.
65
+ if (hasPreview && !entry.latestAssistantPreview) entry.latestAssistantPreview = preview;
66
+ if (hasActivity && entry.lastAgentActivityAt == null) entry.lastAgentActivityAt = activityAt;
67
+ }
68
+ known.set(viewId, entry);
69
+ }
70
+
71
+ function backfill(viewId, status) {
72
+ if (!viewId || !status) return false;
73
+ const entry = known.get(viewId);
74
+ if (!entry) return false;
75
+ const statusAt = status.lastAgentActivityAt ?? null;
76
+ let changed = false;
77
+ if (entry.lastAgentActivityAt != null && (statusAt == null || entry.lastAgentActivityAt > statusAt)) {
78
+ // The disk rebuild is a stale snapshot (or a timestampless legacy row):
79
+ // the freshest value this process projected wins wholesale.
80
+ if (entry.latestAssistantPreview && status.latestAssistantPreview !== entry.latestAssistantPreview) {
81
+ status.latestAssistantPreview = entry.latestAssistantPreview;
82
+ changed = true;
83
+ }
84
+ if (statusAt !== entry.lastAgentActivityAt) {
85
+ status.lastAgentActivityAt = entry.lastAgentActivityAt;
86
+ changed = true;
87
+ }
88
+ return changed;
89
+ }
90
+ if (entry.latestAssistantPreview && !status.latestAssistantPreview) {
91
+ status.latestAssistantPreview = entry.latestAssistantPreview;
92
+ changed = true;
93
+ }
94
+ if (entry.lastAgentActivityAt != null && statusAt == null) {
95
+ status.lastAgentActivityAt = entry.lastAgentActivityAt;
96
+ changed = true;
97
+ }
98
+ return changed;
99
+ }
100
+
101
+ function forget(viewId) {
102
+ return known.delete(viewId);
103
+ }
104
+
105
+ function clear() {
106
+ known.clear();
107
+ }
108
+
109
+ function size() {
110
+ return known.size;
111
+ }
112
+
113
+ return { remember, backfill, forget, clear, size };
114
+ }
115
+
116
+ /** Shared cache used by the runtime service (module-level: see module doc). */
117
+ export const foregroundPreviewCache = createForegroundPreviewCache();
@@ -0,0 +1,24 @@
1
+ /** Control-socket client identity helpers (issue #103).
2
+ *
3
+ * Client ids travel in the `hello` handshake. The runner uses them to keep
4
+ * bookkeeping-only connections out of `attachedClients` / `attachedEver`:
5
+ * the attach resolver's probes are read-only, and the hosted child's
6
+ * editor-state reporter is a resident connection — counting either would pin
7
+ * every host against warm-host reclaim (issue #75 / #103 §C).
8
+ */
9
+
10
+ /** Read-only endpoint probe (attach resolver); never counts as attached. */
11
+ export const CLIENT_ID_PROBE = "probe";
12
+ /** Resident editor-state reporter inside a hosted child; never counts as attached. */
13
+ export const CLIENT_ID_EDITOR_REPORTER = "editor-reporter";
14
+
15
+ /**
16
+ * @param {{ type?: string, clientId?: string } | null | undefined} msg
17
+ * @returns {"probe" | "editor-reporter" | "client"}
18
+ */
19
+ export function classifyClientHello(msg) {
20
+ const clientId = msg && typeof msg.clientId === "string" ? msg.clientId : "";
21
+ if (clientId === CLIENT_ID_PROBE) return "probe";
22
+ if (clientId === CLIENT_ID_EDITOR_REPORTER) return "editor-reporter";
23
+ return "client";
24
+ }
@@ -120,3 +120,18 @@ export function launchAutoState(root, config, opts) {
120
120
 
121
121
  return { pid: child.pid ?? null, configPath };
122
122
  }
123
+
124
+ /**
125
+ * Launch the detached view-state coordinator for a board root (issue #91, spec
126
+ * D3). No config file: the coordinator takes the root as its only argument.
127
+ * Idempotent by lease — a second instance loses the coordinator lease and
128
+ * exits silently, so callers may spawn freely on probe failure.
129
+ * @param {string} root
130
+ * @param {{ runnerScript: string, node?: string }} opts
131
+ * @returns {{ pid: number|null }}
132
+ */
133
+ export function launchCoordinator(root, opts) {
134
+ const node = opts.node ?? resolveNode();
135
+ const child = spawnDetached(node, [opts.runnerScript, root], root);
136
+ return { pid: child.pid ?? null };
137
+ }
@@ -143,6 +143,12 @@ function releaseLock(lockPath, fs) {
143
143
  const defaultLeaseFs = Object.freeze({ ...defaultLocksFs, renameSync });
144
144
  /** Max stale-reclaim → republish rounds inside a single acquire attempt. */
145
145
  const MAX_LEASE_RECLAIM_ATTEMPTS = 3;
146
+ /**
147
+ * Age past which an identity-less lock is treated as an orphan candidate.
148
+ * Host-meta holds are millisecond-scale critical sections, so no legitimate
149
+ * holder reaches this (issue #112).
150
+ */
151
+ const ORPHAN_LEASE_AGE_MS = 5 * 60_000;
146
152
 
147
153
  /**
148
154
  * A token-fenced lease over the view-lock directory. Every operation re-reads
@@ -189,6 +195,23 @@ export function tryAcquireOwnedViewLock(root, viewId, name, opts = {}) {
189
195
  return { acquired: true, lease: attempt };
190
196
  }
191
197
 
198
+ /**
199
+ * Whether a publish-rename failure means "the lock path already exists"
200
+ * (contention) rather than a genuine filesystem error.
201
+ *
202
+ * POSIX reports EEXIST/ENOTEMPTY when renaming a directory onto an existing
203
+ * one; Windows reports EPERM (errno -4048) for the same situation — this op's
204
+ * platform equivalent of EEXIST (issue #114: a crashed owner's lease could
205
+ * never be reclaimed because EPERM was rethrown before reclaimOrBlock).
206
+ * Routing a genuine permission error here stays safe: reclaimability is still
207
+ * decided solely by `classifyLeaseOwner`, so it resolves `blocked`/`busy`.
208
+ * @param {string|undefined|null} code
209
+ * @returns {boolean}
210
+ */
211
+ export function isPublishConflictCode(code) {
212
+ return code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM";
213
+ }
214
+
192
215
  /**
193
216
  * Single-shot acquire round: publish a complete candidate lock (owner.json
194
217
  * written BEFORE the lock path exists) via atomic rename, and on contention
@@ -219,9 +242,9 @@ function attemptAcquireLease(lockPath, opts) {
219
242
  return makeLease(lockPath, token, fs, now);
220
243
  } catch (err) {
221
244
  try { fs.rmSync(candidate, { recursive: true, force: true }); } catch { /* best effort */ }
222
- const code = err && err.code;
223
- if (code !== "EEXIST" && code !== "ENOTEMPTY") throw err;
224
- const verdict = reclaimOrBlock(lockPath, token, fs, isProcessDead);
245
+ const code = /** @type {NodeJS.ErrnoException|undefined} */ (err)?.code;
246
+ if (!isPublishConflictCode(code)) throw err;
247
+ const verdict = reclaimOrBlock(lockPath, token, fs, isProcessDead, now);
225
248
  if (verdict !== true) return verdict;
226
249
  // Reclaimed a dead owner's lock — retry the publish on the next round.
227
250
  }
@@ -230,29 +253,60 @@ function attemptAcquireLease(lockPath, opts) {
230
253
  }
231
254
 
232
255
  /**
233
- * Decide whether an existing lock may be reclaimed. Only a lock whose
234
- * owner.json carries a usable identity (pid + startToken) AND whose pid is
235
- * provably dead is reclaimable; anything corrupt, unreadable, or live is
236
- * never deleted. Reclaim quarantines via rename first so a concurrent winner
237
- * can only ever delete the directory it itself renamed.
256
+ * Decide what to do with an inspected lease owner. Pure: the caller supplies
257
+ * `now` and a pid-liveness probe.
258
+ *
259
+ * A full identity (pid + startToken) reclaims exactly when its pid is dead.
260
+ * An identity-less owner — legacy short-hold locks, or platforms where
261
+ * startToken cannot be captured — is only reclaimable past `orphanAgeMs`
262
+ * with a provably dead top-level pid: fresh identity-less locks stay blocked,
263
+ * preserving the short-critical-section contract (issue #112).
264
+ * @param {any} owner parsed owner.json
265
+ * @param {number} now
266
+ * @param {(pid: number) => boolean} isProcessDead
267
+ * @param {{ orphanAgeMs?: number }} [opts]
268
+ * @returns {"reclaim" | "busy" | "blocked"}
269
+ */
270
+ export function classifyLeaseOwner(owner, now, isProcessDead, opts = {}) {
271
+ if (!owner || typeof owner !== "object") return "blocked";
272
+ const ownPid = Number(owner?.identity?.pid ?? 0);
273
+ const hasIdentity = Number.isFinite(ownPid) && ownPid > 0 && typeof owner?.identity?.startToken === "string";
274
+ if (hasIdentity) {
275
+ if (!isProcessDead(ownPid)) return "busy";
276
+ // Quarantine-mode reclaim verifies by token that it renamed the lock it
277
+ // inspected — without one nothing may be deleted.
278
+ return typeof owner.token === "string" ? "reclaim" : "blocked";
279
+ }
280
+ const pid = Number(owner?.pid ?? 0);
281
+ if (!Number.isFinite(pid) || pid <= 0) return "blocked";
282
+ const orphanAgeMs = Number(opts.orphanAgeMs ?? ORPHAN_LEASE_AGE_MS);
283
+ if (!(Number(now) - Number(owner?.startedAt ?? 0) >= orphanAgeMs)) return "blocked";
284
+ if (!isProcessDead(pid)) return "busy";
285
+ return typeof owner.token === "string" ? "reclaim" : "blocked";
286
+ }
287
+
288
+ /**
289
+ * Decide whether an existing lock may be reclaimed. Verdicts come from the
290
+ * pure `classifyLeaseOwner`; reclaim itself quarantines via rename first so a
291
+ * concurrent winner can only ever delete the directory it itself renamed.
238
292
  * @param {string} lockPath
239
293
  * @param {string} token the caller's own token (names the quarantine dir)
240
294
  * @param {typeof defaultLeaseFs} fs
241
295
  * @param {(pid: number) => boolean} isProcessDead
296
+ * @param {() => number} now clock (matching the caller's candidate timestamps)
242
297
  * @returns {true | "busy" | "blocked"}
243
298
  */
244
- function reclaimOrBlock(lockPath, token, fs, isProcessDead) {
299
+ function reclaimOrBlock(lockPath, token, fs, isProcessDead, now) {
245
300
  let owner;
246
301
  try {
247
302
  owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf8"));
248
303
  } catch {
249
304
  return "blocked";
250
305
  }
251
- const pid = Number(owner?.identity?.pid ?? 0);
252
- if (!Number.isFinite(pid) || pid <= 0 || typeof owner?.identity?.startToken !== "string") return "blocked";
253
- if (!isProcessDead(pid)) return "busy";
254
- const inspectedToken = typeof owner?.token === "string" ? owner.token : null;
255
- if (inspectedToken === null) return "blocked";
306
+ const verdict = classifyLeaseOwner(owner, now(), isProcessDead);
307
+ if (verdict !== "reclaim") return verdict;
308
+ // classifyLeaseOwner only returns "reclaim" when owner.token is a string.
309
+ const inspectedToken = owner.token;
256
310
  const quarantine = `${lockPath}.reclaim.${token}`;
257
311
  try {
258
312
  fs.renameSync(lockPath, quarantine);
@@ -37,6 +37,10 @@ export const metaPath = (root, viewId) => path.join(viewDir(root, viewId), "meta
37
37
  export const statePath = (root, viewId) => path.join(viewDir(root, viewId), "state.json");
38
38
  /** @param {string} root @param {string} viewId */
39
39
  export const hostPath = (root, viewId) => path.join(viewDir(root, viewId), "host.json");
40
+ /** Durable control-command journal for a view's host (issue #91 phase 5, spec
41
+ * D4): one JSONL line per durable-command lifecycle transition
42
+ * (accepted/applied). Sibling of host.json in the view dir. */
43
+ export const controlJournalPath = (root, viewId) => path.join(viewDir(root, viewId), "control-journal.jsonl");
40
44
  /** @param {string} root @param {string} viewId */
41
45
  export const hostConfigPath = (root, viewId) => path.join(viewDir(root, viewId), "host-config.json");
42
46
  /**
@@ -71,6 +75,21 @@ export function controlSocketPathFor(platform, root, viewId) {
71
75
  }
72
76
  /** @param {string} root @param {string} viewId */
73
77
  export const controlSocketPath = (root, viewId) => controlSocketPathFor(process.platform, root, viewId);
78
+ /**
79
+ * Endpoint a hosted child's editor-state reporter should connect to.
80
+ *
81
+ * The runner knows which endpoint it actually bound and injects it into the
82
+ * child env as AGENT_BOARD_CONTROL_SOCKET (issue #103): since #70 the owned
83
+ * runner binds a per-instance address, while the reporter kept dialing the
84
+ * stable per-view one, so it never connected and the attach gate lost its
85
+ * authoritative editor state. The injected value wins; the stable address
86
+ * remains the fallback for legacy hosts and older runners that predate the key.
87
+ * @param {{ envSocketPath?: string | null, platform: "win32"|"linux"|"darwin", root: string, viewId: string }} args
88
+ */
89
+ export function resolveControlEndpoint({ envSocketPath, platform, root, viewId }) {
90
+ const injected = typeof envSocketPath === "string" ? envSocketPath.trim() : "";
91
+ return injected.length > 0 ? injected : controlSocketPathFor(platform, root, viewId);
92
+ }
74
93
  /**
75
94
  * Per-instance control endpoint. Each new host instance binds its own socket/pipe,
76
95
  * so a superseded runner can never unlink the current owner's endpoint (issue #70).
@@ -89,6 +108,35 @@ export function hostEndpointPathFor(platform, root, viewId, instanceId) {
89
108
  }
90
109
  /** @param {string} root @param {string} viewId */
91
110
  export const screenLogPath = (root, viewId) => path.join(viewDir(root, viewId), "screen.log");
111
+ /**
112
+ * Well-known endpoint for the board-root View State Coordinator (issue #91, spec D3).
113
+ * Exactly one coordinator may own a root (token-fenced lease), so one stable path
114
+ * suffices; a stale POSIX socket left by a crashed coordinator is unlinked by the
115
+ * new lease owner before bind. win32 pipe names embed a 16-hex hash of the
116
+ * *normalized* root to stay under the 256-char limit and keep per-root isolation.
117
+ * @param {"win32"|"linux"|"darwin"} platform
118
+ * @param {string} root
119
+ */
120
+ export function coordinatorEndpointPathFor(platform, root) {
121
+ if (platform === "win32") {
122
+ // Normalize before hashing: the pipe name must be invariant to the root's
123
+ // spelling (C:/x vs C:\x, trailing separators, dot segments). The lock path
124
+ // derived from the same root already is (via path.join); a mismatch yields
125
+ // "same lock, two pipes" — the panel probes a pipe nobody bound, spawns
126
+ // replacements that cannot take the held lease, and locks itself out
127
+ // (issue #124). resolve() is idempotent on canonical roots, so coordinators
128
+ // already deployed keep their pipe name and need no restart.
129
+ // win32 semantics are named explicitly rather than using the ambient
130
+ // path.resolve: this branch must emit the same pipe name on every host OS
131
+ // (on POSIX, ambient resolve treats `C:\x` as a relative path: no
132
+ // drive-letter or backslash-separator semantics), so platform-injected
133
+ // tests and CI behave identically everywhere.
134
+ const normalized = path.win32.resolve(root);
135
+ const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
136
+ return `\\\\.\\pipe\\agent-board-coordinator-${hash}`;
137
+ }
138
+ return path.join(root, "coordinator.sock");
139
+ }
92
140
  /** @param {string} root @param {string} viewId */
93
141
  export const hostPidPath = (root, viewId) => path.join(viewDir(root, viewId), "host-pid.json");
94
142
  /** @param {string} root @param {string} viewId */
package/src/core/pid.mjs CHANGED
@@ -1,4 +1,5 @@
1
- /** Process liveness checks. */
1
+ /** Process liveness checks and process identity capture. */
2
+ import { readFileSync } from "node:fs";
2
3
 
3
4
  /**
4
5
  * Whether `pid` refers to a live process.
@@ -40,3 +41,33 @@ export function killProcess(pid, graceMs = 4000) {
40
41
  }
41
42
  }, graceMs).unref?.();
42
43
  }
44
+
45
+ /**
46
+ * POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
47
+ * across exec(2). Distinguishes an owned-live pid from a reused one; null on
48
+ * failure or non-Linux platforms. Mirrors the runner's captureStartToken
49
+ * (issue #70); shared here for host-meta lease identity (issue #112).
50
+ * @param {number|null|undefined} pid
51
+ * @returns {string|null}
52
+ */
53
+ export function captureStartToken(pid) {
54
+ if (process.platform !== "linux" || !pid) return null;
55
+ try {
56
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
57
+ // comm (field 2) may contain spaces and parens — fields resume AFTER the
58
+ // last ')'. fields[0] is state (field 3) → starttime (field 22) is [19].
59
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trimStart();
60
+ return afterComm.split(/\s+/)[19] ?? null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Launch-time identity for the current process, as stamped on host-meta
68
+ * acquisitions (issue #112).
69
+ * @returns {{pid: number, startToken: string|null}}
70
+ */
71
+ export function currentProcessIdentity() {
72
+ return { pid: process.pid, startToken: captureStartToken(process.pid) };
73
+ }
@@ -1,4 +1,13 @@
1
1
  /**
2
+ * LEGACY FALLBACK (issue #91 phase 6 marking): the shrink-and-hold jiggle is
3
+ * kept only for pre-protocol runners and the AGENT_BOARD_TERMINAL_SNAPSHOT=0
4
+ * kill switch. It is NOT a success condition of the snapshot+subscribe
5
+ * protocol path — the protocol re-baselines the view from runner-owned
6
+ * snapshots (phase 4), and jiggle arming is skipped once the probe resolves
7
+ * to the protocol attach mode. Removal condition: the installed runner fleet
8
+ * is on the snapshot protocol baseline (wire-detectable via hello protocol
9
+ * fields).
10
+ *
2
11
  * Injectable orchestration for the attach shrink-and-hold jiggle protocol.
3
12
  *
4
13
  * Replaces the pulse-pair jiggle (shrink → 200ms → restore) with a
@@ -60,6 +69,11 @@ const NO_FRAME_RESTORE_MS = 6000;
60
69
  */
61
70
  const POST_RESTORE_VERIFY_MS = 900;
62
71
 
72
+ /** Lifetime cap on runtime desync heals (issue #11): a persistent misdiagnosis
73
+ * must not flicker the screen forever; after this many attempts the backstop
74
+ * stays quiet until the controller is recreated. Consumed on heal() entry. */
75
+ const HEAL_MAX_PER_LIFETIME = 5;
76
+
63
77
  /**
64
78
  * @typedef {Object} JiggleRetryControllerDeps
65
79
  * @property {(cols: number, rows: number) => void} sendResize - Resize the child PTY.
@@ -88,6 +102,8 @@ export function createJiggleRetryController(deps) {
88
102
  let chainTimer = null;
89
103
  /** @type {unknown | null} */
90
104
  let g1Timer = null;
105
+ /** Runtime heals spent (issue #11); never reset by start()/restoreAndStop(). */
106
+ let healCount = 0;
91
107
 
92
108
  function clearChainTimer() {
93
109
  if (chainTimer === null) return;
@@ -199,27 +215,49 @@ export function createJiggleRetryController(deps) {
199
215
 
200
216
  /**
201
217
  * Feed one socket output chunk. A clear wins over the re-arm when both
202
- * appear in one chunk. The first TUI frame restores the held size (the
218
+ * appear in one chunk — but frame cognition is still learned from that
219
+ * chunk: a clear only proves the child redraws, not that it isn't a TUI
220
+ * (a live fullRender chunk bundles a frame start with its clear, issue #11).
221
+ * The first TUI frame restores the held size (the
203
222
  * child is now rendering and will fullRender on the width delta) and
204
223
  * does NOT reschedule the chain; if G1 already released the hold before
205
224
  * the TUI booted, the frame instead re-arms a fresh hold so the running
206
225
  * child still sees a width delta (F1 slow-boot probe).
226
+ * Terminal chain states still learn frame cognition from later output
227
+ * (issue #106) — cognition only; re-opening the protocol stays heal()'s job.
207
228
  * @param {string} data
208
229
  */
209
230
  function feed(data) {
210
- if (state.clearDetected) return; // chain done; nothing left to detect
211
- if (state.stopped) return; // chain ended (G2/G3/G4); output is inert
231
+ // Terminal chain states: a clear was seen (chain done) or the chain ended
232
+ // (G2/G3/G4). Output no longer drives the retry protocol — but frame
233
+ // cognition must still be learned from it (issue #106): a TUI whose first
234
+ // frame lands after the chain settled must still open the runtime desync
235
+ // backstop's gate 2 (issue #11), otherwise heal() stays unreachable for the
236
+ // rest of this connection. Cognition only — no timers, no resizes, and no
237
+ // retry-state change: re-opening the protocol is heal()'s job (rate-limited
238
+ // and lifetime-capped), not an output chunk's.
239
+ if (state.clearDetected || state.stopped) {
240
+ if (tuiFrameSeen) return; // latched already — nothing left to learn
241
+ const terminal = feedOutput(state, data, carry);
242
+ carry = terminal.carry; // keep cross-chunk marker detection intact
243
+ if (terminal.frameStartFound) tuiFrameSeen = true;
244
+ return;
245
+ }
212
246
  const result = feedOutput(state, data, carry);
213
247
  state = result.state;
214
248
  carry = result.carry;
249
+ // Learn frame cognition unconditionally, BEFORE the clear branch: a
250
+ // clear-wins chunk must not swallow it (issue #11), and only the FIRST
251
+ // frame ever seen drives the re-arm/fast-path logic below.
252
+ const firstFrame = result.frameStartFound && !tuiFrameSeen;
253
+ if (result.frameStartFound) tuiFrameSeen = true;
215
254
  if (result.clearFound) {
216
255
  clearAllTimers();
217
256
  restoreIfHeld();
218
257
  state = stopRetry({ ...state, clearDetected: true });
219
258
  return;
220
259
  }
221
- if (result.frameStartFound && !tuiFrameSeen) {
222
- tuiFrameSeen = true;
260
+ if (firstFrame) {
223
261
  clearG1Timer();
224
262
  if (held) {
225
263
  restoreIfHeld(); // fast path: child is rendering, width delta now lands
@@ -242,6 +280,44 @@ export function createJiggleRetryController(deps) {
242
280
  }
243
281
  }
244
282
 
283
+ /**
284
+ * Runtime desync backstop (issue #11): re-arm the shrink-and-hold protocol
285
+ * mid-session. Unlike start(), tuiFrameSeen is preserved (the child has
286
+ * rendered), G1 is not armed (frames are flowing), and the budget is
287
+ * lifetime-capped so a misdiagnosis cannot flicker the screen forever.
288
+ * Consumes one budget slot on entry, including the tiny-terminal give-up.
289
+ * @param {number} cols
290
+ * @param {number} rows
291
+ * @returns {boolean} true when a heal hold was armed.
292
+ */
293
+ function heal(cols, rows) {
294
+ if (healCount >= HEAL_MAX_PER_LIFETIME) return false;
295
+ healCount++;
296
+ clearAllTimers();
297
+ if (held) {
298
+ // Unwind any live hold (e.g. a previous clear-less heal) first.
299
+ sendResize(originalCols, originalRows);
300
+ restored = true;
301
+ held = false;
302
+ }
303
+ state = createJiggleRetryState();
304
+ carry = "";
305
+ originalCols = cols;
306
+ originalRows = rows;
307
+ holdSize = resizeJiggleSize(cols, rows);
308
+ if (!holdSize) {
309
+ state = stopRetry(state);
310
+ held = false;
311
+ restored = true;
312
+ return false;
313
+ }
314
+ sendResize(holdSize.cols, holdSize.rows);
315
+ held = true;
316
+ restored = false;
317
+ scheduleNextRetry(); // G2 backoff re-shrinks while a renderer is seen but no clear follows
318
+ return true;
319
+ }
320
+
245
321
  /**
246
322
  * Restore the held size (if any) and stop all chain activity. Used by the
247
323
  * component on close/detach while the socket is still usable (G3).
@@ -272,8 +348,9 @@ export function createJiggleRetryController(deps) {
272
348
  return {
273
349
  start,
274
350
  feed,
351
+ heal,
275
352
  restoreAndStop,
276
353
  notifyExternalResize,
277
- getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows, holdSize }),
354
+ getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows, holdSize, healCount }),
278
355
  };
279
356
  }
@@ -31,13 +31,20 @@ export function evaluateAttachReconnect({ everConnected, disconnectedAt, connect
31
31
  }
32
32
 
33
33
  /**
34
- * Detach-key policy: while the socket is down a key can never reach the child,
35
- * so treat ← / ctrl+] as "leave the view" unconditionally; otherwise keep the
36
- * existing empty-input-line gate (issue #48 comment 1).
34
+ * Detach-key policy (issue #91 Phase 6, spec §D1): while the socket is down a
35
+ * key can never reach the child, so treat ← as "leave the view"
36
+ * unconditionally; otherwise detach only when the pushed editorEmpty side
37
+ * channel is exactly true. false and null/undefined mean "forward" — the
38
+ * explicit conservative policy; callers pass `editorEmpty === true` so an
39
+ * unknown state can never arm the detach, and no terminal-buffer heuristic is
40
+ * consulted (the deleted terminal-buffer heuristic family — issues
41
+ * #42/#66/#69/#103 — stays deleted).
37
42
  * @param {boolean} connected
38
- * @param {boolean} childInputLooksEmpty
43
+ * @param {boolean | null | undefined} editorEmpty
39
44
  * @returns {boolean}
40
45
  */
41
- export function shouldEscapeAttach(connected, childInputLooksEmpty) {
42
- return !connected || childInputLooksEmpty;
46
+ export function shouldEscapeAttach(connected, editorEmpty) {
47
+ // Normalize to a strict boolean: a null/undefined editorEmpty (unknown)
48
+ // forwards — never escapes — and must not leak through the return value.
49
+ return !connected || editorEmpty === true;
43
50
  }
@@ -43,6 +43,26 @@ export function projectPtyCursor(buf, start, height) {
43
43
  return { row, col: buf.cursorX };
44
44
  }
45
45
 
46
+ /**
47
+ * Duck-typed read of the child terminal's DECTCEM visibility state.
48
+ *
49
+ * pi-tui hides the hardware cursor (ESC[?25l) on essentially every frame and
50
+ * still parks it for IME positioning, so the xterm cursor position outlives its
51
+ * visibility: it is a rendering byproduct, not a request to show a cursor. The
52
+ * attach projection must not resurrect that parked cell as a visible block, and
53
+ * must equally not hide a cursor the child wants shown (shells, vim,
54
+ * PI_HARDWARE_CURSOR=1). Only an explicit `true` from xterm's cursor service
55
+ * counts as hidden; anything unknown (renamed internals, another @xterm build)
56
+ * falls back to visible, which is the pre-#102 behavior.
57
+ */
58
+ export function isPtyCursorHidden(term) {
59
+ try {
60
+ return term?._core?.coreService?.isCursorHidden === true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
46
66
  /**
47
67
  * Coalesce PTY parser callbacks into a bounded stream of repaint requests.
48
68
  *
@@ -70,3 +90,33 @@ export function createAttachOutputRenderScheduler(requestRender, delayMs = ATTAC
70
90
  },
71
91
  };
72
92
  }
93
+
94
+ /**
95
+ * Classify the PTY cursor's alignment for runtime desync detection (issue #11).
96
+ *
97
+ * Healthy idle pi: the child pi-tui parks the hardware cursor on the editor
98
+ * marker, whose cell is the inverse-video "fake cursor" — so the cursor cell
99
+ * itself is inverse. A desynced buffer leaves the cursor parked elsewhere
100
+ * (typically where the last differential write ended), on a non-inverse cell.
101
+ * Width-0 cells are CJK continuation cells and out-of-range columns sit past
102
+ * the line's cells; in both cases the meaningful attribute lives on the
103
+ * preceding cell, so we look left. Returns:
104
+ * "aligned" — cursor resolves to an inverse cell (healthy);
105
+ * "misaligned" — cursor resolves to a non-inverse cell (candidate desync;
106
+ * callers gate this with an output-quietness window);
107
+ * "unknown" — no cursor (scrolled out of the projected viewport) or no
108
+ * buffer line (defensive); never treat these as desync.
109
+ */
110
+ export function detectCursorDesync(buf, cursor) {
111
+ if (!cursor) return "unknown";
112
+ const line = buf.getLine(cursor.row);
113
+ if (!line) return "unknown";
114
+ let x = cursor.col;
115
+ let cell = line.getCell(x);
116
+ while ((!cell || cell.getWidth() === 0) && x > 0) {
117
+ x--;
118
+ cell = line.getCell(x);
119
+ }
120
+ if (!cell) return "misaligned";
121
+ return cell.isInverse() ? "aligned" : "misaligned";
122
+ }