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