@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,704 @@
1
+ # screen.log Startup GC Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Reclaim disk from ended views' `screen.log` files via a startup GC, plus `screenLogRetentionDays` / `screenLogMaxSize` launch-prefs knobs (issue zhuxixi/pi-agent-board#1).
6
+
7
+ **Architecture:** New module `src/core/screen-log-gc.mjs` owns retention policy (`pruneScreenLogs` + prefs normalizers). `createService` triggers it deferred on dashboard startup. `launchHost` passes `screenLogMaxBytes` through `HostConfig` to `pty-runner.mjs`, which forwards it to the existing `appendBoundedScreenLog`/`reconcileScreenLog` cap logic.
8
+
9
+ **Tech Stack:** Pure Node ESM (`.mjs`), `node:test` + tmp dirs (repo test style), no new dependencies.
10
+
11
+ ## Global Constraints
12
+
13
+ - Spec: `docs/superpowers/specs/2026-08-15-screenlog-gc-design.md` (this repo, previous commit).
14
+ - Test runner: `node --test test/*.test.mjs` (NOT bun). Full suite: `npm test`; typecheck: `npm run typecheck`.
15
+ - Indent with **tabs**, matching existing `.mjs` files.
16
+ - Commit messages: conventional commits (`feat`/`fix`/`test`/`refactor`).
17
+ - No new npm dependencies. No changes to job-runner (it never writes screen.log).
18
+ - **Active views must never be touched by GC** — runner holds an in-memory byte counter; external mutation of a live log races with it.
19
+ - Work only inside this worktree; never touch the main checkout.
20
+
21
+ ---
22
+
23
+ ### Task 1: `src/core/screen-log-gc.mjs` — retention policy module
24
+
25
+ **Files:**
26
+ - Create: `src/core/screen-log-gc.mjs`
27
+ - Test: `test/screen-log-gc.test.mjs`
28
+
29
+ **Interfaces:**
30
+ - Consumes: `readJson` from `src/core/atomic.mjs` (signature `readJson(path, fallback)`), path helpers from `src/core/paths.mjs` (`viewsDir`, `screenLogPath`, `hostPath`, `viewDir`).
31
+ - Produces:
32
+ - `DEFAULT_SCREEN_LOG_RETENTION_DAYS` (const, `7`)
33
+ - `normalizeRetentionDays(value) → number|null` — `0` → `null` (GC disabled); positive finite → floored int; anything else → default 7.
34
+ - `normalizeScreenLogMaxBytes(value) → number|null` — positive finite → floored int; anything else → `null` (runner uses built-in default).
35
+ - `pruneScreenLogs(root, opts?) → { scanned, removed, skippedActive, skippedFresh, bytesReclaimed, errors }` where `opts = { retentionDays?: number|null, now?: number }`.
36
+
37
+ - [ ] **Step 1: Write the failing test**
38
+
39
+ Create `test/screen-log-gc.test.mjs`:
40
+
41
+ ```js
42
+ import assert from "node:assert/strict";
43
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
44
+ import { tmpdir } from "node:os";
45
+ import { join } from "node:path";
46
+ import { test } from "node:test";
47
+ import { atomicWriteJson } from "../src/core/atomic.mjs";
48
+ import * as P from "../src/core/paths.mjs";
49
+ import {
50
+ DEFAULT_SCREEN_LOG_RETENTION_DAYS,
51
+ normalizeRetentionDays,
52
+ pruneScreenLogs,
53
+ } from "../src/core/screen-log-gc.mjs";
54
+
55
+ const DAY_MS = 24 * 60 * 60 * 1000;
56
+
57
+ function freshRoot() {
58
+ return mkdtempSync(join(tmpdir(), "agent-board-gc-"));
59
+ }
60
+
61
+ /** Create a view dir with a small meta.json, optional host.json, optional screen.log. */
62
+ function makeView(root, viewId, { host = null, logBytes = 128, logMtimeMs = null } = {}) {
63
+ mkdirSync(P.viewDir(root, viewId), { recursive: true });
64
+ writeFileSync(P.metaPath(root, viewId), "{}");
65
+ if (host) atomicWriteJson(P.hostPath(root, viewId), host);
66
+ if (logBytes > 0) {
67
+ writeFileSync(P.screenLogPath(root, viewId), Buffer.alloc(logBytes, 65));
68
+ if (logMtimeMs != null) {
69
+ const secs = logMtimeMs / 1000;
70
+ utimesSync(P.screenLogPath(root, viewId), secs, secs);
71
+ }
72
+ }
73
+ }
74
+
75
+ test("normalizeRetentionDays maps prefs values", () => {
76
+ assert.equal(normalizeRetentionDays(0), null); // disabled
77
+ assert.equal(normalizeRetentionDays(7), 7);
78
+ assert.equal(normalizeRetentionDays("3"), 3);
79
+ assert.equal(normalizeRetentionDays(2.9), 2);
80
+ assert.equal(normalizeRetentionDays(-2), DEFAULT_SCREEN_LOG_RETENTION_DAYS);
81
+ assert.equal(normalizeRetentionDays(NaN), DEFAULT_SCREEN_LOG_RETENTION_DAYS);
82
+ assert.equal(normalizeRetentionDays(undefined), DEFAULT_SCREEN_LOG_RETENTION_DAYS);
83
+ });
84
+
85
+ test("removes screen.log of ended views past retention, keeps other files", () => {
86
+ const root = freshRoot();
87
+ try {
88
+ const now = Date.now();
89
+ makeView(root, "old", { host: { state: "exited", endedAt: now - 10 * DAY_MS }, logBytes: 4096 });
90
+ const stats = pruneScreenLogs(root, { now });
91
+ assert.equal(stats.removed, 1);
92
+ assert.equal(stats.bytesReclaimed, 4096);
93
+ assert.equal(existsSync(P.screenLogPath(root, "old")), false);
94
+ assert.equal(existsSync(P.metaPath(root, "old")), true); // history row survives
95
+ } finally {
96
+ rmSync(root, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ test("active views are never touched, even with old logs", () => {
101
+ const root = freshRoot();
102
+ try {
103
+ const now = Date.now();
104
+ makeView(root, "live", {
105
+ host: { state: "alive", endedAt: null },
106
+ logBytes: 4096,
107
+ logMtimeMs: now - 30 * DAY_MS, // mtime says ancient; host says live — live wins
108
+ });
109
+ makeView(root, "starting", { host: { state: "starting", endedAt: null }, logBytes: 4096 });
110
+ const stats = pruneScreenLogs(root, { now });
111
+ assert.equal(stats.skippedActive, 2);
112
+ assert.equal(stats.removed, 0);
113
+ assert.equal(existsSync(P.screenLogPath(root, "live")), true);
114
+ assert.equal(existsSync(P.screenLogPath(root, "starting")), true);
115
+ } finally {
116
+ rmSync(root, { recursive: true, force: true });
117
+ }
118
+ });
119
+
120
+ test("recently ended views are kept", () => {
121
+ const root = freshRoot();
122
+ try {
123
+ const now = Date.now();
124
+ makeView(root, "fresh", { host: { state: "exited", endedAt: now - 1 * DAY_MS } });
125
+ const stats = pruneScreenLogs(root, { now });
126
+ assert.equal(stats.removed, 0);
127
+ assert.equal(stats.skippedFresh, 1);
128
+ assert.equal(existsSync(P.screenLogPath(root, "fresh")), true);
129
+ } finally {
130
+ rmSync(root, { recursive: true, force: true });
131
+ }
132
+ });
133
+
134
+ test("retentionDays 0 disables GC entirely", () => {
135
+ const root = freshRoot();
136
+ try {
137
+ const now = Date.now();
138
+ makeView(root, "old", { host: { state: "exited", endedAt: now - 365 * DAY_MS } });
139
+ const stats = pruneScreenLogs(root, { retentionDays: 0, now });
140
+ assert.equal(stats.removed, 0);
141
+ assert.equal(existsSync(P.screenLogPath(root, "old")), true);
142
+ } finally {
143
+ rmSync(root, { recursive: true, force: true });
144
+ }
145
+ });
146
+
147
+ test("missing host.json falls back to screen.log mtime", () => {
148
+ const root = freshRoot();
149
+ try {
150
+ const now = Date.now();
151
+ makeView(root, "stale", { host: null, logMtimeMs: now - 30 * DAY_MS });
152
+ makeView(root, "recent", { host: null, logMtimeMs: now - 1 * DAY_MS });
153
+ const stats = pruneScreenLogs(root, { now });
154
+ assert.equal(stats.removed, 1);
155
+ assert.equal(stats.skippedFresh, 1);
156
+ assert.equal(existsSync(P.screenLogPath(root, "stale")), false);
157
+ assert.equal(existsSync(P.screenLogPath(root, "recent")), true);
158
+ } finally {
159
+ rmSync(root, { recursive: true, force: true });
160
+ }
161
+ });
162
+
163
+ test("an unlink failure does not abort the sweep", () => {
164
+ const root = freshRoot();
165
+ try {
166
+ const now = Date.now();
167
+ // A directory named screen.log: statSync succeeds with size>0, unlinkSync fails (EISDIR).
168
+ makeView(root, "broken", { host: { state: "exited", endedAt: now - 10 * DAY_MS }, logBytes: 0 });
169
+ mkdirSync(P.screenLogPath(root, "broken"));
170
+ makeView(root, "normal", { host: { state: "exited", endedAt: now - 10 * DAY_MS } });
171
+ const stats = pruneScreenLogs(root, { now });
172
+ assert.equal(stats.errors, 1);
173
+ assert.equal(stats.removed, 1);
174
+ assert.equal(existsSync(P.screenLogPath(root, "normal")), false);
175
+ } finally {
176
+ rmSync(root, { recursive: true, force: true });
177
+ }
178
+ });
179
+ ```
180
+
181
+ - [ ] **Step 2: Run test to verify it fails**
182
+
183
+ Run: `node --test test/screen-log-gc.test.mjs`
184
+ Expected: FAIL — `Cannot find module '../src/core/screen-log-gc.mjs'`
185
+
186
+ - [ ] **Step 3: Write minimal implementation**
187
+
188
+ Create `src/core/screen-log-gc.mjs`:
189
+
190
+ ```js
191
+ /**
192
+ * Startup GC for per-view PTY replay logs.
193
+ *
194
+ * screen.log write-path growth is already bounded by screen-log.mjs (cap + tail
195
+ * compaction inside pty-runner). This module reclaims the other half: logs of
196
+ * views whose session ENDED long ago — no runner will ever touch them again,
197
+ * so without a sweep they sit on disk forever.
198
+ *
199
+ * Safety rules:
200
+ * - Only screen.log is removed; meta/state/evidence stay so the dashboard row survives.
201
+ * - Views with a live host (state alive/starting, endedAt null) are never touched:
202
+ * pty-runner holds an in-memory byte counter for its log and external mutation
203
+ * would race with it. Live logs are bounded by the runner's own cap.
204
+ */
205
+ import { readdirSync, statSync, unlinkSync } from "node:fs";
206
+ import { readJson } from "./atomic.mjs";
207
+ import * as P from "./paths.mjs";
208
+
209
+ export const DEFAULT_SCREEN_LOG_RETENTION_DAYS = 7;
210
+ const DAY_MS = 24 * 60 * 60 * 1000;
211
+
212
+ /**
213
+ * Normalize the `screenLogRetentionDays` pref.
214
+ * @param {unknown} value
215
+ * @returns {number|null} days, or null when GC is disabled (pref = 0)
216
+ */
217
+ export function normalizeRetentionDays(value) {
218
+ if (value === 0 || value === "0") return null;
219
+ const n = Number(value);
220
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_SCREEN_LOG_RETENTION_DAYS;
221
+ return Math.floor(n);
222
+ }
223
+
224
+ /**
225
+ * Normalize the `screenLogMaxSize` pref.
226
+ * @param {unknown} value
227
+ * @returns {number|null} bytes, or null to keep the runner's built-in default
228
+ */
229
+ export function normalizeScreenLogMaxBytes(value) {
230
+ const n = Number(value);
231
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
232
+ }
233
+
234
+ /**
235
+ * Delete screen.log of ended views older than the retention window.
236
+ * Best-effort: a per-file failure is counted and skipped, never thrown.
237
+ * @param {string} root
238
+ * @param {{ retentionDays?: number|null, now?: number }} [opts]
239
+ * @returns {{ scanned: number, removed: number, skippedActive: number, skippedFresh: number, bytesReclaimed: number, errors: number }}
240
+ */
241
+ export function pruneScreenLogs(root, opts = {}) {
242
+ const stats = { scanned: 0, removed: 0, skippedActive: 0, skippedFresh: 0, bytesReclaimed: 0, errors: 0 };
243
+ const retentionDays = normalizeRetentionDays(opts.retentionDays);
244
+ if (retentionDays === null) return stats;
245
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
246
+ const cutoff = now - retentionDays * DAY_MS;
247
+ /** @type {import("node:fs").Dirent[]} */
248
+ let entries;
249
+ try {
250
+ entries = readdirSync(P.viewsDir(root), { withFileTypes: true });
251
+ } catch {
252
+ return stats; // no views dir yet — nothing to do
253
+ }
254
+ for (const entry of entries) {
255
+ if (!entry.isDirectory()) continue;
256
+ const logFile = P.screenLogPath(root, entry.name);
257
+ /** @type {number} */
258
+ let size;
259
+ try {
260
+ size = statSync(logFile).size;
261
+ } catch {
262
+ continue; // no screen.log (job-runner views never have one)
263
+ }
264
+ if (size <= 0) continue;
265
+ stats.scanned++;
266
+ const basis = ageBasisMs(root, entry.name, logFile);
267
+ if (basis === "active") {
268
+ stats.skippedActive++;
269
+ continue;
270
+ }
271
+ if (basis === null || basis > cutoff) {
272
+ stats.skippedFresh++;
273
+ continue;
274
+ }
275
+ try {
276
+ unlinkSync(logFile);
277
+ stats.removed++;
278
+ stats.bytesReclaimed += size;
279
+ } catch {
280
+ stats.errors++;
281
+ }
282
+ }
283
+ return stats;
284
+ }
285
+
286
+ /**
287
+ * Age basis for one view's log: host endedAt when known, else the log's mtime.
288
+ * @param {string} root @param {string} viewId @param {string} logFile
289
+ * @returns {number|null|"active"} epoch ms, "active" for live views, null when unknown
290
+ */
291
+ function ageBasisMs(root, viewId, logFile) {
292
+ const host = readJson(P.hostPath(root, viewId), null);
293
+ if (host && host.endedAt == null && (host.state === "alive" || host.state === "starting")) return "active";
294
+ if (host && Number.isFinite(host.endedAt)) return host.endedAt;
295
+ try {
296
+ return statSync(logFile).mtimeMs;
297
+ } catch {
298
+ return null;
299
+ }
300
+ }
301
+ ```
302
+
303
+ - [ ] **Step 4: Run test to verify it passes**
304
+
305
+ Run: `node --test test/screen-log-gc.test.mjs`
306
+ Expected: PASS (7 tests)
307
+
308
+ - [ ] **Step 5: Commit**
309
+
310
+ ```bash
311
+ git add src/core/screen-log-gc.mjs test/screen-log-gc.test.mjs
312
+ git commit -m "feat: add startup GC module for ended views' screen logs (issue #1)"
313
+ ```
314
+
315
+ ---
316
+
317
+ ### Task 2: launch-prefs carry the two new knobs
318
+
319
+ **Files:**
320
+ - Modify: `src/core/types.mjs` (LaunchPrefs typedef, ~line 369)
321
+ - Modify: `src/core/store.mjs` (`readLaunchPrefs` ~line 56, `writeLaunchPrefs` ~line 61)
322
+ - Test: `test/store.test.mjs` (append)
323
+
324
+ **Interfaces:**
325
+ - Consumes: nothing new.
326
+ - Produces: `LaunchPrefs` gains optional `screenLogRetentionDays: number|null` and `screenLogMaxSize: number|null` (both `null` = unset). Task 3 reads them via the existing `readLaunchPrefs(root)`.
327
+
328
+ - [ ] **Step 1: Write the failing test**
329
+
330
+ Append to `test/store.test.mjs` (reuse its existing `freshRoot()` helper; add `readLaunchPrefs, writeLaunchPrefs` to its `../src/core/store.mjs` import if not already imported):
331
+
332
+ ```js
333
+ test("launch prefs carry screen log knobs with null defaults", () => {
334
+ const root = freshRoot();
335
+ try {
336
+ const prefs = readLaunchPrefs(root);
337
+ assert.equal(prefs.screenLogRetentionDays, null);
338
+ assert.equal(prefs.screenLogMaxSize, null);
339
+ writeLaunchPrefs(root, { cwd: "/tmp/x", screenLogRetentionDays: 3, screenLogMaxSize: 2048 });
340
+ const next = readLaunchPrefs(root);
341
+ assert.equal(next.screenLogRetentionDays, 3);
342
+ assert.equal(next.screenLogMaxSize, 2048);
343
+ assert.equal(next.cwd, "/tmp/x"); // existing fields untouched
344
+ } finally {
345
+ rmSync(root, { recursive: true, force: true });
346
+ }
347
+ });
348
+ ```
349
+
350
+ - [ ] **Step 2: Run test to verify it fails**
351
+
352
+ Run: `node --test test/store.test.mjs`
353
+ Expected: FAIL — `assert.equal(prefs.screenLogRetentionDays, null)` gets `undefined`
354
+
355
+ - [ ] **Step 3: Write minimal implementation**
356
+
357
+ In `src/core/types.mjs`, extend the LaunchPrefs typedef (null = unset; consumers apply defaults):
358
+
359
+ ```js
360
+ /**
361
+ * Persisted launch dialog defaults (`launch-prefs.json`).
362
+ * @typedef {Object} LaunchPrefs
363
+ * @property {number} version
364
+ * @property {string|null} cwd
365
+ * @property {string|null} model
366
+ * @property {"off"|"minimal"|"low"|"medium"|"high"|"xhigh"|null} thinkingLevel
367
+ * @property {number|null} screenLogRetentionDays days before an ended view's screen.log is GC'd; 0 disables GC
368
+ * @property {number|null} screenLogMaxSize per-view screen.log write cap in bytes; null = built-in default
369
+ */
370
+ ```
371
+
372
+ In `src/core/store.mjs`:
373
+
374
+ ```js
375
+ /** @param {string} root @returns {LaunchPrefs} */
376
+ export function readLaunchPrefs(root) {
377
+ return readJson(P.launchPrefsPath(root), {
378
+ version: 1,
379
+ cwd: null,
380
+ model: null,
381
+ thinkingLevel: null,
382
+ screenLogRetentionDays: null,
383
+ screenLogMaxSize: null,
384
+ });
385
+ }
386
+
387
+ /** @param {string} root @param {Partial<LaunchPrefs>} prefs */
388
+ export function writeLaunchPrefs(root, prefs) {
389
+ atomicWriteJson(P.launchPrefsPath(root), {
390
+ version: 1,
391
+ cwd: prefs.cwd ?? null,
392
+ model: prefs.model ?? null,
393
+ thinkingLevel: prefs.thinkingLevel ?? null,
394
+ screenLogRetentionDays: prefs.screenLogRetentionDays ?? null,
395
+ screenLogMaxSize: prefs.screenLogMaxSize ?? null,
396
+ });
397
+ }
398
+ ```
399
+
400
+ (The `writeLaunchPrefs` change is load-bearing: without it, saving launch dialog prefs would silently drop the new fields.)
401
+
402
+ - [ ] **Step 4: Run test to verify it passes**
403
+
404
+ Run: `node --test test/store.test.mjs`
405
+ Expected: PASS
406
+
407
+ - [ ] **Step 5: Commit**
408
+
409
+ ```bash
410
+ git add src/core/types.mjs src/core/store.mjs test/store.test.mjs
411
+ git commit -m "feat: add screen log retention knobs to launch prefs (issue #1)"
412
+ ```
413
+
414
+ ---
415
+
416
+ ### Task 3: service wiring — GC trigger + HostConfig passthrough
417
+
418
+ **Files:**
419
+ - Modify: `src/runtime/service.mjs` (imports, `createService` top ~line 60, `launchHost` config ~line 107, opts JSDoc ~line 55)
420
+ - Modify: `src/core/types.mjs` (HostConfig typedef ~line 200)
421
+ - Test: `test/service.test.mjs` (append)
422
+
423
+ **Interfaces:**
424
+ - Consumes: `pruneScreenLogs`, `normalizeScreenLogMaxBytes` from Task 1; `readLaunchPrefs` (already imported in service.mjs); prefs fields from Task 2.
425
+ - Produces:
426
+ - `createService` opts gains optional `pruneScreenLogs?: typeof pruneScreenLogs` (test injection point).
427
+ - `HostConfig` gains `screenLogMaxBytes: number|null` — Task 4 reads it in the runner.
428
+
429
+ - [ ] **Step 1: Write the failing tests**
430
+
431
+ Append to `test/service.test.mjs` (reuses its `service(root, overrides)` helper — it spreads `...overrides` into `createService`; also reuse `createView`, and add `readLaunchPrefs, writeLaunchPrefs` / `atomicWriteJson` imports as needed):
432
+
433
+ ```js
434
+ test("createService schedules screen log GC with the prefs retention", async () => {
435
+ const root = freshRoot();
436
+ try {
437
+ writeLaunchPrefs(root, { screenLogRetentionDays: 3 });
438
+ const calls = [];
439
+ service(root, { pruneScreenLogs: (r, o) => calls.push([r, o]) });
440
+ // GC is deferred via setImmediate; one tick is enough (FIFO order).
441
+ await new Promise((r) => setImmediate(r));
442
+ assert.equal(calls.length, 1);
443
+ assert.equal(calls[0][0], root);
444
+ assert.deepEqual(calls[0][1], { retentionDays: 3 });
445
+ } finally {
446
+ rmSync(root, { recursive: true, force: true });
447
+ }
448
+ });
449
+
450
+ test("a failing screen log GC does not break createService", async () => {
451
+ const root = freshRoot();
452
+ try {
453
+ const svc = service(root, {
454
+ pruneScreenLogs: () => {
455
+ throw new Error("gc boom");
456
+ },
457
+ });
458
+ await new Promise((r) => setImmediate(r));
459
+ assert.equal(typeof svc.row, "function"); // service still constructed fine
460
+ } finally {
461
+ rmSync(root, { recursive: true, force: true });
462
+ }
463
+ });
464
+
465
+ test("ensureHost passes screenLogMaxBytes from prefs into HostConfig", async () => {
466
+ const root = freshRoot();
467
+ try {
468
+ writeLaunchPrefs(root, { screenLogMaxSize: 2048 });
469
+ const meta = createView(root, { id: "gc1", name: "gc1", cwd: process.cwd() });
470
+ writeFileSync(meta.sessionFile, "");
471
+ let captured = null;
472
+ const svc = service(root, {
473
+ ptySupport: () => ({ ok: true }),
474
+ launchHost: (r, config) => {
475
+ captured = config;
476
+ return { pid: null, configPath: "/no/host-config.json" };
477
+ },
478
+ });
479
+ const result = svc.ensureHost("gc1");
480
+ assert.equal(result.ok, true);
481
+ assert.equal(captured.screenLogMaxBytes, 2048);
482
+ } finally {
483
+ rmSync(root, { recursive: true, force: true });
484
+ }
485
+ });
486
+ ```
487
+
488
+ (`ensureHost` requires: view exists, not busy, session file exists, `ptySupport` ok — the setup above satisfies all four. `svc.ensureHost` is the public method at service.mjs:657 calling internal `launchHost`.)
489
+
490
+ - [ ] **Step 2: Run tests to verify they fail**
491
+
492
+ Run: `node --test test/service.test.mjs`
493
+ Expected: FAIL — GC tests: `calls.length` is 0 (no such opt); ensureHost test: `captured.screenLogMaxBytes` is `undefined`.
494
+
495
+ - [ ] **Step 3: Write minimal implementation**
496
+
497
+ In `src/runtime/service.mjs`:
498
+
499
+ 1. Add import (near the other core imports):
500
+
501
+ ```js
502
+ import { normalizeScreenLogMaxBytes, pruneScreenLogs } from "../core/screen-log-gc.mjs";
503
+ ```
504
+
505
+ 2. Extend the `createService` opts JSDoc block with one line:
506
+
507
+ ```
508
+ * pruneScreenLogs?: typeof pruneScreenLogs,
509
+ ```
510
+
511
+ 3. Right after the existing `const ptyRunnerScript = ...; const titleRunnerScript = ...;` lines at the top of `createService`, add:
512
+
513
+ ```js
514
+ const pruneScreenLogsImpl = opts.pruneScreenLogs ?? pruneScreenLogs;
515
+ // Reclaim replay logs of long-ended views on dashboard startup. Deferred via
516
+ // setImmediate so the first frame is unaffected; any failure must not break
517
+ // the dashboard.
518
+ setImmediate(() => {
519
+ try {
520
+ pruneScreenLogsImpl(root, { retentionDays: readLaunchPrefs(root).screenLogRetentionDays });
521
+ } catch {}
522
+ }).unref?.();
523
+ ```
524
+
525
+ 4. In `launchHost(meta, initialPrompt, launchOpts)`, add one field to the `config` object literal (after `rows`):
526
+
527
+ ```js
528
+ screenLogMaxBytes: normalizeScreenLogMaxBytes(readLaunchPrefs(root).screenLogMaxSize),
529
+ ```
530
+
531
+ In `src/core/types.mjs`, extend the HostConfig typedef (after `@property {number} rows`):
532
+
533
+ ```
534
+ * @property {number|null} screenLogMaxBytes per-view screen.log write cap; null = runner default
535
+ ```
536
+
537
+ - [ ] **Step 4: Run tests to verify they pass**
538
+
539
+ Run: `node --test test/service.test.mjs`
540
+ Expected: PASS (new 3 + existing)
541
+
542
+ - [ ] **Step 5: Commit**
543
+
544
+ ```bash
545
+ git add src/runtime/service.mjs src/core/types.mjs test/service.test.mjs
546
+ git commit -m "feat: run screen log GC on service startup and pass max size to hosts (issue #1)"
547
+ ```
548
+
549
+ ---
550
+
551
+ ### Task 4: pty-runner honors `screenLogMaxBytes`
552
+
553
+ **Files:**
554
+ - Modify: `runner/pty-runner.mjs` (main() config read ~line 41, onData append ~line 122)
555
+ - Test: `test/pty-runner.integration.test.mjs` (append)
556
+
557
+ **Interfaces:**
558
+ - Consumes: `HostConfig.screenLogMaxBytes` from Task 3; existing `reconcileScreenLog(file, { maxBytes })` / `appendBoundedScreenLog(file, data, bytes, { maxBytes })` from `src/core/screen-log.mjs` (both already accept `opts.maxBytes`; `undefined` falls back to the built-in 5 MB default, and `retainBytes` is clamped to `min(100 KB, maxBytes)`).
559
+ - Produces: runner-side cap honoring the pref. Old host-config.json files without the field keep today's behavior.
560
+
561
+ - [ ] **Step 1: Write the failing test**
562
+
563
+ Append to `test/pty-runner.integration.test.mjs` (reuses its `freshRoot`, `waitFor`, `send` helpers and the `createConnection` socket pattern from the existing test):
564
+
565
+ ```js
566
+ test("pty-runner honors screenLogMaxBytes from host config", async () => {
567
+ const root = freshRoot();
568
+ let runner;
569
+ try {
570
+ const meta = createView(root, { id: "cap1", name: "cap", cwd: process.cwd() });
571
+ const configPath = P.hostConfigPath(root, "cap1");
572
+ atomicWriteJson(configPath, {
573
+ root,
574
+ viewId: "cap1",
575
+ sessionFile: meta.sessionFile,
576
+ cwd: process.cwd(),
577
+ initialPrompt: null,
578
+ piCommand: process.execPath,
579
+ piArgsPrefix: [resolve("test-support/fake-pty-pi.mjs")],
580
+ model: null,
581
+ tools: null,
582
+ env: { AGENT_BOARD_ALLOW_PIPE_FALLBACK: "1" },
583
+ cols: 80,
584
+ rows: 24,
585
+ screenLogMaxBytes: 2048,
586
+ });
587
+ runner = spawn(process.execPath, [resolve("runner/pty-runner.mjs"), configPath], { stdio: ["ignore", "pipe", "pipe"] });
588
+ await waitFor(() => existsSync(P.controlSocketPath(root, "cap1")) && readHost(root, "cap1")?.state === "alive");
589
+
590
+ const socket = createConnection(P.controlSocketPath(root, "cap1"));
591
+ await once(socket, "connect");
592
+ // ~8 KB of echoed output → well over the 2 KB cap → runner must compact.
593
+ send(socket, { type: "input", data: `${"x".repeat(8192)}\n` });
594
+ await waitFor(() => {
595
+ try {
596
+ return statSync(P.screenLogPath(root, "cap1")).size > 0;
597
+ } catch {
598
+ return false;
599
+ }
600
+ });
601
+ send(socket, { type: "input", data: "exit\n" });
602
+ await waitFor(() => readHost(root, "cap1")?.endedAt != null);
603
+ // Compaction happens synchronously inside onData; size must settle ≤ cap.
604
+ const size = await waitFor(() => {
605
+ try {
606
+ const s = statSync(P.screenLogPath(root, "cap1")).size;
607
+ return s <= 2048 ? s : false;
608
+ } catch {
609
+ return false;
610
+ }
611
+ });
612
+ assert.ok(size > 0 && size <= 2048, `screen.log should be compacted to <=2048 bytes, got ${size}`);
613
+ socket.end();
614
+ } finally {
615
+ try { runner?.kill(); } catch {}
616
+ rmSync(root, { recursive: true, force: true });
617
+ }
618
+ });
619
+ ```
620
+
621
+ (Add `statSync` to the `node:fs` import at the top of the test file if missing.)
622
+
623
+ Note: the fake pi echoes input (`echo:<text>`), so an 8192-char input produces >8 KB of output, exceeding the 2048-byte cap and forcing compaction. Compaction runs synchronously in `appendBoundedScreenLog` before the broadcast, so by the time `endedAt` is set the file is already bounded.
624
+
625
+ - [ ] **Step 2: Run test to verify it fails**
626
+
627
+ Run: `node --test test/pty-runner.integration.test.mjs`
628
+ Expected: FAIL — the new test's `waitFor(size <= 2048)` times out (runner ignores the unknown field today, log stays >8 KB).
629
+
630
+ - [ ] **Step 3: Write minimal implementation**
631
+
632
+ In `runner/pty-runner.mjs`, inside `main()`:
633
+
634
+ Replace:
635
+
636
+ ```js
637
+ const screenLog = P.screenLogPath(config.root, config.viewId);
638
+ let screenLogBytes = reconcileScreenLog(screenLog);
639
+ ```
640
+
641
+ with:
642
+
643
+ ```js
644
+ const screenLog = P.screenLogPath(config.root, config.viewId);
645
+ // Optional per-install cap override from launch prefs (screenLogMaxSize).
646
+ // undefined → screen-log.mjs falls back to its built-in default.
647
+ const screenLogMaxBytes =
648
+ Number.isFinite(config.screenLogMaxBytes) && config.screenLogMaxBytes > 0
649
+ ? Math.floor(config.screenLogMaxBytes)
650
+ : undefined;
651
+ const screenLogLimits = { maxBytes: screenLogMaxBytes };
652
+ let screenLogBytes = reconcileScreenLog(screenLog, screenLogLimits);
653
+ ```
654
+
655
+ And in `child.onData`, replace:
656
+
657
+ ```js
658
+ screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes);
659
+ ```
660
+
661
+ with:
662
+
663
+ ```js
664
+ screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
665
+ ```
666
+
667
+ - [ ] **Step 4: Run test to verify it passes**
668
+
669
+ Run: `node --test test/pty-runner.integration.test.mjs`
670
+ Expected: PASS (new + existing; the existing test's config has no `screenLogMaxBytes`, covering backward compat)
671
+
672
+ - [ ] **Step 5: Commit**
673
+
674
+ ```bash
675
+ git add runner/pty-runner.mjs test/pty-runner.integration.test.mjs
676
+ git commit -m "feat: honor screenLogMaxSize pref in pty-runner log cap (issue #1)"
677
+ ```
678
+
679
+ ---
680
+
681
+ ### Task 5: Full verification + issue comment
682
+
683
+ **Files:** none (verification only)
684
+
685
+ - [ ] **Step 1: Typecheck**
686
+
687
+ Run: `npm run typecheck`
688
+ Expected: clean (new JSDoc typedefs must typecheck)
689
+
690
+ - [ ] **Step 2: Full test suite**
691
+
692
+ Run: `npm test`
693
+ Expected: all tests pass (existing 133+ plus ~12 new)
694
+
695
+ - [ ] **Step 3: Pack dry run**
696
+
697
+ Run: `npm run pack:dry`
698
+ Expected: `src/core/screen-log-gc.mjs` appears in the tarball file list (it's under `src/`, so it should be included automatically)
699
+
700
+ - [ ] **Step 4: Comment verification results on the issue**
701
+
702
+ ```bash
703
+ gh issue comment 1 --repo zhuxixi/pi-agent-board --body "Implementation done on branch issue-1-screenlog-gc: startup GC + screenLogRetentionDays/screenLogMaxSize prefs. Verify: typecheck+tests+pack clean."
704
+ ```