@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,198 @@
1
+ /**
2
+ * Startup GC for per-view PTY replay logs.
3
+ *
4
+ * screen.log write-path growth is already bounded by screen-log.mjs (cap + tail
5
+ * compaction inside pty-runner). This module reclaims the other half: logs of
6
+ * views whose session ENDED long ago — no runner will ever touch them again,
7
+ * so without a sweep they sit on disk forever.
8
+ *
9
+ * Safety rules:
10
+ * - Only screen.log is removed; meta/state/evidence stay so the dashboard row survives.
11
+ * - Views with a live host (state alive/starting, endedAt null) are never touched:
12
+ * pty-runner holds an in-memory byte counter for its log and external mutation
13
+ * would race with it. Live logs are bounded by the runner's own cap.
14
+ */
15
+ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
16
+ import { readJson } from "./atomic.mjs";
17
+ import * as P from "./paths.mjs";
18
+ import { isAlive } from "./pid.mjs";
19
+
20
+ export const DEFAULT_SCREEN_LOG_RETENTION_DAYS = 7;
21
+ const DAY_MS = 24 * 60 * 60 * 1000;
22
+
23
+ /**
24
+ * A live pty-runner heartbeat-persists host.json every second, and a booting runner
25
+ * persists state "starting" before the service's own writeHost lands. A host.json
26
+ * modified within this window therefore means a runner is live or mid-launch.
27
+ */
28
+ const HOST_FRESH_GRACE_MS = 10_000;
29
+
30
+ /**
31
+ * An "alive"/"starting" claim older than this is not trusted: heartbeat-persisted
32
+ * lastSeenAt goes stale when a runner dies, and a dead pid can later be recycled by
33
+ * an unrelated process — pid liveness alone must not exempt a view forever.
34
+ */
35
+ const ALIVE_CLAIM_MAX_AGE_MS = 3 * DAY_MS;
36
+
37
+ /**
38
+ * Shared input gate for the prefs normalizers: only finite numbers and non-blank
39
+ * numeric strings are meaningful. Anything else (booleans, arrays, "", " ",
40
+ * objects) is a hand-edit accident, not a value.
41
+ * @param {unknown} value
42
+ * @returns {number|null} the coerced finite number, or null when not usable
43
+ */
44
+ function coerceUsableNumber(value) {
45
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
46
+ if (typeof value === "string" && value.trim() !== "") {
47
+ const n = Number(value);
48
+ return Number.isFinite(n) ? n : null;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Normalize the `screenLogRetentionDays` pref.
55
+ * @param {unknown} value
56
+ * @returns {number|null} days, or null when GC is disabled (pref = 0)
57
+ */
58
+ export function normalizeRetentionDays(value) {
59
+ const n = coerceUsableNumber(value);
60
+ if (n === null || n < 0) return DEFAULT_SCREEN_LOG_RETENTION_DAYS;
61
+ const days = Math.floor(n);
62
+ // Anything flooring to 0 (0, "0", "0.0", fractions in (0,1)) means "disabled".
63
+ // A 0-day retention would otherwise compute cutoff=now and delete EVERY ended log.
64
+ return days === 0 ? null : days;
65
+ }
66
+
67
+ /**
68
+ * Normalize the `screenLogMaxSize` pref.
69
+ * @param {unknown} value
70
+ * @returns {number|null} bytes, or null to keep the runner's built-in default
71
+ */
72
+ export function normalizeScreenLogMaxBytes(value) {
73
+ const n = coerceUsableNumber(value);
74
+ if (n === null) return null;
75
+ const bytes = Math.floor(n);
76
+ return bytes > 0 ? bytes : null;
77
+ }
78
+
79
+ /**
80
+ * Delete screen.log of ended views older than the retention window.
81
+ * Best-effort: a per-file failure is counted and skipped, never thrown.
82
+ * @param {string} root
83
+ * @param {{ retentionDays?: number|null, now?: number }} [opts]
84
+ * @returns {{ scanned: number, removed: number, skippedActive: number, skippedFresh: number, skippedForeign: number, bytesReclaimed: number, errors: number }}
85
+ */
86
+ export function pruneScreenLogs(root, opts = {}) {
87
+ const stats = { scanned: 0, removed: 0, skippedActive: 0, skippedFresh: 0, skippedForeign: 0, bytesReclaimed: 0, errors: 0 };
88
+ const retentionDays = normalizeRetentionDays(opts.retentionDays);
89
+ if (retentionDays === null) return stats;
90
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
91
+ const cutoff = now - retentionDays * DAY_MS;
92
+ /** @type {import("node:fs").Dirent[]} */
93
+ let entries;
94
+ try {
95
+ entries = readdirSync(P.viewsDir(root), { withFileTypes: true });
96
+ } catch {
97
+ return stats; // no views dir yet — nothing to do
98
+ }
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory()) continue;
101
+ if (!isViewDir(root, entry.name)) {
102
+ // Foreign dirs must never lose a file; count the ones holding a screen.log
103
+ // so the skip is visible in stats.
104
+ try {
105
+ if (statSync(P.screenLogPath(root, entry.name)).size > 0) stats.skippedForeign++;
106
+ } catch {}
107
+ continue;
108
+ }
109
+ const logFile = P.screenLogPath(root, entry.name);
110
+ /** @type {number} */
111
+ let size;
112
+ try {
113
+ size = statSync(logFile).size;
114
+ } catch {
115
+ continue; // no screen.log (job-runner views never have one)
116
+ }
117
+ if (size <= 0) continue;
118
+ stats.scanned++;
119
+ const basis = ageBasisMs(root, entry.name, logFile, now);
120
+ if (basis === "active") {
121
+ stats.skippedActive++;
122
+ continue;
123
+ }
124
+ if (basis === null || basis > cutoff) {
125
+ stats.skippedFresh++;
126
+ continue;
127
+ }
128
+ try {
129
+ unlinkSync(logFile);
130
+ stats.removed++;
131
+ stats.bytesReclaimed += size;
132
+ } catch (err) {
133
+ if (classifyUnlinkFailure(err) === "removed") stats.removed++;
134
+ else stats.errors++;
135
+ }
136
+ }
137
+ return stats;
138
+ }
139
+
140
+ /**
141
+ * Age basis for one view's log: host endedAt when known, else the log's mtime.
142
+ * @param {string} root @param {string} viewId @param {string} logFile @param {number} now
143
+ * @returns {number|null|"active"} epoch ms, "active" for live views, null when unknown
144
+ */
145
+ function ageBasisMs(root, viewId, logFile, now) {
146
+ const hostFile = P.hostPath(root, viewId);
147
+ const host = readJson(hostFile, null);
148
+ if (host) {
149
+ // Fresh host.json = a runner is heartbeating or mid-launch. Checking mtime first
150
+ // closes the launch TOCTOU window (runner boots and opens the log before the
151
+ // service's writeHost records "starting"): a stale read can never delete the
152
+ // log of a runner that just started.
153
+ try {
154
+ if (now - statSync(hostFile).mtimeMs < HOST_FRESH_GRACE_MS) return "active";
155
+ } catch {}
156
+ if (host.endedAt == null && (host.state === "alive" || host.state === "starting")) {
157
+ // A runner killed by SIGKILL/OOM leaves state "alive" on disk forever, and its
158
+ // pid may later be recycled by an unrelated long-lived process (isAlive also
159
+ // treats EPERM as alive). Trust the claim only when the heartbeat refreshed
160
+ // lastSeenAt recently AND the pid is actually alive.
161
+ const claimFresh = Number.isFinite(host.lastSeenAt) && now - host.lastSeenAt < ALIVE_CLAIM_MAX_AGE_MS;
162
+ if (claimFresh && Number.isInteger(host.runnerPid) && isAlive(host.runnerPid)) return "active";
163
+ }
164
+ if (Number.isFinite(host.endedAt)) return host.endedAt;
165
+ }
166
+ try {
167
+ return statSync(logFile).mtimeMs;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Classify an unlinkSync failure. ENOENT after a successful stat means a concurrent
175
+ * sweep (a second dashboard sharing the root) won the unlink: the reclaim goal is
176
+ * achieved, so it counts as removed (without the bytes, which the winner accounted
177
+ * for), never as an error. Everything else is a real failure.
178
+ * Extracted as a pure predicate because the stat→unlink race cannot be reproduced
179
+ * deterministically in tests — the predicate is the part that can silently regress.
180
+ * @param {unknown} err
181
+ * @returns {"removed"|"error"}
182
+ */
183
+ export function classifyUnlinkFailure(err) {
184
+ return err && err.code === "ENOENT" ? "removed" : "error";
185
+ }
186
+
187
+ /**
188
+ * A sweep-eligible directory is a real view: it carries meta.json, or — when
189
+ * meta.json was externally deleted — a host.json that is parseable AND names this
190
+ * directory as its view. A stray/garbage host.json in a foreign directory must not
191
+ * make that directory's screen.log a deletion candidate.
192
+ * @param {string} root @param {string} viewId
193
+ */
194
+ function isViewDir(root, viewId) {
195
+ if (existsSync(P.metaPath(root, viewId))) return true;
196
+ const host = readJson(P.hostPath(root, viewId), null);
197
+ return Boolean(host && host.viewId === viewId);
198
+ }
@@ -0,0 +1,160 @@
1
+ import {
2
+ appendFileSync,
3
+ closeSync,
4
+ existsSync,
5
+ fstatSync,
6
+ fsyncSync,
7
+ openSync,
8
+ readSync,
9
+ renameSync,
10
+ statSync,
11
+ unlinkSync,
12
+ writeSync,
13
+ } from "node:fs";
14
+
15
+ export const SCREEN_LOG_REPLAY_BYTES = 100_000;
16
+ export const SCREEN_LOG_MAX_BYTES = 5_000_000;
17
+
18
+ export const defaultScreenLogFs = Object.freeze({
19
+ appendFileSync,
20
+ closeSync,
21
+ existsSync,
22
+ fstatSync,
23
+ fsyncSync,
24
+ openSync,
25
+ readSync,
26
+ renameSync,
27
+ statSync,
28
+ unlinkSync,
29
+ writeSync,
30
+ });
31
+
32
+ let tempSequence = 0;
33
+
34
+ /** @param {string} file @param {number} maxBytes @param {typeof defaultScreenLogFs} fs */
35
+ export function readScreenLogTailBytes(file, maxBytes = SCREEN_LOG_REPLAY_BYTES, fs = defaultScreenLogFs) {
36
+ return readTailResult(file, maxBytes, fs).data;
37
+ }
38
+
39
+ /** @param {string} file @param {number} maxBytes */
40
+ export function readScreenLogTail(file, maxBytes = SCREEN_LOG_REPLAY_BYTES) {
41
+ return readScreenLogTailBytes(file, maxBytes).toString("utf8");
42
+ }
43
+
44
+ /**
45
+ * Compact an existing screen log when it exceeds maxBytes.
46
+ * @param {string} file
47
+ * @param {{ maxBytes?: number, retainBytes?: number, fs?: typeof defaultScreenLogFs }} [opts]
48
+ */
49
+ export function reconcileScreenLog(file, opts = {}) {
50
+ const fs = opts.fs ?? defaultScreenLogFs;
51
+ const { maxBytes, retainBytes } = limits(opts);
52
+ const size = fileSize(file, 0, fs);
53
+ if (size <= maxBytes) return size;
54
+ const tail = readTailResult(file, retainBytes, fs);
55
+ if (!tail.ok || !replaceScreenLog(file, tail.data, fs)) return fileSize(file, size, fs);
56
+ return fileSize(file, tail.data.length, fs);
57
+ }
58
+
59
+ /**
60
+ * Append PTY output while bounding the persisted replay log.
61
+ * @param {string} file
62
+ * @param {string|Buffer} data
63
+ * @param {number} currentBytes
64
+ * @param {{ maxBytes?: number, retainBytes?: number, fs?: typeof defaultScreenLogFs }} [opts]
65
+ */
66
+ export function appendBoundedScreenLog(file, data, currentBytes, opts = {}) {
67
+ const fs = opts.fs ?? defaultScreenLogFs;
68
+ const { maxBytes, retainBytes } = limits(opts);
69
+ const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
70
+ let size = Number.isFinite(currentBytes) && currentBytes >= 0 ? currentBytes : fileSize(file, 0, fs);
71
+
72
+ if (size > maxBytes) {
73
+ size = reconcileScreenLog(file, { maxBytes, retainBytes, fs });
74
+ if (size > maxBytes) return size;
75
+ }
76
+ if (payload.length === 0) return size;
77
+ if (payload.length >= maxBytes) {
78
+ const tail = payload.subarray(Math.max(0, payload.length - retainBytes));
79
+ if (!replaceScreenLog(file, tail, fs)) return fileSize(file, size, fs);
80
+ return fileSize(file, tail.length, fs);
81
+ }
82
+
83
+ try {
84
+ fs.appendFileSync(file, payload);
85
+ } catch {
86
+ return fileSize(file, size, fs);
87
+ }
88
+ size += payload.length;
89
+ if (size <= maxBytes) return size;
90
+ return reconcileScreenLog(file, { maxBytes, retainBytes, fs });
91
+ }
92
+
93
+ /** @param {string} file @param {number} maxBytes @param {typeof defaultScreenLogFs} fs */
94
+ function readTailResult(file, maxBytes, fs) {
95
+ if (!file || maxBytes <= 0 || !fs.existsSync(file)) return { ok: true, data: Buffer.alloc(0) };
96
+ let fd;
97
+ try {
98
+ fd = fs.openSync(file, "r");
99
+ const size = fs.fstatSync(fd).size;
100
+ const length = Math.min(size, Math.floor(maxBytes));
101
+ if (length <= 0) return { ok: true, data: Buffer.alloc(0) };
102
+ const output = Buffer.allocUnsafe(length);
103
+ const start = size - length;
104
+ let offset = 0;
105
+ while (offset < length) {
106
+ const read = fs.readSync(fd, output, offset, length - offset, start + offset);
107
+ if (read === 0) break;
108
+ offset += read;
109
+ }
110
+ return { ok: true, data: offset === length ? output : output.subarray(0, offset) };
111
+ } catch {
112
+ return { ok: false, data: Buffer.alloc(0) };
113
+ } finally {
114
+ if (fd !== undefined) {
115
+ try { fs.closeSync(fd); } catch {}
116
+ }
117
+ }
118
+ }
119
+
120
+ /** @param {{ maxBytes?: number, retainBytes?: number }} opts */
121
+ function limits(opts) {
122
+ const maxBytes = positiveInt(opts.maxBytes, SCREEN_LOG_MAX_BYTES);
123
+ return { maxBytes, retainBytes: Math.min(positiveInt(opts.retainBytes, SCREEN_LOG_REPLAY_BYTES), maxBytes) };
124
+ }
125
+
126
+ /** @param {string} file @param {Buffer} data @param {typeof defaultScreenLogFs} fs */
127
+ function replaceScreenLog(file, data, fs) {
128
+ const temp = `${file}.${process.pid}.${tempSequence++}.tmp`;
129
+ let fd;
130
+ try {
131
+ fd = fs.openSync(temp, "w");
132
+ let offset = 0;
133
+ while (offset < data.length) offset += fs.writeSync(fd, data, offset, data.length - offset, offset);
134
+ fs.fsyncSync(fd);
135
+ fs.closeSync(fd);
136
+ fd = undefined;
137
+ fs.renameSync(temp, file);
138
+ return true;
139
+ } catch {
140
+ if (fd !== undefined) {
141
+ try { fs.closeSync(fd); } catch {}
142
+ }
143
+ try { fs.unlinkSync(temp); } catch {}
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /** @param {string} file @param {number} fallback @param {typeof defaultScreenLogFs} fs */
149
+ function fileSize(file, fallback, fs) {
150
+ try {
151
+ return fs.statSync(file).size;
152
+ } catch {
153
+ return fallback;
154
+ }
155
+ }
156
+
157
+ function positiveInt(value, fallback) {
158
+ const number = Number(value);
159
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
160
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Read and project a managed Pi session file into a simple display model for the
3
+ * dashboard's non-interrupting session view.
4
+ *
5
+ * We show the current active branch only: starting from the last appended entry
6
+ * (the active leaf in normal Pi append semantics), walk parentId links to root,
7
+ * then render messages / visible custom messages / summaries on that branch.
8
+ */
9
+ import { existsSync, readFileSync } from "node:fs";
10
+
11
+ /**
12
+ * @typedef {Object} SessionViewItem
13
+ * @property {string} id
14
+ * @property {"user"|"assistant"|"custom"|"note"} role
15
+ * @property {string} label
16
+ * @property {string} text
17
+ * @property {string} timestamp
18
+ * @property {string} entryType
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} SessionViewData
23
+ * @property {{ id:string, cwd:string }|null} header
24
+ * @property {SessionViewItem[]} items
25
+ * @property {string|null} error
26
+ */
27
+
28
+ /**
29
+ * Load and parse one session file.
30
+ * @param {string} sessionFile
31
+ * @returns {SessionViewData}
32
+ */
33
+ export function loadSessionView(sessionFile) {
34
+ if (!sessionFile || !existsSync(sessionFile)) {
35
+ return { header: null, items: [], error: "Session file not created yet." };
36
+ }
37
+ try {
38
+ return parseSessionText(readFileSync(sessionFile, "utf8"));
39
+ } catch (err) {
40
+ return {
41
+ header: null,
42
+ items: [],
43
+ error: `Couldn't read session: ${err instanceof Error ? err.message : String(err)}`,
44
+ };
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Parse session JSONL into a displayable active branch.
50
+ * @param {string} text
51
+ * @returns {SessionViewData}
52
+ */
53
+ export function parseSessionText(text) {
54
+ /** @type {{ id:string, cwd:string }|null} */
55
+ let header = null;
56
+ /** @type {any[]} */
57
+ const entries = [];
58
+ /** @type {Map<string, any>} */
59
+ const byId = new Map();
60
+
61
+ for (const rawLine of String(text || "").split(/\r?\n/)) {
62
+ const line = rawLine.trim();
63
+ if (!line) continue;
64
+ let entry;
65
+ try {
66
+ entry = JSON.parse(line);
67
+ } catch {
68
+ continue;
69
+ }
70
+ if (entry?.type === "session") {
71
+ header = { id: String(entry.id || ""), cwd: String(entry.cwd || "") };
72
+ continue;
73
+ }
74
+ if (!entry || typeof entry !== "object" || typeof entry.id !== "string") continue;
75
+ entries.push(entry);
76
+ byId.set(entry.id, entry);
77
+ }
78
+
79
+ if (entries.length === 0) return { header, items: [], error: null };
80
+
81
+ const leaf = entries[entries.length - 1];
82
+ const branch = activeBranch(leaf, byId);
83
+ const items = branch.flatMap(displayItemsForEntry);
84
+ return { header, items, error: null };
85
+ }
86
+
87
+ /**
88
+ * Walk from `leaf` to root following parentId pointers.
89
+ * @param {any} leaf
90
+ * @param {Map<string, any>} byId
91
+ * @returns {any[]}
92
+ */
93
+ function activeBranch(leaf, byId) {
94
+ /** @type {any[]} */
95
+ const out = [];
96
+ const seen = new Set();
97
+ let cur = leaf;
98
+ while (cur && typeof cur.id === "string" && !seen.has(cur.id)) {
99
+ out.push(cur);
100
+ seen.add(cur.id);
101
+ cur = cur.parentId ? byId.get(cur.parentId) ?? null : null;
102
+ }
103
+ out.reverse();
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * @param {any} entry
109
+ * @returns {SessionViewItem[]}
110
+ */
111
+ function displayItemsForEntry(entry) {
112
+ switch (entry?.type) {
113
+ case "message": {
114
+ const role = entry.message?.role;
115
+ const text = contentText(entry.message?.content);
116
+ if (!text) return [];
117
+ if (role === "user") {
118
+ return [{ id: entry.id, role: "user", label: "you", text, timestamp: String(entry.timestamp || ""), entryType: entry.type }];
119
+ }
120
+ if (role === "assistant") {
121
+ return [{ id: entry.id, role: "assistant", label: "agent", text, timestamp: String(entry.timestamp || ""), entryType: entry.type }];
122
+ }
123
+ return [{ id: entry.id, role: "note", label: String(role || "message"), text, timestamp: String(entry.timestamp || ""), entryType: entry.type }];
124
+ }
125
+ case "custom_message": {
126
+ if (entry.display === false) return [];
127
+ const text = contentText(entry.content);
128
+ if (!text) return [];
129
+ return [{
130
+ id: entry.id,
131
+ role: "custom",
132
+ label: String(entry.customType || "context"),
133
+ text,
134
+ timestamp: String(entry.timestamp || ""),
135
+ entryType: entry.type,
136
+ }];
137
+ }
138
+ case "branch_summary":
139
+ return [{
140
+ id: entry.id,
141
+ role: "note",
142
+ label: "branch summary",
143
+ text: String(entry.summary || "").trim(),
144
+ timestamp: String(entry.timestamp || ""),
145
+ entryType: entry.type,
146
+ }].filter((x) => x.text);
147
+ case "compaction":
148
+ return [{
149
+ id: entry.id,
150
+ role: "note",
151
+ label: "compaction",
152
+ text: String(entry.summary || "").trim(),
153
+ timestamp: String(entry.timestamp || ""),
154
+ entryType: entry.type,
155
+ }].filter((x) => x.text);
156
+ default:
157
+ return [];
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Convert Pi message/content blocks to plain text.
163
+ * @param {any} content
164
+ * @returns {string}
165
+ */
166
+ function contentText(content) {
167
+ if (typeof content === "string") return content.trim();
168
+ if (!Array.isArray(content)) return "";
169
+ return content
170
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
171
+ .map((b) => b.text)
172
+ .join("\n")
173
+ .trim();
174
+ }
@@ -0,0 +1,34 @@
1
+ /** Prompt builders for steering-first Agent Board workflows. */
2
+
3
+ /** @param {string} userText */
4
+ export function buildPlanRequestPrompt(userText = "") {
5
+ const extra = String(userText || "").trim();
6
+ return [
7
+ "Create an implementation plan only. Do not modify files unless explicitly instructed by the user.",
8
+ "Return: scope, assumptions, proposed steps, validation plan, risks, and open questions.",
9
+ "This Agent Board plan mode is advisory; hard tool restrictions may not be enforced by the runtime yet.",
10
+ extra ? `User request/context: ${extra}` : "Use the current session context and repository state.",
11
+ ].join("\n\n");
12
+ }
13
+
14
+ /** @param {string} planText */
15
+ export function buildApprovePlanPrompt(planText = "") {
16
+ return [
17
+ "The user approved the plan below. Implement it now.",
18
+ "When finished, summarize changed files, commands/tests run, validation results, and residual risks for the Agent Board evidence panel.",
19
+ "Plan:",
20
+ String(planText || "(no plan text captured)"),
21
+ ].join("\n\n");
22
+ }
23
+
24
+ /** @param {string} planText @param {string} feedback */
25
+ export function buildPlanChangesPrompt(planText = "", feedback = "") {
26
+ return [
27
+ "Revise the implementation plan based on the user's feedback. Do not implement yet.",
28
+ "Return the revised plan and call out what changed.",
29
+ "Current plan:",
30
+ String(planText || "(no prior plan captured)"),
31
+ "User feedback:",
32
+ String(feedback || "(no feedback supplied)"),
33
+ ].join("\n\n");
34
+ }
@@ -0,0 +1,133 @@
1
+ /** Durable steering / plan-approval state helpers. */
2
+ import { atomicWriteJson, readJson } from "./atomic.mjs";
3
+ import { truncate } from "./heuristics.mjs";
4
+ import * as P from "./paths.mjs";
5
+
6
+ const STATES = new Set(["none", "plan_requested", "awaiting_approval", "approved", "changes_requested", "executing_approved_plan"]);
7
+
8
+ /** @param {string} viewId @param {number} [now] @returns {import("./types.mjs").SteeringState} */
9
+ export function emptySteeringState(viewId, now = Date.now()) {
10
+ return {
11
+ version: 1,
12
+ viewId,
13
+ status: "none",
14
+ updatedAt: now,
15
+ planText: "",
16
+ planRunId: null,
17
+ approvedAt: null,
18
+ changeRequest: null,
19
+ executionRunId: null,
20
+ history: [],
21
+ };
22
+ }
23
+
24
+ /** @param {string} root @param {string} viewId */
25
+ export function readSteering(root, viewId) {
26
+ return normalizeSteering(readJson(P.steeringPath(root, viewId), null), viewId);
27
+ }
28
+
29
+ /** @param {string} root @param {import("./types.mjs").SteeringState} state */
30
+ export function writeSteering(root, state) {
31
+ const normalized = normalizeSteering(state, state.viewId);
32
+ normalized.updatedAt = Date.now();
33
+ atomicWriteJson(P.steeringPath(root, normalized.viewId), normalized);
34
+ return normalized;
35
+ }
36
+
37
+ /** @param {import("./types.mjs").SteeringState} state @returns {import("./types.mjs").SteeringSummary} */
38
+ export function summarizeSteering(state) {
39
+ const s = normalizeSteering(state, state?.viewId ?? "");
40
+ return {
41
+ status: s.status,
42
+ awaitingApproval: s.status === "awaiting_approval",
43
+ planPreview: s.planText ? truncate(s.planText.replace(/\s+/g, " ").trim(), 180) : null,
44
+ updatedAt: s.updatedAt ?? null,
45
+ question: s.status === "awaiting_approval" ? "Approve this plan?" : null,
46
+ };
47
+ }
48
+
49
+ /** @param {string} root @param {string} viewId @param {{ runId?: string|null, note?: string }} [opts] */
50
+ export function requestPlan(root, viewId, opts = {}) {
51
+ const state = readSteering(root, viewId);
52
+ transition(state, "plan_requested", "request_plan", opts.runId ?? null, opts.note ?? null);
53
+ state.planRunId = opts.runId ?? state.planRunId ?? null;
54
+ return writeSteering(root, state);
55
+ }
56
+
57
+ /** @param {string} root @param {string} viewId @param {{ runId?: string|null, planText: string, note?: string }} opts */
58
+ export function recordPlanReady(root, viewId, opts) {
59
+ const state = readSteering(root, viewId);
60
+ state.planText = String(opts.planText || "").trim();
61
+ state.planRunId = opts.runId ?? state.planRunId ?? null;
62
+ transition(state, "awaiting_approval", "plan_ready", opts.runId ?? null, opts.note ?? null);
63
+ return writeSteering(root, state);
64
+ }
65
+
66
+ /** @param {string} root @param {string} viewId @param {{ runId?: string|null, note?: string }} [opts] */
67
+ export function approvePlan(root, viewId, opts = {}) {
68
+ const state = readSteering(root, viewId);
69
+ if (state.status !== "awaiting_approval") return { ok: false, error: "No plan is awaiting approval", state };
70
+ state.approvedAt = Date.now();
71
+ state.executionRunId = opts.runId ?? null;
72
+ transition(state, "approved", "approve_plan", opts.runId ?? null, opts.note ?? null);
73
+ const saved = writeSteering(root, state);
74
+ return { ok: true, state: saved };
75
+ }
76
+
77
+ /** @param {string} root @param {string} viewId @param {string} feedback @param {{ runId?: string|null }} [opts] */
78
+ export function requestPlanChanges(root, viewId, feedback, opts = {}) {
79
+ const clean = String(feedback || "").trim();
80
+ if (!clean) return { ok: false, error: "Empty change request", state: readSteering(root, viewId) };
81
+ const state = readSteering(root, viewId);
82
+ state.changeRequest = clean;
83
+ transition(state, "changes_requested", "request_plan_changes", opts.runId ?? null, clean);
84
+ const saved = writeSteering(root, state);
85
+ return { ok: true, state: saved };
86
+ }
87
+
88
+ /** @param {string} root @param {string} viewId @param {{ runId?: string|null }} [opts] */
89
+ export function markExecutingApprovedPlan(root, viewId, opts = {}) {
90
+ const state = readSteering(root, viewId);
91
+ state.executionRunId = opts.runId ?? state.executionRunId ?? null;
92
+ transition(state, "executing_approved_plan", "execute_approved_plan", opts.runId ?? null, null);
93
+ return writeSteering(root, state);
94
+ }
95
+
96
+ /** @param {string} root @param {string} viewId @param {{ note?: string }} [opts] */
97
+ export function resetSteering(root, viewId, opts = {}) {
98
+ const state = readSteering(root, viewId);
99
+ transition(state, "none", "reset", null, opts.note ?? null);
100
+ state.planText = "";
101
+ state.planRunId = null;
102
+ state.approvedAt = null;
103
+ state.changeRequest = null;
104
+ state.executionRunId = null;
105
+ return writeSteering(root, state);
106
+ }
107
+
108
+ /** @param {any} state @param {string} viewId @returns {import("./types.mjs").SteeringState} */
109
+ function normalizeSteering(state, viewId) {
110
+ const base = emptySteeringState(viewId || state?.viewId || "");
111
+ if (!state || typeof state !== "object") return base;
112
+ return {
113
+ ...base,
114
+ ...state,
115
+ viewId: typeof state.viewId === "string" ? state.viewId : base.viewId,
116
+ status: STATES.has(state.status) ? state.status : "none",
117
+ planText: typeof state.planText === "string" ? state.planText : "",
118
+ planRunId: typeof state.planRunId === "string" ? state.planRunId : null,
119
+ approvedAt: Number.isFinite(state.approvedAt) ? state.approvedAt : null,
120
+ changeRequest: typeof state.changeRequest === "string" ? state.changeRequest : null,
121
+ executionRunId: typeof state.executionRunId === "string" ? state.executionRunId : null,
122
+ history: Array.isArray(state.history) ? state.history : [],
123
+ };
124
+ }
125
+
126
+ /** @param {import("./types.mjs").SteeringState} state @param {import("./types.mjs").SteeringModeState} to @param {string} action @param {string|null} runId @param {string|null} note */
127
+ function transition(state, to, action, runId, note) {
128
+ const now = Date.now();
129
+ const from = state.status;
130
+ state.status = to;
131
+ state.updatedAt = now;
132
+ state.history.push({ at: now, from, to, action, runId, note: note ? truncate(note, 240) : null });
133
+ }