@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.
Files changed (65) hide show
  1. package/IMPLEMENTATION_PLAN.md +920 -0
  2. package/LICENSE +21 -0
  3. package/PRD.md +484 -0
  4. package/PROGRESS.md +127 -0
  5. package/README.md +131 -0
  6. package/VERIFY.md +113 -0
  7. package/docs/BATCH_SELECTION_READ_FLOW.md +277 -0
  8. package/docs/EXPLORATION.md +187 -0
  9. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +579 -0
  10. package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +704 -0
  11. package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +499 -0
  12. package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +366 -0
  13. package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +105 -0
  14. package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +142 -0
  15. package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +59 -0
  16. package/index.ts +6 -0
  17. package/package.json +81 -0
  18. package/runner/job-runner.mjs +420 -0
  19. package/runner/pty-runner.mjs +310 -0
  20. package/runner/state-runner.mjs +120 -0
  21. package/runner/title-runner.mjs +80 -0
  22. package/scripts/patch-vulns.mjs +59 -0
  23. package/src/commands/agent-board.ts +318 -0
  24. package/src/commands/attach-flow.ts +231 -0
  25. package/src/commands/bg.ts +70 -0
  26. package/src/core/atomic.mjs +145 -0
  27. package/src/core/auto-state.mjs +320 -0
  28. package/src/core/dashboard-render.mjs +10 -0
  29. package/src/core/derive.mjs +114 -0
  30. package/src/core/diagnostics.mjs +109 -0
  31. package/src/core/events.mjs +268 -0
  32. package/src/core/evidence.mjs +242 -0
  33. package/src/core/follow-up-queue.mjs +193 -0
  34. package/src/core/heuristics.mjs +240 -0
  35. package/src/core/ids.mjs +35 -0
  36. package/src/core/invocation.mjs +43 -0
  37. package/src/core/launch-options.mjs +317 -0
  38. package/src/core/launch.mjs +116 -0
  39. package/src/core/locks.mjs +80 -0
  40. package/src/core/paths.mjs +86 -0
  41. package/src/core/pid.mjs +42 -0
  42. package/src/core/prewarm-schedule.mjs +41 -0
  43. package/src/core/prompt-transport.mjs +13 -0
  44. package/src/core/pty-attach-jiggle-retry.mjs +90 -0
  45. package/src/core/pty-attach-render.mjs +51 -0
  46. package/src/core/pty-input.mjs +15 -0
  47. package/src/core/pty-links.mjs +71 -0
  48. package/src/core/pty-scroll.mjs +155 -0
  49. package/src/core/pty-support.mjs +327 -0
  50. package/src/core/repo.mjs +47 -0
  51. package/src/core/rows.mjs +290 -0
  52. package/src/core/screen-log-gc.mjs +198 -0
  53. package/src/core/screen-log.mjs +160 -0
  54. package/src/core/session-view.mjs +174 -0
  55. package/src/core/steering-prompts.mjs +34 -0
  56. package/src/core/steering.mjs +133 -0
  57. package/src/core/store.mjs +308 -0
  58. package/src/core/title.mjs +43 -0
  59. package/src/core/types.mjs +380 -0
  60. package/src/core/worktree.mjs +64 -0
  61. package/src/index.ts +109 -0
  62. package/src/runtime/service.mjs +1194 -0
  63. package/src/ui/dashboard-evidence.mjs +85 -0
  64. package/src/ui/dashboard.ts +1952 -0
  65. package/src/ui/pty-attach.ts +1378 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Atomic + crash-tolerant file helpers used across the extension and the runner.
