@zhuxixi/pi-agent-board 0.3.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/IMPLEMENTATION_PLAN.md +920 -0
- package/LICENSE +21 -0
- package/PRD.md +484 -0
- package/PROGRESS.md +127 -0
- package/README.md +131 -0
- package/VERIFY.md +113 -0
- package/docs/BATCH_SELECTION_READ_FLOW.md +277 -0
- package/docs/EXPLORATION.md +187 -0
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +579 -0
- package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +704 -0
- package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +499 -0
- package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +366 -0
- package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +105 -0
- package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +142 -0
- package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +59 -0
- package/index.ts +6 -0
- package/package.json +81 -0
- package/runner/job-runner.mjs +420 -0
- package/runner/pty-runner.mjs +310 -0
- package/runner/state-runner.mjs +120 -0
- package/runner/title-runner.mjs +80 -0
- package/scripts/patch-vulns.mjs +59 -0
- package/src/commands/agent-board.ts +318 -0
- package/src/commands/attach-flow.ts +231 -0
- package/src/commands/bg.ts +70 -0
- package/src/core/atomic.mjs +145 -0
- package/src/core/auto-state.mjs +320 -0
- package/src/core/dashboard-render.mjs +10 -0
- package/src/core/derive.mjs +114 -0
- package/src/core/diagnostics.mjs +109 -0
- package/src/core/events.mjs +268 -0
- package/src/core/evidence.mjs +242 -0
- package/src/core/follow-up-queue.mjs +193 -0
- package/src/core/heuristics.mjs +240 -0
- package/src/core/ids.mjs +35 -0
- package/src/core/invocation.mjs +43 -0
- package/src/core/launch-options.mjs +317 -0
- package/src/core/launch.mjs +116 -0
- package/src/core/locks.mjs +80 -0
- package/src/core/paths.mjs +86 -0
- package/src/core/pid.mjs +42 -0
- package/src/core/prewarm-schedule.mjs +41 -0
- package/src/core/prompt-transport.mjs +13 -0
- package/src/core/pty-attach-jiggle-retry.mjs +90 -0
- package/src/core/pty-attach-render.mjs +51 -0
- package/src/core/pty-input.mjs +15 -0
- package/src/core/pty-links.mjs +71 -0
- package/src/core/pty-scroll.mjs +155 -0
- package/src/core/pty-support.mjs +327 -0
- package/src/core/repo.mjs +47 -0
- package/src/core/rows.mjs +290 -0
- package/src/core/screen-log-gc.mjs +198 -0
- package/src/core/screen-log.mjs +160 -0
- package/src/core/session-view.mjs +174 -0
- package/src/core/steering-prompts.mjs +34 -0
- package/src/core/steering.mjs +133 -0
- package/src/core/store.mjs +308 -0
- package/src/core/title.mjs +43 -0
- package/src/core/types.mjs +380 -0
- package/src/core/worktree.mjs +64 -0
- package/src/index.ts +109 -0
- package/src/runtime/service.mjs +1194 -0
- package/src/ui/dashboard-evidence.mjs +85 -0
- package/src/ui/dashboard.ts +1952 -0
- package/src/ui/pty-attach.ts +1378 -0
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhuxixi/pi-agent-board",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"exports": "./index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"index.ts",
|
|
10
|
+
"src",
|
|
11
|
+
"scripts",
|
|
12
|
+
"runner",
|
|
13
|
+
"README.md",
|
|
14
|
+
"VERIFY.md",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"docs",
|
|
17
|
+
"PRD.md",
|
|
18
|
+
"IMPLEMENTATION_PLAN.md",
|
|
19
|
+
"PROGRESS.md"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"pi-package",
|
|
23
|
+
"pi-extension",
|
|
24
|
+
"pi",
|
|
25
|
+
"pi-coding-agent",
|
|
26
|
+
"agent-board",
|
|
27
|
+
"background-agents",
|
|
28
|
+
"dashboard"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/zhuxixi/pi-agent-board.git"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/zhuxixi/pi-agent-board/issues"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/zhuxixi/pi-agent-board#readme",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20"
|
|
44
|
+
},
|
|
45
|
+
"pi": {
|
|
46
|
+
"extensions": [
|
|
47
|
+
"./src/index.ts"
|
|
48
|
+
],
|
|
49
|
+
"video": "https://github.com/zhuxixi/pi-agent-board/raw/refs/heads/main/assets/demo.mp4",
|
|
50
|
+
"image": "https://github.com/zhuxixi/pi-agent-board/raw/refs/heads/main/assets/banner.png"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"postinstall": "node scripts/patch-vulns.mjs",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"test": "node --test test/*.test.mjs",
|
|
56
|
+
"pack:dry": "npm pack --dry-run",
|
|
57
|
+
"verify": "npm run typecheck && npm test && npm run pack:dry"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
61
|
+
"@earendil-works/pi-tui": "*"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@earendil-works/pi-coding-agent": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"@earendil-works/pi-tui": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@earendil-works/pi-coding-agent": "0.79.8",
|
|
73
|
+
"@earendil-works/pi-tui": "0.79.8",
|
|
74
|
+
"@types/node": "^25.9.1",
|
|
75
|
+
"typescript": "^5.9.3"
|
|
76
|
+
},
|
|
77
|
+
"dependencies": {
|
|
78
|
+
"@xterm/headless": "^6.0.0",
|
|
79
|
+
"node-pty": "^1.1.0"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Detached job-runner shim (plain ESM — must not depend on Pi's jiti loader).
|
|
4
|
+
*
|
|
5
|
+
* Usage: node job-runner.mjs <configPath>
|
|
6
|
+
*
|
|
7
|
+
* Owns one run: spawns a headless Pi worker (`pi --mode json -p --session <file> <prompt>`),
|
|
8
|
+
* streams its JSON events into events.jsonl, reduces them into status.json + the row's
|
|
9
|
+
* state.json, and finalizes on exit. Survives the parent Pi process exiting/reloading.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { appendLine, readJson } from "../src/core/atomic.mjs";
|
|
14
|
+
import { createRunStatus, finalizeRun, projectViewState, reduceEvent } from "../src/core/events.mjs";
|
|
15
|
+
import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
|
|
16
|
+
import { applyAutoStateToStatus, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState } from "../src/core/auto-state.mjs";
|
|
17
|
+
import { appendDiagnostic } from "../src/core/diagnostics.mjs";
|
|
18
|
+
import { emptyEvidenceSnapshot, finalizeEvidence, reduceEvidence, summarizeEvidence, writeEvidence, writeRunEvidence } from "../src/core/evidence.mjs";
|
|
19
|
+
import { claimNextFollowUp, completeFollowUp, releaseFollowUp } from "../src/core/follow-up-queue.mjs";
|
|
20
|
+
import { newRunId } from "../src/core/ids.mjs";
|
|
21
|
+
import { launchRun } from "../src/core/launch.mjs";
|
|
22
|
+
import * as P from "../src/core/paths.mjs";
|
|
23
|
+
import { readState, writeState, writeStatus } from "../src/core/store.mjs";
|
|
24
|
+
import { readSteering, recordPlanReady } from "../src/core/steering.mjs";
|
|
25
|
+
import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../src/core/steering-prompts.mjs";
|
|
26
|
+
|
|
27
|
+
const WRITE_THROTTLE_MS = 250;
|
|
28
|
+
|
|
29
|
+
/** @param {string[]} args */
|
|
30
|
+
function redactWorkerArgs(args) {
|
|
31
|
+
return args.map((arg, idx) => {
|
|
32
|
+
const prev = args[idx - 1];
|
|
33
|
+
if (["--api-key", "--token", "--password", "--secret"].includes(prev)) return "[redacted]";
|
|
34
|
+
if (/^(sk-|gho_|ghp_)/.test(arg)) return "[redacted]";
|
|
35
|
+
return arg;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function main() {
|
|
40
|
+
const configPath = process.argv[2];
|
|
41
|
+
if (!configPath) {
|
|
42
|
+
process.stderr.write("job-runner: missing config path\n");
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
/** @type {import("../src/core/types.mjs").RunConfig|null} */
|
|
46
|
+
const config = readJson(configPath, null);
|
|
47
|
+
if (!config) {
|
|
48
|
+
process.stderr.write(`job-runner: cannot read config ${configPath}\n`);
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { root, viewId, runId } = config;
|
|
53
|
+
const stdoutLog = P.stdoutPath(root, viewId, runId);
|
|
54
|
+
const stderrLog = P.stderrPath(root, viewId, runId);
|
|
55
|
+
const eventsLog = P.eventsPath(root, viewId, runId);
|
|
56
|
+
|
|
57
|
+
let status = createRunStatus(config, null, Date.now());
|
|
58
|
+
let evidence = emptyEvidenceSnapshot({ viewId, runId, source: "json-runner" });
|
|
59
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, code: "runner_start", message: "Runner started", details: { kind: config.kind, cwd: config.cwd, model: config.model } });
|
|
60
|
+
writeStatus(root, status);
|
|
61
|
+
writeRunEvidence(root, evidence);
|
|
62
|
+
writeEvidence(root, evidence);
|
|
63
|
+
writeState(root, projectViewState(status, Date.now(), readState(root, viewId)));
|
|
64
|
+
|
|
65
|
+
// Build worker args: pi --mode json -p --session <file> [--model m] [--thinking l] [--tools t] <prompt>
|
|
66
|
+
const args = [
|
|
67
|
+
...config.piArgsPrefix,
|
|
68
|
+
"--mode",
|
|
69
|
+
"json",
|
|
70
|
+
"-p",
|
|
71
|
+
"--session",
|
|
72
|
+
config.sessionFile,
|
|
73
|
+
];
|
|
74
|
+
if (config.model) args.push("--model", config.model);
|
|
75
|
+
if (config.thinkingLevel) args.push("--thinking", config.thinkingLevel);
|
|
76
|
+
if (config.tools) args.push("--tools", config.tools);
|
|
77
|
+
args.push(encodePromptForCliArg(config.prompt));
|
|
78
|
+
|
|
79
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, code: "worker_spawn", message: "Worker spawned", details: { command: config.piCommand, args: redactWorkerArgs(args) } });
|
|
80
|
+
const worker = spawn(config.piCommand, args, {
|
|
81
|
+
cwd: config.cwd,
|
|
82
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
83
|
+
env: process.env,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
status.pid = worker.pid ?? null;
|
|
87
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, code: "worker_pid", message: "Worker pid recorded", details: { pid: status.pid } });
|
|
88
|
+
writeStatus(root, status);
|
|
89
|
+
|
|
90
|
+
let stoppedByUser = false;
|
|
91
|
+
let dirty = false;
|
|
92
|
+
let flushTimer = null;
|
|
93
|
+
|
|
94
|
+
const persist = (force = false) => {
|
|
95
|
+
void force;
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
status.evidenceSummary = summarizeEvidence(evidence);
|
|
98
|
+
writeStatus(root, status);
|
|
99
|
+
writeRunEvidence(root, evidence);
|
|
100
|
+
writeEvidence(root, evidence);
|
|
101
|
+
writeState(root, projectViewState(status, now, readState(root, viewId)));
|
|
102
|
+
dirty = false;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const scheduleFlush = () => {
|
|
106
|
+
if (flushTimer) {
|
|
107
|
+
dirty = true;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
persist();
|
|
111
|
+
flushTimer = setTimeout(() => {
|
|
112
|
+
flushTimer = null;
|
|
113
|
+
if (dirty) scheduleFlush();
|
|
114
|
+
}, WRITE_THROTTLE_MS);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ---- stdout: JSON event stream -----------------------------------------
|
|
118
|
+
let buffer = "";
|
|
119
|
+
const onLine = (line) => {
|
|
120
|
+
const trimmed = line.trim();
|
|
121
|
+
if (!trimmed) return;
|
|
122
|
+
appendLine(eventsLog, trimmed);
|
|
123
|
+
let event;
|
|
124
|
+
try {
|
|
125
|
+
event = JSON.parse(trimmed);
|
|
126
|
+
} catch {
|
|
127
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, level: "warn", code: "malformed_event", message: "Worker emitted malformed JSON", details: { preview: trimmed.slice(0, 160) } });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// First line is the session header {type:"session",...}; nothing to reduce.
|
|
131
|
+
if (event?.type === "session") return;
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const statusChanged = reduceEvent(status, event, now);
|
|
134
|
+
const evidenceChanged = reduceEvidence(evidence, event, now);
|
|
135
|
+
if (statusChanged || evidenceChanged) scheduleFlush();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
worker.stdout.on("data", (chunk) => {
|
|
139
|
+
const text = chunk.toString();
|
|
140
|
+
try {
|
|
141
|
+
appendLine(stdoutLog, text.replace(/\n$/, ""));
|
|
142
|
+
} catch {
|
|
143
|
+
/* ignore raw-log failures */
|
|
144
|
+
}
|
|
145
|
+
buffer += text;
|
|
146
|
+
const lines = buffer.split("\n");
|
|
147
|
+
buffer = lines.pop() ?? "";
|
|
148
|
+
for (const line of lines) onLine(line);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
worker.stderr.on("data", (chunk) => {
|
|
152
|
+
const text = chunk.toString();
|
|
153
|
+
appendLine(stderrLog, text.replace(/\n$/, ""));
|
|
154
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, level: "warn", code: "worker_stderr", message: "Worker wrote to stderr", details: { preview: text.slice(0, 300) } });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---- termination handling ----------------------------------------------
|
|
158
|
+
const stop = () => {
|
|
159
|
+
stoppedByUser = true;
|
|
160
|
+
try {
|
|
161
|
+
if (worker.pid && !worker.killed) worker.kill("SIGTERM");
|
|
162
|
+
} catch {
|
|
163
|
+
/* ignore */
|
|
164
|
+
}
|
|
165
|
+
setTimeout(() => {
|
|
166
|
+
try {
|
|
167
|
+
if (worker.pid && !worker.killed) worker.kill("SIGKILL");
|
|
168
|
+
} catch {
|
|
169
|
+
/* ignore */
|
|
170
|
+
}
|
|
171
|
+
}, 4000).unref?.();
|
|
172
|
+
};
|
|
173
|
+
process.on("SIGTERM", stop);
|
|
174
|
+
process.on("SIGINT", stop);
|
|
175
|
+
|
|
176
|
+
worker.on("error", (err) => {
|
|
177
|
+
status.error = `Failed to launch worker: ${err instanceof Error ? err.message : String(err)}`;
|
|
178
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, level: "error", code: "worker_error", message: status.error, details: {} });
|
|
179
|
+
finalizeRun(status, { exitCode: 1, stoppedByUser }, Date.now());
|
|
180
|
+
finalizeEvidence(evidence, status, Date.now());
|
|
181
|
+
persist(true);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
worker.on("close", (code) => {
|
|
186
|
+
if (buffer.trim()) onLine(buffer);
|
|
187
|
+
if (flushTimer) {
|
|
188
|
+
clearTimeout(flushTimer);
|
|
189
|
+
flushTimer = null;
|
|
190
|
+
}
|
|
191
|
+
finalizeRun(status, { exitCode: code ?? 0, stoppedByUser }, Date.now());
|
|
192
|
+
finalizeEvidence(evidence, status, Date.now());
|
|
193
|
+
appendDiagnostic(root, viewId, { source: "runner", runId, level: code ? "error" : "info", code: "worker_exit", message: `Worker exited with code ${code ?? 0}`, details: { code, stoppedByUser } });
|
|
194
|
+
// Persist the terminal state (with the heuristic summary) IMMEDIATELY so the
|
|
195
|
+
// dashboard flips to its final state at once. Then try to classify the final
|
|
196
|
+
// bucket and upgrade the summary with cheap model passes. Slow/unreachable
|
|
197
|
+
// model calls must never stall the row indefinitely.
|
|
198
|
+
persist(true);
|
|
199
|
+
if (applyHeuristicAutoState(config, status, evidence)) {
|
|
200
|
+
finalizeEvidence(evidence, status, Date.now());
|
|
201
|
+
status.evidenceSummary = summarizeEvidence(evidence);
|
|
202
|
+
persist(true);
|
|
203
|
+
}
|
|
204
|
+
maybeModelAutoState(config, status, evidence)
|
|
205
|
+
.then((changed) => {
|
|
206
|
+
if (changed) {
|
|
207
|
+
finalizeEvidence(evidence, status, Date.now());
|
|
208
|
+
status.evidenceSummary = summarizeEvidence(evidence);
|
|
209
|
+
persist(true);
|
|
210
|
+
}
|
|
211
|
+
return maybeModelSummary(config, status);
|
|
212
|
+
})
|
|
213
|
+
.then((changed) => {
|
|
214
|
+
if (changed) persist(true);
|
|
215
|
+
})
|
|
216
|
+
.catch(() => {})
|
|
217
|
+
.finally(() => {
|
|
218
|
+
finalizeSteeringIfNeeded(config, status, evidence);
|
|
219
|
+
drainQueuedFollowUp(config, status);
|
|
220
|
+
process.exit(stoppedByUser ? 0 : (code ?? 0));
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status @param {import("../src/core/types.mjs").EvidenceSnapshot} evidence */
|
|
226
|
+
function finalizeSteeringIfNeeded(config, status, evidence) {
|
|
227
|
+
if (config.kind !== "plan" && config.kind !== "plan_change") return;
|
|
228
|
+
if (status.semanticState === "failed" || status.semanticState === "stopped") return;
|
|
229
|
+
recordPlanReady(config.root, config.viewId, {
|
|
230
|
+
runId: config.runId,
|
|
231
|
+
planText: latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "Plan ready",
|
|
232
|
+
});
|
|
233
|
+
const prev = readState(config.root, config.viewId);
|
|
234
|
+
if (prev) {
|
|
235
|
+
prev.semanticState = "needs_input";
|
|
236
|
+
prev.processState = "exited";
|
|
237
|
+
prev.needsInput = true;
|
|
238
|
+
prev.question = "Approve this plan?";
|
|
239
|
+
prev.summary = "Plan ready for approval";
|
|
240
|
+
prev.currentRunId = config.runId;
|
|
241
|
+
prev.updatedAt = Date.now();
|
|
242
|
+
writeState(config.root, prev);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** @param {import("../src/core/types.mjs").RunConfig} config @param {import("../src/core/types.mjs").RunStatus} status */
|
|
247
|
+
function drainQueuedFollowUp(config, status) {
|
|
248
|
+
if (status.semanticState !== "idle" && status.semanticState !== "completed") return;
|
|
249
|
+
if (config.kind === "plan" || config.kind === "plan_change") return;
|
|
250
|
+
const claimed = claimNextFollowUp(config.root, config.viewId);
|
|
251
|
+
if (!claimed.ok || !claimed.item) return;
|
|
252
|
+
const item = claimed.item;
|
|
253
|
+
const nextRunId = newRunId();
|
|
254
|
+
const nextConfig = {
|
|
255
|
+
...config,
|
|
256
|
+
runId: nextRunId,
|
|
257
|
+
kind: runKindForFollowUp(item),
|
|
258
|
+
prompt: promptForFollowUp(config.root, config.viewId, item),
|
|
259
|
+
};
|
|
260
|
+
try {
|
|
261
|
+
const { pid } = launchRun(config.root, nextConfig, { runnerScript: fileURLToPath(import.meta.url) });
|
|
262
|
+
const nextStatus = createRunStatus(nextConfig, pid ?? null, Date.now());
|
|
263
|
+
writeStatus(config.root, nextStatus);
|
|
264
|
+
writeState(config.root, projectViewState(nextStatus, Date.now(), readState(config.root, config.viewId)));
|
|
265
|
+
completeFollowUp(config.root, config.viewId, item.id, { runId: nextRunId });
|
|
266
|
+
appendDiagnostic(config.root, config.viewId, { source: "queue", runId: nextRunId, code: "follow_up_started", message: "Queued follow-up started by JSON runner", details: { kind: item.kind } });
|
|
267
|
+
} catch (err) {
|
|
268
|
+
releaseFollowUp(config.root, config.viewId, item.id);
|
|
269
|
+
appendDiagnostic(config.root, config.viewId, { source: "queue", level: "error", code: "follow_up_drain_failed", message: "JSON runner could not start queued follow-up", details: { error: err instanceof Error ? err.message : String(err) } });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** @param {import("../src/core/types.mjs").FollowUpItem} item */
|
|
274
|
+
function runKindForFollowUp(item) {
|
|
275
|
+
switch (item.kind) {
|
|
276
|
+
case "plan_request":
|
|
277
|
+
return "plan";
|
|
278
|
+
case "plan_change":
|
|
279
|
+
return "plan_change";
|
|
280
|
+
case "plan_approval":
|
|
281
|
+
return "plan_approval";
|
|
282
|
+
default:
|
|
283
|
+
return "reply";
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** @param {import("../src/core/types.mjs").EvidenceSnapshot} evidence */
|
|
288
|
+
function latestEvidenceText(evidence) {
|
|
289
|
+
return evidence.assistantEvidence?.[evidence.assistantEvidence.length - 1]?.text ?? "";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @param {string} root @param {string} viewId @param {import("../src/core/types.mjs").FollowUpItem} item */
|
|
293
|
+
function promptForFollowUp(root, viewId, item) {
|
|
294
|
+
const steering = readSteering(root, viewId);
|
|
295
|
+
switch (item.kind) {
|
|
296
|
+
case "plan_request":
|
|
297
|
+
return buildPlanRequestPrompt(item.text);
|
|
298
|
+
case "plan_change":
|
|
299
|
+
return buildPlanChangesPrompt(steering.planText, item.text);
|
|
300
|
+
case "plan_approval":
|
|
301
|
+
return buildApprovePlanPrompt(steering.planText);
|
|
302
|
+
default:
|
|
303
|
+
return item.text;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function canAutoState(config, status, evidence) {
|
|
308
|
+
if (!autoStateEnabled()) return false;
|
|
309
|
+
if (config.kind === "plan" || config.kind === "plan_change") return false;
|
|
310
|
+
if (status.semanticState === "failed" || status.semanticState === "stopped" || status.processState === "alive") return false;
|
|
311
|
+
return Boolean((latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "").trim());
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function applyHeuristicAutoState(config, status, evidence) {
|
|
315
|
+
if (!canAutoState(config, status, evidence)) return false;
|
|
316
|
+
const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
|
|
317
|
+
const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
|
|
318
|
+
const changed = applyAutoStateToStatus(status, classification, Date.now());
|
|
319
|
+
if (changed) {
|
|
320
|
+
appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated terminal state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
|
|
321
|
+
}
|
|
322
|
+
return changed;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function maybeModelAutoState(config, status, evidence) {
|
|
326
|
+
if (!canAutoState(config, status, evidence)) return false;
|
|
327
|
+
const model = autoStateModel();
|
|
328
|
+
if (!model) return false;
|
|
329
|
+
const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
|
|
330
|
+
const prompt = buildAutoStatePrompt(latest);
|
|
331
|
+
const out = await runOneShot(
|
|
332
|
+
config.piCommand,
|
|
333
|
+
[...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
|
|
334
|
+
15000,
|
|
335
|
+
);
|
|
336
|
+
const classification = autoStateFromModelOrHeuristic(out, latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
|
|
337
|
+
const changed = applyAutoStateToStatus(status, classification, Date.now());
|
|
338
|
+
if (changed) {
|
|
339
|
+
appendDiagnostic(config.root, config.viewId, { source: "runner", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier refined terminal state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
|
|
340
|
+
}
|
|
341
|
+
return changed;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Default cheap model for terminal summaries. Override/disable via $AGENT_BOARD_SUMMARY_MODEL. */
|
|
345
|
+
const DEFAULT_SUMMARY_MODEL = "gpt-4o";
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Cheap-model summary, ON BY DEFAULT (uses {@link DEFAULT_SUMMARY_MODEL}; override with
|
|
349
|
+
* $AGENT_BOARD_SUMMARY_MODEL=<model>, disable with $AGENT_BOARD_SUMMARY_MODEL=off). Overrides
|
|
350
|
+
* status.summary with a short model-generated line. On any failure (no API key, model
|
|
351
|
+
* unavailable, timeout) it silently keeps the heuristic summary already in status.summary.
|
|
352
|
+
* @param {import("../src/core/types.mjs").RunConfig} config
|
|
353
|
+
* @param {import("../src/core/types.mjs").RunStatus} status
|
|
354
|
+
* @returns {Promise<boolean>} whether the summary was upgraded.
|
|
355
|
+
*/
|
|
356
|
+
async function maybeModelSummary(config, status) {
|
|
357
|
+
const configured = process.env.AGENT_BOARD_SUMMARY_MODEL ?? process.env.AGENT_VIEW_SUMMARY_MODEL;
|
|
358
|
+
if (configured === "off") return false;
|
|
359
|
+
const model = configured || DEFAULT_SUMMARY_MODEL;
|
|
360
|
+
if (status.semanticState === "failed" || status.semanticState === "stopped") return false;
|
|
361
|
+
const source = status.latestAssistantPreview || status.summary;
|
|
362
|
+
if (!source) return false;
|
|
363
|
+
const prompt = `In 8 words or fewer, summarize what this coding agent just did. No quotes.\n\n${source}`;
|
|
364
|
+
const out = await runOneShot(
|
|
365
|
+
config.piCommand,
|
|
366
|
+
[...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
|
|
367
|
+
15000,
|
|
368
|
+
);
|
|
369
|
+
const text = out.trim().split("\n").slice(-1)[0]?.trim();
|
|
370
|
+
if (text) {
|
|
371
|
+
status.summary = text.replace(/^["']|["']$/g, "").slice(0, 80);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Run a pi one-shot and return the concatenated assistant text from message_end events.
|
|
379
|
+
* @param {string} command
|
|
380
|
+
* @param {string[]} args
|
|
381
|
+
* @param {number} [timeoutMs]
|
|
382
|
+
* @returns {Promise<string>}
|
|
383
|
+
*/
|
|
384
|
+
function runOneShot(command, args, timeoutMs = 20000) {
|
|
385
|
+
return new Promise((resolve) => {
|
|
386
|
+
let out = "";
|
|
387
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
388
|
+
let buf = "";
|
|
389
|
+
child.stdout.on("data", (c) => {
|
|
390
|
+
buf += c.toString();
|
|
391
|
+
const lines = buf.split("\n");
|
|
392
|
+
buf = lines.pop() ?? "";
|
|
393
|
+
for (const line of lines) {
|
|
394
|
+
try {
|
|
395
|
+
const e = JSON.parse(line);
|
|
396
|
+
if (e?.type === "message_end" && e.message?.role === "assistant") {
|
|
397
|
+
for (const b of e.message.content ?? []) {
|
|
398
|
+
if (b.type === "text") out += b.text;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
/* ignore */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
child.on("close", () => resolve(out));
|
|
407
|
+
child.on("error", () => resolve(""));
|
|
408
|
+
// Safety timeout so a hung summarizer never blocks finalization forever.
|
|
409
|
+
setTimeout(() => {
|
|
410
|
+
try {
|
|
411
|
+
child.kill("SIGKILL");
|
|
412
|
+
} catch {
|
|
413
|
+
/* ignore */
|
|
414
|
+
}
|
|
415
|
+
resolve(out);
|
|
416
|
+
}, timeoutMs).unref?.();
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
main();
|