@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
|
@@ -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
|
+
}
|