@zhuxixi/pi-agent-board 0.7.0 → 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 +26 -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-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-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.mjs +8 -7
- package/runner/pty-runner.mjs +616 -27
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- 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/core/control-protocol.mjs +482 -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/locks.mjs +68 -14
- package/src/core/paths.mjs +35 -3
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +27 -3
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +20 -0
- package/src/core/state-commands.mjs +88 -6
- 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/index.ts +12 -4
- package/src/runtime/service.mjs +284 -16
- package/src/ui/dashboard.ts +41 -33
- package/src/ui/pty-attach.ts +236 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -74,8 +74,16 @@ const LAST_ACTIVITY_STAMP_KINDS = new Set(["mark_queued", "archive_view", "adopt
|
|
|
74
74
|
* full status content for a run that has no materialized status yet. Every
|
|
75
75
|
* other kind keeps PR #1's "patch presence ≠ file requirement" semantics
|
|
76
76
|
* (e.g. mark_completed's `status: {autoState: null}` on a legacy row without
|
|
77
|
-
* a status file must not fabricate one).
|
|
78
|
-
|
|
77
|
+
* a status file must not fabricate one).
|
|
78
|
+
*
|
|
79
|
+
* `run_progress` joins them (issue #91 hard-down residual closure): its beat
|
|
80
|
+
* patch IS the runner's full in-memory status, and when the coordinator was
|
|
81
|
+
* down through the runner's boot window (mark_queued applied, run_started
|
|
82
|
+
* lost) the first post-recovery beat is the only surviving source that can
|
|
83
|
+
* recreate the file — without it beats and finalize reject stale_run forever
|
|
84
|
+
* and the row can never converge. The decision layer gates this on a
|
|
85
|
+
* qualified live-run beat (full-shaped patch, matching string runId). */
|
|
86
|
+
const STATUS_BOOTSTRAP_KINDS = new Set(["run_started", "followup_started", "run_progress"]);
|
|
79
87
|
|
|
80
88
|
const root = process.argv[2];
|
|
81
89
|
if (!root) {
|
|
@@ -312,32 +320,50 @@ async function main() {
|
|
|
312
320
|
* guards stamp only files strictly behind the record revision: the live path
|
|
313
321
|
* always qualifies (the global counter is monotonic), while replay repairs
|
|
314
322
|
* just the half that missed its write in a crash window and never moves the
|
|
315
|
-
* other half backwards.
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
323
|
+
* other half backwards.
|
|
324
|
+
*
|
|
325
|
+
* Pairing invariant (issue #91 final review): EVERY applied revision pairs
|
|
326
|
+
* BOTH files. A state-only patch (e.g. patch_fields markVisited) restamps
|
|
327
|
+
* the existing status file's revision with no content change, and a
|
|
328
|
+
* status-only patch restamps the state file the same way — a healthy
|
|
329
|
+
* coordinator can never produce the asymmetric pair the reader-side
|
|
330
|
+
* revision check (reconcile) treats as a crash signature. Journaled
|
|
331
|
+
* state-only records heal on replay through this shared path. Residual:
|
|
332
|
+
* a torn TRANSIENT beat (crash between the two writes, never journaled)
|
|
333
|
+
* stays half-paired — ms-window × adjacent-crash, documented in the spec.
|
|
334
|
+
*
|
|
335
|
+
* A status patch without a status file is skipped — patch presence ≠ file
|
|
336
|
+
* requirement (mark_completed always emits `status: {autoState: null}`).
|
|
337
|
+
* The status run binding is the decision-time one (`statusRunId`, recorded
|
|
338
|
+
* in the journal) so replay cannot re-bind a runId-less patch to whatever
|
|
339
|
+
* run is current on disk at replay time; `state.currentRunId` remains only
|
|
340
|
+
* as a legacy fallback for records written before statusRunId existed.
|
|
322
341
|
*/
|
|
323
342
|
function materialize(viewId, command, mutate, revision, statusRunId) {
|
|
324
343
|
withViewLockSync(root, viewId, "state-materialize", () => {
|
|
325
344
|
const state = readState(root, viewId);
|
|
326
|
-
if (
|
|
327
|
-
writeState(root,
|
|
345
|
+
if (state && (state.materializedRevision ?? 0) < revision) {
|
|
346
|
+
writeState(root, mutate?.state
|
|
347
|
+
? { ...state, ...mutate.state, materializedRevision: revision }
|
|
348
|
+
: { ...state, materializedRevision: revision });
|
|
328
349
|
}
|
|
329
350
|
const runId = command?.runId ?? statusRunId ?? state?.currentRunId ?? null;
|
|
330
|
-
if (
|
|
331
|
-
|
|
351
|
+
if (!runId) return;
|
|
352
|
+
const status = readStatus(root, viewId, runId);
|
|
353
|
+
if (mutate?.status) {
|
|
332
354
|
if (!status && STATUS_BOOTSTRAP_KINDS.has(command?.kind)) {
|
|
333
|
-
// run_started / followup_started
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// carries everything meaningful.
|
|
355
|
+
// run_started / followup_started / (bootstrapping) run_progress
|
|
356
|
+
// carry the full status content for a run that has no status
|
|
357
|
+
// file yet — create it. Identity fields come from the command
|
|
358
|
+
// context; the patch carries everything meaningful.
|
|
337
359
|
writeStatus(root, { version: 1, runId, viewId, ...mutate.status, materializedRevision: revision });
|
|
338
360
|
} else if (status && (status.materializedRevision ?? 0) < revision) {
|
|
339
361
|
writeStatus(root, { ...status, ...mutate.status, materializedRevision: revision });
|
|
340
362
|
}
|
|
363
|
+
} else if (status && (status.materializedRevision ?? 0) < revision) {
|
|
364
|
+
// State-only patch: restamp the existing status file's revision with
|
|
365
|
+
// no content change (pairing invariant above).
|
|
366
|
+
writeStatus(root, { ...status, materializedRevision: revision });
|
|
341
367
|
}
|
|
342
368
|
});
|
|
343
369
|
}
|
package/runner/state-runner.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { finalizeEvidence, readEvidence, summarizeEvidence, writeEvidence } from
|
|
|
14
14
|
import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
|
|
15
15
|
import { readState, readStatus, readMeta, writeState, writeStatus } from "../src/core/store.mjs";
|
|
16
16
|
import { sendStateCommand } from "../src/core/coordinator-client.mjs";
|
|
17
|
+
import { commandRejectDiagnostic } from "../src/core/state-commands.mjs";
|
|
17
18
|
|
|
18
19
|
async function main() {
|
|
19
20
|
const configPath = process.argv[2];
|
|
@@ -69,12 +70,12 @@ async function main() {
|
|
|
69
70
|
|
|
70
71
|
if (result.status === "applied") {
|
|
71
72
|
appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
|
|
72
|
-
} else if (result.reason === "manual_fence" || result.reason === "no_change" || result.reason === "stale_run") {
|
|
73
|
-
// Designed fences — informational, not errors: the coordinator is the
|
|
74
|
-
// authority and a manual completion wins by design.
|
|
75
|
-
appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified_skipped", message: `Auto-state classification not applied (${result.reason})`, details: { reason: result.reason } });
|
|
76
73
|
} else if (result.reason !== "coordinator_disabled") {
|
|
77
|
-
|
|
74
|
+
// Unified decided/ambiguous classification (CR r2 issue-3): decided
|
|
75
|
+
// rejects (manual_fence/stale_run/…) log as info *_skipped — the
|
|
76
|
+
// coordinator's verdict is authoritative; only genuinely ambiguous
|
|
77
|
+
// outcomes (timeout/connection_reset) keep the recovery-path warn.
|
|
78
|
+
appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, ...commandRejectDiagnostic("auto_state", "Auto-state classification", result.reason, "otherwise the next classification pass will converge the row"), details: { reason: result.reason } });
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
if (result.reason === "coordinator_disabled") {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Authoritative entry for the A11 perf gate (issue #121).
|
|
4
|
+
*
|
|
5
|
+
* Loudly refuses under coverage instrumentation: c8 sets NODE_V8_COVERAGE
|
|
6
|
+
* for its whole process tree, and instrumented latency measurements are
|
|
7
|
+
* invalid (2.5–6× inflation). Silently skipping here would manufacture a
|
|
8
|
+
* fake-green gate, so refusal is a nonzero exit with an explicit message.
|
|
9
|
+
*
|
|
10
|
+
* This script is the side-effect layer only — no decision logic, no
|
|
11
|
+
* measurement. The pure gate decision lives in test-support/perf-gate.mjs
|
|
12
|
+
* and serves the test file; do not merge the layers (spec §3.2/§4).
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
if (process.env.NODE_V8_COVERAGE !== undefined) {
|
|
18
|
+
console.error(
|
|
19
|
+
"run-perf-gate: refusing to measure under coverage instrumentation " +
|
|
20
|
+
"(NODE_V8_COVERAGE is set). Perf assertions are only valid without c8; " +
|
|
21
|
+
"run `npm run test:perf` directly, outside any coverage wrapper.",
|
|
22
|
+
);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const perfTest = fileURLToPath(new URL("../test/terminal-model-perf.test.mjs", import.meta.url));
|
|
27
|
+
// No --test-concurrency=1: the flag requires Node 21+ but package.json
|
|
28
|
+
// declares engines >=20, and it is a no-op while the gate runs a single
|
|
29
|
+
// file (spec D3, revised 2026-09-20). Re-add it when a second perf file
|
|
30
|
+
// lands — parallel measurement across files must stay impossible.
|
|
31
|
+
const child = spawn(
|
|
32
|
+
process.execPath,
|
|
33
|
+
["--test", perfTest],
|
|
34
|
+
{ stdio: "inherit", env: { ...process.env, AGENT_BOARD_PERF_GATE: "1" } },
|
|
35
|
+
);
|
|
36
|
+
child.on("error", (err) => {
|
|
37
|
+
console.error(`run-perf-gate: failed to spawn the perf suite: ${err.message}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
});
|
|
40
|
+
child.on("exit", (code) => process.exit(code ?? 1));
|
|
@@ -106,7 +106,7 @@ export async function openDashboard(
|
|
|
106
106
|
currentThinkingLevel: options.currentThinkingLevel ?? "off",
|
|
107
107
|
});
|
|
108
108
|
interval = setInterval(() => {
|
|
109
|
-
service.reconcile();
|
|
109
|
+
void service.reconcile().catch(() => {});
|
|
110
110
|
comp.refresh();
|
|
111
111
|
requestDashboardRender(tui);
|
|
112
112
|
}, POLL_MS);
|
|
@@ -146,8 +146,8 @@ export async function dashboardAttachLoop(
|
|
|
146
146
|
let again = true;
|
|
147
147
|
while (again) {
|
|
148
148
|
const currentId = currentViewId(ctx, service);
|
|
149
|
-
if (currentId) service.markVisited?.(currentId);
|
|
150
|
-
service.reconcile();
|
|
149
|
+
if (currentId) void service.markVisited?.(currentId)?.catch(() => {});
|
|
150
|
+
void service.reconcile().catch(() => {});
|
|
151
151
|
const result = await openDashboard(ctx, service, {
|
|
152
152
|
initialSelectedId: selectedId,
|
|
153
153
|
currentThinkingLevel: getThinkingLevel?.(),
|
|
@@ -185,9 +185,9 @@ async function attach(
|
|
|
185
185
|
// Single async resolver entry: real probe + bounded recovery decide readiness (issue #70).
|
|
186
186
|
const plan = planAttachResolved(await service.resolveAttachTarget(viewId));
|
|
187
187
|
if (plan.plan === "open-pty") {
|
|
188
|
-
service.markVisited?.(viewId);
|
|
188
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
189
189
|
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
|
|
190
|
-
service.markVisited?.(viewId);
|
|
190
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
191
191
|
return { action: result.action === "closed" ? "closed" : "detached" };
|
|
192
192
|
}
|
|
193
193
|
if (plan.plan === "session-switch") {
|
|
@@ -197,7 +197,7 @@ async function attach(
|
|
|
197
197
|
return { action: "none" };
|
|
198
198
|
}
|
|
199
199
|
const name = latest.meta.name;
|
|
200
|
-
service.markVisited?.(viewId);
|
|
200
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
201
201
|
const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable");
|
|
202
202
|
const result = await ctx.switchSession(latest.meta.sessionFile, {
|
|
203
203
|
withSession: async (replaced) => {
|
|
@@ -296,8 +296,8 @@ function installBackToDashboard(ctx: ExtensionCommandContext, service: ReturnTyp
|
|
|
296
296
|
try {
|
|
297
297
|
let selectedId = currentViewId(ctx, service);
|
|
298
298
|
while (true) {
|
|
299
|
-
if (selectedId) service.markVisited?.(selectedId);
|
|
300
|
-
service.reconcile();
|
|
299
|
+
if (selectedId) void service.markVisited?.(selectedId)?.catch(() => {});
|
|
300
|
+
void service.reconcile().catch(() => {});
|
|
301
301
|
const result = await openDashboard(ctx, service, { initialSelectedId: selectedId });
|
|
302
302
|
if (result.action !== "attach") return;
|
|
303
303
|
selectedId = result.viewId;
|
|
@@ -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;
|