@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.
- package/CHANGELOG.md +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- 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
|
+
}
|
package/src/core/launch.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/core/locks.mjs
CHANGED
|
@@ -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 =
|
|
223
|
-
if (code
|
|
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
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
const inspectedToken =
|
|
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);
|
package/src/core/paths.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
211
|
-
|
|
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 (
|
|
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
|
|
35
|
-
* so treat ←
|
|
36
|
-
*
|
|
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}
|
|
43
|
+
* @param {boolean | null | undefined} editorEmpty
|
|
39
44
|
* @returns {boolean}
|
|
40
45
|
*/
|
|
41
|
-
export function shouldEscapeAttach(connected,
|
|
42
|
-
|
|
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
|
+
}
|