3
+ *
4
+ * Writes go to a temp sibling then `rename()` (atomic on the same filesystem) so a
5
+ * reader never observes a half-written JSON file. Reads tolerate missing/corrupt files.
6
+ */
7
+ import { randomBytes } from "node:crypto";
8
+ import {
9
+ appendFileSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ renameSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import * as path from "node:path";
18
+
19
+ /** @param {string} dir */
20
+ export function ensureDir(dir) {
21
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22
+ }
23
+
24
+ /**
25
+ * Atomically write a string to `file` (creates parent dirs).
26
+ * @param {string} file
27
+ * @param {string} data
28
+ */
29
+ export function atomicWrite(file, data) {
30
+ ensureDir(path.dirname(file));
31
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
32
+ writeFileSync(tmp, data, "utf8");
33
+ renameSync(tmp, file);
34
+ }
35
+
36
+ /**
37
+ * Atomically write a value as pretty JSON.
38
+ * @param {string} file
39
+ * @param {unknown} value
40
+ */
41
+ export function atomicWriteJson(file, value) {
42
+ atomicWrite(file, `${JSON.stringify(value, null, 2)}\n`);
43
+ }
44
+
45
+ /**
46
+ * Read and parse JSON, returning `fallback` when the file is missing or unparseable.
47
+ * @template T
48
+ * @param {string} file
49
+ * @param {T} fallback
50
+ * @returns {T}
51
+ */
52
+ export function readJson(file, fallback) {
53
+ try {
54
+ if (!existsSync(file)) return fallback;
55
+ const raw = readFileSync(file, "utf8");
56
+ if (!raw.trim()) return fallback;
57
+ return /** @type {T} */ (JSON.parse(raw));
58
+ } catch {
59
+ return fallback;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Append one line (a trailing newline is added) to a log/jsonl file.
65
+ * @param {string} file
66
+ * @param {string} line
67
+ */
68
+ export function appendLine(file, line) {
69
+ ensureDir(path.dirname(file));
70
+ appendFileSync(file, line.endsWith("\n") ? line : `${line}\n`, "utf8");
71
+ }
72
+
73
+ /**
74
+ * Append one JSON value as a JSONL record.
75
+ * @param {string} file
76
+ * @param {unknown} value
77
+ */
78
+ export function appendJsonl(file, value) {
79
+ appendLine(file, JSON.stringify(value));
80
+ }
81
+
82
+ /**
83
+ * Read a JSONL file into parsed objects, skipping blank/corrupt lines.
84
+ * @param {string} file
85
+ * @returns {any[]}
86
+ */
87
+ export function readJsonl(file) {
88
+ try {
89
+ if (!existsSync(file)) return [];
90
+ const out = [];
91
+ for (const line of readFileSync(file, "utf8").split("\n")) {
92
+ const t = line.trim();
93
+ if (!t) continue;
94
+ try {
95
+ out.push(JSON.parse(t));
96
+ } catch {
97
+ /* skip corrupt line */
98
+ }
99
+ }
100
+ return out;
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Read the newest `limit` JSONL records. Corrupt lines are skipped.
108
+ * @param {string} file
109
+ * @param {number} [limit]
110
+ * @returns {any[]}
111
+ */
112
+ export function readJsonlTail(file, limit = 50) {
113
+ const all = readJsonl(file);
114
+ const n = Math.max(0, Math.floor(Number(limit) || 0));
115
+ return n > 0 ? all.slice(-n) : all;
116
+ }
117
+
118
+ /**
119
+ * Best-effort delete helper used by cleanup actions.
120
+ * @param {string} file
121
+ * @returns {boolean}
122
+ */
123
+ export function removeFile(file) {
124
+ try {
125
+ if (!existsSync(file)) return false;
126
+ unlinkSync(file);
127
+ return true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Read a file's text, returning `fallback` if missing.
135
+ * @param {string} file
136
+ * @param {string} [fallback]
137
+ * @returns {string}
138
+ */
139
+ export function readText(file, fallback = "") {
140
+ try {
141
+ return existsSync(file) ? readFileSync(file, "utf8") : fallback;
142
+ } catch {
143
+ return fallback;
144
+ }
145
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Automatic terminal-state classification for finished agent turns.
3
+ *
4
+ * The dashboard has three user-facing post-run buckets:
5
+ * - needs_input → agent is waiting on the user
6
+ * - idle → in progress / worth revisiting
7
+ * - completed → done
8
+ *
9
+ * This module is pure (no model/network I/O). Runners may feed model JSON into
10
+ * parseAutoStateModelOutput(); otherwise heuristicAutoState() gives a safe fallback.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { deriveSummary } from "./derive.mjs";
14
+ import { detectNeedsInput, firstSentence, splitSentences, truncate } from "./heuristics.mjs";
15
+
16
+ /** @typedef {import("./types.mjs").SemanticState} SemanticState */
17
+ /** @typedef {"needs_input"|"in_progress"|"done"} AutoStateKind */
18
+ /** @typedef {"model"|"heuristic"} AutoStateSource */
19
+ /** @typedef {"high"|"medium"|"low"} AutoStateConfidence */
20
+
21
+ const AUTO_STATE_KINDS = new Set(["needs_input", "in_progress", "done"]);
22
+ const CONFIDENCES = new Set(["high", "medium", "low"]);
23
+ const DEFAULT_AUTO_STATE_MODEL = "gpt-4o";
24
+
25
+ /** @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env] */
26
+ export function autoStateEnabled(env = process.env) {
27
+ const raw = env.AGENT_BOARD_AUTO_STATE ?? env.AGENT_VIEW_AUTO_STATE;
28
+ return !isOff(raw);
29
+ }
30
+
31
+ /** @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env] */
32
+ export function autoStateModel(env = process.env) {
33
+ const configured = env.AGENT_BOARD_AUTO_STATE_MODEL ?? env.AGENT_VIEW_AUTO_STATE_MODEL;
34
+ if (isOff(configured)) return null;
35
+ return configured || DEFAULT_AUTO_STATE_MODEL;
36
+ }
37
+
38
+ /** @param {string|undefined} value */
39
+ function isOff(value) {
40
+ return typeof value === "string" && /^(0|false|off|no)$/i.test(value.trim());
41
+ }
42
+
43
+ /** @param {AutoStateKind} kind @returns {SemanticState} */
44
+ export function semanticStateForAutoKind(kind) {
45
+ switch (kind) {
46
+ case "needs_input":
47
+ return "needs_input";
48
+ case "done":
49
+ return "completed";
50
+ default:
51
+ return "idle";
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Prompt used by runners for the cheap classifier pass.
57
+ * @param {string} latestAssistantText
58
+ */
59
+ export function buildAutoStatePrompt(latestAssistantText) {
60
+ const text = truncate(String(latestAssistantText || "").trim(), 6000);
61
+ return `Classify the LAST assistant response for a coding-agent dashboard.\n\nChoose exactly one state:\n- needs_input: the assistant asks the user for a decision, clarification, approval, credentials, or is blocked waiting for the user.\n- in_progress: work is partial, next steps remain, verification is pending/failed, or the assistant says it will continue later.\n- done: the requested work is complete, final answer given, no user input required.\n\nReturn ONLY minified JSON with this shape:\n{"state":"needs_input|in_progress|done","confidence":"high|medium|low","reason":"short reason <=18 words","question":"user-facing question or null"}\n\nLast assistant response:\n${text}`;
62
+ }
63
+
64
+ /**
65
+ * Parse and normalize model JSON. Returns null if the response is unusable.
66
+ * @param {string} raw
67
+ * @param {{ latestAssistantText?: string, now?: number, lastAgentActivityAt?: number|null }} [opts]
68
+ */
69
+ export function parseAutoStateModelOutput(raw, opts = {}) {
70
+ const obj = extractJsonObject(raw);
71
+ if (!obj) return null;
72
+ const kind = normalizeKind(obj.state ?? obj.kind ?? obj.status);
73
+ if (!kind) return null;
74
+ const confidence = normalizeConfidence(obj.confidence);
75
+ const latest = opts.latestAssistantText ?? "";
76
+ const nb = detectNeedsInput(latest);
77
+ const question = kind === "needs_input" ? cleanQuestion(obj.question) || nb.question || latestQuestion(latest) : null;
78
+ return makeClassification(kind, {
79
+ source: "model",
80
+ confidence,
81
+ reason: cleanReason(obj.reason) || defaultReason(kind),
82
+ question,
83
+ now: opts.now,
84
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
85
+ latestAssistantText: latest,
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Conservative fallback classifier. It should be useful, but never clever enough to
91
+ * overrule strong question/error signals.
92
+ * @param {string} latestAssistantText
93
+ * @param {{ now?: number, lastAgentActivityAt?: number|null }} [opts]
94
+ */
95
+ export function heuristicAutoState(latestAssistantText, opts = {}) {
96
+ const text = String(latestAssistantText || "").trim();
97
+ const nb = detectNeedsInput(text);
98
+ if (nb.needsInput) {
99
+ return makeClassification("needs_input", {
100
+ source: "heuristic",
101
+ confidence: "high",
102
+ reason: "Assistant asked for user input",
103
+ question: nb.question,
104
+ now: opts.now,
105
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
106
+ latestAssistantText: text,
107
+ });
108
+ }
109
+
110
+ const lower = text.toLowerCase();
111
+ const pending = hasPendingSignal(lower);
112
+ const done = hasDoneSignal(lower);
113
+ if (done && !pending) {
114
+ return makeClassification("done", {
115
+ source: "heuristic",
116
+ confidence: hasStrongDoneSignal(lower) ? "high" : "medium",
117
+ reason: "Assistant reported the work is complete",
118
+ now: opts.now,
119
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
120
+ latestAssistantText: text,
121
+ });
122
+ }
123
+ if (pending) {
124
+ return makeClassification("in_progress", {
125
+ source: "heuristic",
126
+ confidence: "medium",
127
+ reason: "Assistant mentioned remaining work or pending verification",
128
+ now: opts.now,
129
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
130
+ latestAssistantText: text,
131
+ });
132
+ }
133
+ return makeClassification("in_progress", {
134
+ source: "heuristic",
135
+ confidence: "low",
136
+ reason: "No clear completion or input signal",
137
+ now: opts.now,
138
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
139
+ latestAssistantText: text,
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Pick a model classification when valid, otherwise fall back to heuristics.
145
+ * @param {string} modelOutput
146
+ * @param {string} latestAssistantText
147
+ * @param {{ now?: number, lastAgentActivityAt?: number|null }} [opts]
148
+ */
149
+ export function autoStateFromModelOrHeuristic(modelOutput, latestAssistantText, opts = {}) {
150
+ return parseAutoStateModelOutput(modelOutput, { latestAssistantText, ...opts }) ?? heuristicAutoState(latestAssistantText, opts);
151
+ }
152
+
153
+ /**
154
+ * Mutate a RunStatus with a classification. Returns true if state/metadata changed.
155
+ * @param {import("./types.mjs").RunStatus} status
156
+ * @param {import("./types.mjs").AutoStateClassification} classification
157
+ * @param {number} [now]
158
+ */
159
+ export function applyAutoStateToStatus(status, classification, now = Date.now()) {
160
+ if (!classification || status.processState === "alive") return false;
161
+ if (status.semanticState === "failed" || status.semanticState === "stopped") return false;
162
+ const nextState = semanticStateForAutoKind(classification.kind);
163
+ const before = `${status.semanticState}|${status.question ?? ""}|${status.summary ?? ""}|${status.autoState?.source ?? ""}|${status.autoState?.textHash ?? ""}`;
164
+ status.semanticState = nextState;
165
+ status.autoState = { ...classification, semanticState: nextState, classifiedAt: now };
166
+ if (nextState === "needs_input") {
167
+ status.question = classification.question || detectNeedsInput(status.latestAssistantPreview).question || latestQuestion(status.latestAssistantPreview);
168
+ } else {
169
+ status.question = null;
170
+ }
171
+ status.summary = deriveSummary(status);
172
+ const after = `${status.semanticState}|${status.question ?? ""}|${status.summary ?? ""}|${status.autoState?.source ?? ""}|${status.autoState?.textHash ?? ""}`;
173
+ return before !== after;
174
+ }
175
+
176
+ /**
177
+ * Mutate a ViewState with a classification. Returns true if state/metadata changed.
178
+ * @param {import("./types.mjs").ViewState} state
179
+ * @param {import("./types.mjs").AutoStateClassification} classification
180
+ * @param {number} [now]
181
+ */
182
+ export function applyAutoStateToViewState(state, classification, now = Date.now()) {
183
+ if (!classification || state.processState === "alive") return false;
184
+ if (state.semanticState === "failed" || state.semanticState === "stopped") return false;
185
+ const nextState = semanticStateForAutoKind(classification.kind);
186
+ const before = `${state.semanticState}|${state.question ?? ""}|${state.summary ?? ""}|${state.autoState?.source ?? ""}|${state.autoState?.textHash ?? ""}`;
187
+ state.semanticState = nextState;
188
+ state.needsInput = nextState === "needs_input";
189
+ state.hasError = nextState === "failed";
190
+ state.autoState = { ...classification, semanticState: nextState, classifiedAt: now };
191
+ if (nextState === "needs_input") {
192
+ state.question = classification.question || detectNeedsInput(state.latestAssistantPreview).question || latestQuestion(state.latestAssistantPreview);
193
+ } else {
194
+ state.question = null;
195
+ }
196
+ state.error = nextState === "failed" ? state.error : null;
197
+ state.updatedAt = now;
198
+ state.summary = deriveSummary({
199
+ processState: state.processState,
200
+ semanticState: state.semanticState,
201
+ currentTool: state.latestTool,
202
+ question: state.question,
203
+ error: state.error,
204
+ latestAssistantPreview: state.latestAssistantPreview,
205
+ });
206
+ const after = `${state.semanticState}|${state.question ?? ""}|${state.summary ?? ""}|${state.autoState?.source ?? ""}|${state.autoState?.textHash ?? ""}`;
207
+ return before !== after;
208
+ }
209
+
210
+ /**
211
+ * @param {AutoStateKind} kind
212
+ * @param {{ source: AutoStateSource, confidence?: AutoStateConfidence, reason?: string, question?: string|null, now?: number, lastAgentActivityAt?: number|null, latestAssistantText?: string }} opts
213
+ * @returns {import("./types.mjs").AutoStateClassification}
214
+ */
215
+ function makeClassification(kind, opts) {
216
+ const now = opts.now ?? Date.now();
217
+ const semanticState = semanticStateForAutoKind(kind);
218
+ return {
219
+ version: 1,
220
+ kind,
221
+ semanticState,
222
+ confidence: opts.confidence ?? "low",
223
+ source: opts.source,
224
+ reason: truncate(String(opts.reason || defaultReason(kind)).replace(/\s+/g, " ").trim(), 120),
225
+ question: opts.question ? truncate(String(opts.question).replace(/\s+/g, " ").trim(), 240) : null,
226
+ classifiedAt: now,
227
+ lastAgentActivityAt: opts.lastAgentActivityAt ?? null,
228
+ textHash: textHash(opts.latestAssistantText ?? ""),
229
+ };
230
+ }
231
+
232
+ /** @param {string} raw */
233
+ function extractJsonObject(raw) {
234
+ const text = String(raw || "").trim();
235
+ if (!text) return null;
236
+ const candidates = [text];
237
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
238
+ if (fenced) candidates.unshift(fenced[1].trim());
239
+ const first = text.indexOf("{");
240
+ const last = text.lastIndexOf("}");
241
+ if (first >= 0 && last > first) candidates.unshift(text.slice(first, last + 1));
242
+ for (const candidate of candidates) {
243
+ try {
244
+ const parsed = JSON.parse(candidate);
245
+ if (parsed && typeof parsed === "object") return parsed;
246
+ } catch {
247
+ /* try next */
248
+ }
249
+ }
250
+ return null;
251
+ }
252
+
253
+ /** @param {any} value @returns {AutoStateKind|null} */
254
+ function normalizeKind(value) {
255
+ const s = String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
256
+ if (AUTO_STATE_KINDS.has(s)) return /** @type {AutoStateKind} */ (s);
257
+ if (["needsinput", "input", "input_required", "requires_input", "awaiting_input", "blocked", "question"].includes(s)) return "needs_input";
258
+ if (["progress", "ongoing", "continue", "continuing", "partial", "not_done", "todo"].includes(s)) return "in_progress";
259
+ if (["complete", "completed", "finished", "finish", "success", "resolved"].includes(s)) return "done";
260
+ return null;
261
+ }
262
+
263
+ /** @param {any} value @returns {AutoStateConfidence} */
264
+ function normalizeConfidence(value) {
265
+ const s = String(value ?? "medium").toLowerCase().trim();
266
+ return CONFIDENCES.has(s) ? /** @type {AutoStateConfidence} */ (s) : "medium";
267
+ }
268
+
269
+ /** @param {any} value */
270
+ function cleanReason(value) {
271
+ return String(value ?? "").replace(/\s+/g, " ").trim();
272
+ }
273
+
274
+ /** @param {any} value */
275
+ function cleanQuestion(value) {
276
+ const s = String(value ?? "").replace(/\s+/g, " ").trim();
277
+ if (!s || /^null$/i.test(s) || /^none$/i.test(s)) return null;
278
+ return s;
279
+ }
280
+
281
+ /** @param {AutoStateKind} kind */
282
+ function defaultReason(kind) {
283
+ switch (kind) {
284
+ case "needs_input":
285
+ return "Assistant is waiting for user input";
286
+ case "done":
287
+ return "Assistant reported completion";
288
+ default:
289
+ return "Assistant appears to have more work remaining";
290
+ }
291
+ }
292
+
293
+ /** @param {string} text */
294
+ function latestQuestion(text) {
295
+ const sentences = splitSentences(text);
296
+ return [...sentences].reverse().find((s) => /\?\s*$/.test(s)) ?? null;
297
+ }
298
+
299
+ /** @param {string} lower */
300
+ function hasStrongDoneSignal(lower) {
301
+ return /(^|\b)(done|completed|complete|finished|resolved|all set|implemented|shipped)(\b|[.!])/i.test(lower) || /tests? (pass|passed|passing)/i.test(lower);
302
+ }
303
+
304
+ /** @param {string} lower */
305
+ function hasDoneSignal(lower) {
306
+ return hasStrongDoneSignal(lower)
307
+ || /\b(final answer|summary:|i'?ve (fixed|updated|added|implemented)|successfully|ready to go)\b/i.test(lower)
308
+ || firstSentence(lower).length > 0 && /^done[.!]/i.test(firstSentence(lower));
309
+ }
310
+
311
+ /** @param {string} lower */
312
+ function hasPendingSignal(lower) {
313
+ return /\b(todo|next steps?|remaining|still need|need to|not yet|pending|in progress|continue|will continue|i'?ll continue|i will|follow up|not run|couldn'?t run|unable to run|blocked|waiting for)\b/i.test(lower)
314
+ || /\btests? (fail|failed|failing|pending|not run|weren'?t run|couldn'?t be run)\b/i.test(lower);
315
+ }
316
+
317
+ /** @param {string} text */
318
+ function textHash(text) {
319
+ return createHash("sha1").update(String(text || "")).digest("hex").slice(0, 12);
320
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Request a differential dashboard repaint.
3
+ *
4
+ * Full-screen custom overlays still participate in Pi TUI's line diffing. Passing
5
+ * `true` here discards that state and clears the terminal before every repaint,
6
+ * which is visible as flicker on terminals without synchronized-output support.
7
+ */
8
+ export function requestDashboardRender(tui) {
9
+ tui.requestRender();
10
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * State-machine + summary derivation. Pure functions over a RunStatus.
3
+ *
4
+ * Semantic-state rules (plan §9.1):
5
+ * while alive: queued → working (once assistant/tool activity begins)
6
+ * clean exit: needs_input (asked a question) | idle (user can mark completed)
7
+ * bad exit: failed
8
+ * user stop: stopped
9
+ * Summary priority (plan §9.3): model → active tool → blocker → first sentence → error → fallback.
10
+ */
11
+ import { firstSentence, truncate } from "./heuristics.mjs";
12
+
13
+ /** @typedef {import("./types.mjs").RunStatus} RunStatus */
14
+ /** @typedef {import("./types.mjs").SemanticState} SemanticState */
15
+
16
+ const GENERIC_STATUS_TEXT = {
17
+ queued: new Set(["Queued"]),
18
+ working: new Set(["Working", "Working…", "Running", "Running…"]),
19
+ needs_input: new Set(["Needs input"]),
20
+ idle: new Set(["Idle", "In Progress"]),
21
+ completed: new Set(["Completed", "Done"]),
22
+ failed: new Set(["Failed"]),
23
+ stopped: new Set(["Stopped"]),
24
+ };
25
+
26
+ const ALL_GENERIC_STATUS_TEXT = new Set(Object.values(GENERIC_STATUS_TEXT).flatMap((labels) => [...labels]));
27
+
28
+ /**
29
+ * Compute the terminal semantic state for a finished run.
30
+ * @param {{ exitCode:number|null, stopReason:string|null, stoppedByUser:boolean, needsInput:boolean }} p
31
+ * @returns {SemanticState}
32
+ */
33
+ export function finalizeSemanticState({ exitCode, stopReason, stoppedByUser, needsInput }) {
34
+ if (stoppedByUser) return "stopped";
35
+ const errored = (exitCode != null && exitCode !== 0) || stopReason === "error" || stopReason === "aborted";
36
+ if (errored) return "failed";
37
+ if (needsInput) return "needs_input";
38
+ return "idle";
39
+ }
40
+
41
+ /**
42
+ * Fallback status text when no richer summary is available.
43
+ * @param {SemanticState} state
44
+ * @returns {string}
45
+ */
46
+ export function fallbackStatusText(state) {
47
+ switch (state) {
48
+ case "queued":
49
+ return "Queued";
50
+ case "working":
51
+ return "Running…";
52
+ case "needs_input":
53
+ return "Needs input";
54
+ case "idle":
55
+ return "In Progress";
56
+ case "completed":
57
+ return "Done";
58
+ case "failed":
59
+ return "Failed";
60
+ case "stopped":
61
+ return "Stopped";
62
+ default:
63
+ return "Unknown";
64
+ }
65
+ }
66
+
67
+ /** @param {string|null|undefined} text */
68
+ export function isGenericStatusText(text) {
69
+ const trimmed = String(text || "").trim();
70
+ return !trimmed || ALL_GENERIC_STATUS_TEXT.has(trimmed);
71
+ }
72
+
73
+ /**
74
+ * Normalize generic fallback summaries to the current display label for a state.
75
+ * @param {SemanticState} state
76
+ * @param {string|null|undefined} text
77
+ */
78
+ export function normalizeGenericStatusText(state, text) {
79
+ const trimmed = String(text || "").trim();
80
+ if (!trimmed || GENERIC_STATUS_TEXT[state]?.has(trimmed)) return fallbackStatusText(state);
81
+ return trimmed;
82
+ }
83
+
84
+ /**
85
+ * Heuristic summary for a run (no model). Honors the documented priority order.
86
+ * @param {RunStatus} status
87
+ * @param {number} [max] max characters
88
+ * @returns {string}
89
+ */
90
+ export function deriveSummary(status, max = 80) {
91
+ const s = status;
92
+ // 2. active tool (only while still working)
93
+ if (s.processState === "alive" && s.currentTool?.name) {
94
+ // currentTool.summary is precomputed by the reducer; fall back to name.
95
+ const ct = /** @type {any} */ (s.currentTool);
96
+ if (typeof ct.summary === "string" && ct.summary) return truncate(ct.summary, max);
97
+ return truncate(ct.name, max);
98
+ }
99
+ // 3. explicit blocker / question
100
+ if (s.semanticState === "needs_input" && s.question) return truncate(s.question, max);
101
+ // 5. error (placed above generic preview for failed runs)
102
+ if (s.semanticState === "failed" && s.error) return truncate(s.error, max);
103
+ // 4. first sentence of latest assistant output (fall back to full text if the first
104
+ // sentence is uninformatively short, e.g. "Done.")
105
+ if (s.latestAssistantPreview) {
106
+ const fs = firstSentence(s.latestAssistantPreview);
107
+ const pick = fs.length >= 12 ? fs : s.latestAssistantPreview;
108
+ return truncate(pick, max);
109
+ }
110
+ if (s.question) return truncate(s.question, max);
111
+ if (s.error) return truncate(s.error, max);
112
+ // 6. fallback status text
113
+ return fallbackStatusText(s.semanticState);
114
+ }
@@ -0,0 +1,109 @@
1
+ /** Diagnostics JSONL helpers for Agent Board rows. */
2
+ import { atomicWrite, appendJsonl, readJsonl, readJsonlTail } from "./atomic.mjs";
3
+ import * as P from "./paths.mjs";
4
+
5
+ const SECRET_KEY_RE = /token|secret|password|authorization|api[_-]?key/i;
6
+
7
+ /**
8
+ * @param {string} root
9
+ * @param {string} viewId
10
+ * @param {Partial<import("./types.mjs").DiagnosticEvent>} patch
11
+ * @returns {import("./types.mjs").DiagnosticEvent}
12
+ */
13
+ export function appendDiagnostic(root, viewId, patch = {}) {
14
+ const event = normalizeDiagnostic(viewId, patch);
15
+ appendJsonl(P.diagnosticsPath(root, viewId), event);
16
+ return event;
17
+ }
18
+
19
+ /** @param {string} root @param {string} viewId @returns {import("./types.mjs").DiagnosticEvent[]} */
20
+ export function readDiagnostics(root, viewId) {
21
+ return readJsonl(P.diagnosticsPath(root, viewId)).map((e) => normalizeDiagnostic(viewId, e));
22
+ }
23
+
24
+ /**
25
+ * @param {string} root
26
+ * @param {string} viewId
27
+ * @param {{ limit?: number }} [opts]
28
+ * @returns {import("./types.mjs").DiagnosticEvent[]}
29
+ */
30
+ export function tailDiagnostics(root, viewId, opts = {}) {
31
+ return readJsonlTail(P.diagnosticsPath(root, viewId), opts.limit ?? 50).map((e) => normalizeDiagnostic(viewId, e));
32
+ }
33
+
34
+ /** @param {string} root @param {string} viewId */
35
+ export function clearDiagnostics(root, viewId) {
36
+ atomicWrite(P.diagnosticsPath(root, viewId), "");
37
+ return { ok: true };
38
+ }
39
+
40
+ /** @returns {import("./types.mjs").DiagnosticSummary} */
41
+ export function emptyDiagnosticSummary() {
42
+ return {
43
+ count: 0,
44
+ warningCount: 0,
45
+ errorCount: 0,
46
+ lastAt: null,
47
+ lastLevel: null,
48
+ lastCode: null,
49
+ lastMessage: null,
50
+ stalled: false,
51
+ stallReason: null,
52
+ };
53
+ }
54
+
55
+ /** @param {import("./types.mjs").DiagnosticEvent[]} events @returns {import("./types.mjs").DiagnosticSummary} */
56
+ export function summarizeDiagnostics(events) {
57
+ const summary = emptyDiagnosticSummary();
58
+ for (const event of events ?? []) {
59
+ summary.count += 1;
60
+ if (event.level === "warn") summary.warningCount += 1;
61
+ if (event.level === "error") summary.errorCount += 1;
62
+ summary.lastAt = event.at ?? summary.lastAt;
63
+ summary.lastLevel = event.level ?? summary.lastLevel;
64
+ summary.lastCode = event.code ?? summary.lastCode;
65
+ summary.lastMessage = event.message ?? summary.lastMessage;
66
+ if (event.code === "provider_stall" || event.code === "stalled") {
67
+ summary.stalled = true;
68
+ summary.stallReason = String(event.details?.reason ?? event.message ?? "stalled");
69
+ }
70
+ if (event.code === "provider_stall_resolved" || event.code === "stall_resolved") {
71
+ summary.stalled = false;
72
+ summary.stallReason = null;
73
+ }
74
+ }
75
+ return summary;
76
+ }
77
+
78
+ /** @param {string} root @param {string} viewId */
79
+ export function readDiagnosticSummary(root, viewId) {
80
+ return summarizeDiagnostics(readDiagnostics(root, viewId));
81
+ }
82
+
83
+ /** @param {string} viewId @param {any} patch @returns {import("./types.mjs").DiagnosticEvent} */
84
+ export function normalizeDiagnostic(viewId, patch = {}) {
85
+ const level = ["info", "warn", "error"].includes(patch.level) ? patch.level : "info";
86
+ const code = typeof patch.code === "string" && patch.code ? patch.code : "event";
87
+ return {
88
+ version: 1,
89
+ at: Number.isFinite(patch.at) ? patch.at : Date.now(),
90
+ viewId,
91
+ runId: typeof patch.runId === "string" ? patch.runId : null,
92
+ source: typeof patch.source === "string" && patch.source ? patch.source : "service",
93
+ level,
94
+ code,
95
+ message: typeof patch.message === "string" && patch.message ? patch.message : code,
96
+ details: redactDiagnosticDetails(patch.details ?? {}),
97
+ };
98
+ }
99
+
100
+ /** @param {any} value @returns {any} */
101
+ export function redactDiagnosticDetails(value) {
102
+ if (Array.isArray(value)) return value.map((v) => redactDiagnosticDetails(v));
103
+ if (!value || typeof value !== "object") return value;
104
+ const out = {};
105
+ for (const [key, val] of Object.entries(value)) {
106
+ out[key] = SECRET_KEY_RE.test(key) ? "[redacted]" : redactDiagnosticDetails(val);
107
+ }
108
+ return out;
109
+ }