@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,1194 @@
1
+ /**
2
+ * AgentViewService — the imperative actions behind the dashboard: dispatch a new
3
+ * background session, reply/resume, stop, pin/rename/archive, and the same-repo write
4
+ * safety rule. Pure node + core modules; the Pi-coupled bits (attach, dialogs) live in
5
+ * the command handler. The pi invocation + runner path are injected (resolved in index.ts).
6
+ */
7
+ import { existsSync } from "node:fs";
8
+ import { createRequire } from "node:module";
9
+ import { createConnection } from "node:net";
10
+ import { resolve } from "node:path";
11
+ import { applyAutoStateToStatus, autoStateEnabled, heuristicAutoState } from "../core/auto-state.mjs";
12
+ import { appendLine } from "../core/atomic.mjs";
13
+ import { finalizeRun, projectViewState, reduceEvent } from "../core/events.mjs";
14
+ import { clearDiagnostics, appendDiagnostic, tailDiagnostics } from "../core/diagnostics.mjs";
15
+ import { emptyEvidenceSnapshot, finalizeEvidence, readEvidence, reduceEvidence, summarizeEvidence, writeEvidence } from "../core/evidence.mjs";
16
+ import { claimNextFollowUp, completeFollowUp, enqueueFollowUp, readFollowUpQueue, releaseFollowUp, summarizeFollowUpQueue, clearQueuedFollowUps, removeLastFollowUp } from "../core/follow-up-queue.mjs";
17
+ import { approvePlan as approvePlanState, markExecutingApprovedPlan, readSteering, recordPlanReady, requestPlan as requestPlanState, requestPlanChanges as requestPlanChangesState, summarizeSteering } from "../core/steering.mjs";
18
+ import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../core/steering-prompts.mjs";
19
+ import { isGenericStatusText } from "../core/derive.mjs";
20
+ import { firstSentence, truncate } from "../core/heuristics.mjs";
21
+ import { newRunId, newViewId, slugifyTask } from "../core/ids.mjs";
22
+ import { launchAutoState as launchAutoStateProcess, launchHost as launchHostProcess, launchRun, launchTitle as launchTitleProcess } from "../core/launch.mjs";
23
+ import { gitRepoRoot } from "../core/repo.mjs";
24
+ import { killProcess } from "../core/pid.mjs";
25
+ import * as P from "../core/paths.mjs";
26
+ import {
27
+ createView,
28
+ listRows,
29
+ loadRow,
30
+ readLaunchPrefs,
31
+ readPid,
32
+ readState,
33
+ readStatus,
34
+ writeHost,
35
+ writeHostPid,
36
+ writeLaunchPrefs,
37
+ writeMeta,
38
+ writeState,
39
+ } from "../core/store.mjs";
40
+ import { diagnoseNodePtyFailure, ensureNodePtySpawnHelperExecutable, nodePtyFallbackMessage, probeNodePtyEnvironment } from "../core/pty-support.mjs";
41
+ import { normalizeScreenLogMaxBytes, pruneScreenLogs } from "../core/screen-log-gc.mjs";
42
+
43
+ /** @typedef {import("../core/types.mjs").RunKind} RunKind */
44
+
45
+ /**
46
+ * @param {{
47
+ * root: string,
48
+ * runnerScript: string,
49
+ * ptyRunnerScript?: string,
50
+ * piCommand: string,
51
+ * piArgsPrefix: string[],
52
+ * defaultCwd: string,
53
+ * titleRunnerScript?: string,
54
+ * autoStateRunnerScript?: string,
55
+ * launch?: typeof launchRun,
56
+ * launchHost?: typeof launchHostProcess,
57
+ * launchTitle?: typeof launchTitleProcess,
58
+ * launchAutoState?: typeof launchAutoStateProcess,
59
+ * ptySupport?: (opts?: { refresh?: boolean, maxAgeMs?: number }) => { ok: boolean, reason?: string|null, issue?: any },
60
+ * pruneScreenLogs?: typeof pruneScreenLogs,
61
+ * }} opts
62
+ */
63
+ export function createService(opts) {
64
+ const root = opts.root;
65
+ const launch = opts.launch ?? launchRun;
66
+ const launchHostImpl = opts.launchHost ?? launchHostProcess;
67
+ const launchTitleImpl = opts.launchTitle ?? launchTitleProcess;
68
+ const launchAutoStateImpl = opts.launchAutoState ?? launchAutoStateProcess;
69
+ const ptySupport = opts.ptySupport ?? ptyHostAvailability;
70
+ const ptyRunnerScript = opts.ptyRunnerScript ?? opts.runnerScript;
71
+ const titleRunnerScript = opts.titleRunnerScript ?? null;
72
+
73
+ const pruneScreenLogsImpl = opts.pruneScreenLogs ?? pruneScreenLogs;
74
+ // Reclaim replay logs of long-ended views on dashboard startup. Deferred via
75
+ // setImmediate so the first frame is unaffected; any failure must not break
76
+ // the dashboard.
77
+ setImmediate(() => {
78
+ try {
79
+ const stats = pruneScreenLogsImpl(root, { retentionDays: readLaunchPrefs(root).screenLogRetentionDays });
80
+ // One JSONL record per pass that actually reclaimed something. `removed` is a
81
+ // one-time event per file, so records stay proportional to real usage.
82
+ // Persistent conditions (a permanent foreign dir, a recurring unlink failure)
83
+ // ride along as record fields but never trigger a record on their own —
84
+ // otherwise this file would itself grow without bound from routine opens.
85
+ if (stats && stats.removed > 0) {
86
+ appendLine(P.gcHistoryPath(root), JSON.stringify({ at: Date.now(), ...stats }));
87
+ }
88
+ } catch {}
89
+ }).unref?.();
90
+
91
+ /**
92
+ * Launch a run (dispatch or reply) against an existing view, updating its state to queued.
93
+ * @param {import("../core/types.mjs").ViewMeta} meta
94
+ * @param {string} prompt
95
+ * @param {RunKind} kind
96
+ * @returns {{ runId: string, pid: number|null }}
97
+ */
98
+ function launchForView(meta, prompt, kind) {
99
+ const runId = newRunId();
100
+ /** @type {import("../core/types.mjs").RunConfig} */
101
+ const config = {
102
+ root,
103
+ viewId: meta.id,
104
+ runId,
105
+ kind,
106
+ sessionFile: meta.sessionFile,
107
+ cwd: meta.cwd,
108
+ prompt,
109
+ piCommand: opts.piCommand,
110
+ piArgsPrefix: opts.piArgsPrefix,
111
+ model: meta.defaultModel ?? null,
112
+ thinkingLevel: meta.defaultThinking ?? null,
113
+ tools: null,
114
+ };
115
+ const { pid } = launch(root, config, { runnerScript: opts.runnerScript });
116
+ appendDiagnostic(root, meta.id, { source: "service", runId, code: "launch_run", message: "Detached runner launched", details: { kind, pid } });
117
+ markQueued(meta.id, runId);
118
+ return { runId, pid };
119
+ }
120
+
121
+ /**
122
+ * Launch a durable interactive PTY host for a view.
123
+ * @param {import("../core/types.mjs").ViewMeta} meta
124
+ * @param {string|null} initialPrompt
125
+ * @returns {{ pid: number|null }}
126
+ */
127
+ function launchHost(meta, initialPrompt, launchOpts = {}) {
128
+ /** @type {import("../core/types.mjs").HostConfig} */
129
+ const config = {
130
+ root,
131
+ viewId: meta.id,
132
+ sessionFile: meta.sessionFile,
133
+ cwd: meta.cwd,
134
+ initialPrompt,
135
+ piCommand: opts.piCommand,
136
+ piArgsPrefix: opts.piArgsPrefix,
137
+ model: meta.defaultModel ?? null,
138
+ thinkingLevel: meta.defaultThinking ?? null,
139
+ tools: null,
140
+ env: {},
141
+ cols: Number(process.env.COLUMNS || 120),
142
+ rows: Number(process.env.LINES || 36),
143
+ screenLogMaxBytes: normalizeScreenLogMaxBytes(readLaunchPrefs(root).screenLogMaxSize),
144
+ };
145
+ const socketPath = P.controlSocketPath(root, meta.id);
146
+ const { pid } = launchHostImpl(root, config, { runnerScript: ptyRunnerScript });
147
+ writeHost(root, {
148
+ version: 1,
149
+ viewId: meta.id,
150
+ mode: "pty",
151
+ runnerPid: pid,
152
+ childPid: null,
153
+ socketPath,
154
+ state: "starting",
155
+ startedAt: Date.now(),
156
+ lastSeenAt: Date.now(),
157
+ endedAt: null,
158
+ exitCode: null,
159
+ error: null,
160
+ cols: config.cols,
161
+ rows: config.rows,
162
+ attachedClients: 0,
163
+ });
164
+ writeHostPid(root, meta.id, pid);
165
+ appendDiagnostic(root, meta.id, { source: "service", code: "launch_host", message: "PTY host launched", details: { pid, hasInitialPrompt: Boolean(initialPrompt) } });
166
+ if (launchOpts.markQueued !== false) markQueued(meta.id, null);
167
+ return { pid, socketPath };
168
+ }
169
+
170
+ /**
171
+ * Best-effort detached title generation. If this fails or times out, the fallback slug
172
+ * remains as the row name.
173
+ * @param {import("../core/types.mjs").ViewMeta} meta
174
+ * @param {string} prompt
175
+ */
176
+ function queueGeneratedTitle(meta, prompt) {
177
+ if (!titleRunnerScript) return;
178
+ /** @type {import("../core/types.mjs").TitleConfig} */
179
+ const config = {
180
+ root,
181
+ viewId: meta.id,
182
+ cwd: meta.cwd,
183
+ prompt,
184
+ fallbackName: meta.name,
185
+ piCommand: opts.piCommand,
186
+ piArgsPrefix: opts.piArgsPrefix,
187
+ model: null,
188
+ };
189
+ try {
190
+ launchTitleImpl(root, config, { runnerScript: titleRunnerScript });
191
+ } catch {
192
+ /* best effort */
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Apply immediate heuristic auto-state and, when configured, queue a detached
198
+ * model pass to refine the row without blocking the live Pi child.
199
+ * @param {import("../core/types.mjs").ViewMeta} meta
200
+ * @param {import("../core/types.mjs").RunStatus} status
201
+ * @param {import("../core/types.mjs").EvidenceSnapshot} evidence
202
+ * @returns {boolean}
203
+ */
204
+ function queueAutoState(meta, status, evidence) {
205
+ if (!autoStateEnabled()) return false;
206
+ if (status.processState === "alive" || status.semanticState === "failed" || status.semanticState === "stopped") return false;
207
+ const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
208
+ if (!latest.trim()) return false;
209
+ const changed = applyAutoStateToStatus(status, heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null }), Date.now());
210
+ if (opts.autoStateRunnerScript) {
211
+ try {
212
+ launchAutoStateImpl(root, {
213
+ root,
214
+ viewId: meta.id,
215
+ runId: status.runId === "foreground" ? null : status.runId,
216
+ cwd: meta.cwd,
217
+ piCommand: opts.piCommand,
218
+ piArgsPrefix: opts.piArgsPrefix,
219
+ }, { runnerScript: opts.autoStateRunnerScript });
220
+ } catch (err) {
221
+ appendDiagnostic(root, meta.id, { source: "service", level: "warn", code: "auto_state_launch_failed", message: "Auto-state classifier could not be launched", details: { error: err instanceof Error ? err.message : String(err) } });
222
+ }
223
+ }
224
+ return changed;
225
+ }
226
+
227
+ /** @param {string} viewId @param {string|null} runId */
228
+ function markQueued(viewId, runId) {
229
+ const state = readState(root, viewId) ?? blankState(viewId);
230
+ state.currentRunId = runId;
231
+ state.semanticState = "queued";
232
+ state.processState = "alive";
233
+ state.summary = "Queued";
234
+ state.needsInput = false;
235
+ state.hasError = false;
236
+ state.question = null;
237
+ state.pendingQuestions = [];
238
+ state.error = null;
239
+ state.autoState = null;
240
+ state.lastActivityAt = Date.now();
241
+ state.updatedAt = Date.now();
242
+ writeState(root, state);
243
+ }
244
+
245
+ /** @param {string} viewId @returns {{ ok: boolean, error?: string }} */
246
+ function markVisited(viewId) {
247
+ const row = loadRow(root, viewId);
248
+ if (!row) return { ok: false, error: "Unknown session" };
249
+ const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
250
+ state.lastVisitedAt = Date.now();
251
+ state.updatedAt = Date.now();
252
+ writeState(root, state);
253
+ return { ok: true };
254
+ }
255
+
256
+ /** @param {string} viewId @returns {{ ok: boolean, error?: string }} */
257
+ function completeView(viewId) {
258
+ const row = loadRow(root, viewId);
259
+ if (!row) return { ok: false, error: "Unknown session" };
260
+ if (isAgentBusy(row)) return { ok: false, error: "Wait for the active run to finish before marking done" };
261
+ const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
262
+ state.semanticState = "completed";
263
+ state.processState = "exited";
264
+ state.needsInput = false;
265
+ state.hasError = false;
266
+ state.question = null;
267
+ state.pendingQuestions = [];
268
+ state.error = null;
269
+ state.autoState = null;
270
+ state.summary = completionSummary(state);
271
+ state.lastActivityAt = Date.now();
272
+ state.updatedAt = Date.now();
273
+ writeState(root, state);
274
+ return { ok: true };
275
+ }
276
+
277
+ /**
278
+ * @param {string} viewId
279
+ * @returns {{ ok: boolean, error?: string }}
280
+ */
281
+ function archiveView(viewId) {
282
+ const row = loadRow(root, viewId);
283
+ if (!row) return { ok: false, error: "Unknown session" };
284
+ if (row.hostAlive) sendHostMessage(row, { type: "terminate" });
285
+ if (row.alive && row.state?.currentRunId) {
286
+ const pid = readPid(root, viewId, row.state.currentRunId);
287
+ if (pid) killProcess(pid);
288
+ }
289
+ if (isAgentBusy(row)) {
290
+ const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
291
+ state.semanticState = "stopped";
292
+ state.processState = "exited";
293
+ state.needsInput = false;
294
+ state.hasError = false;
295
+ state.question = null;
296
+ state.pendingQuestions = [];
297
+ state.error = null;
298
+ state.autoState = null;
299
+ state.summary = "Stopped";
300
+ state.lastActivityAt = Date.now();
301
+ state.updatedAt = Date.now();
302
+ writeState(root, state);
303
+ }
304
+ row.meta.archived = true;
305
+ writeMeta(root, row.meta);
306
+ return { ok: true };
307
+ }
308
+
309
+ /** @param {string} viewId @returns {import("../core/types.mjs").ViewState} */
310
+ function blankState(viewId) {
311
+ return {
312
+ version: 1,
313
+ viewId,
314
+ currentRunId: null,
315
+ semanticState: "queued",
316
+ processState: "exited",
317
+ summary: "Queued",
318
+ lastActivityAt: Date.now(),
319
+ updatedAt: Date.now(),
320
+ needsInput: false,
321
+ hasError: false,
322
+ latestAssistantPreview: "",
323
+ latestTool: null,
324
+ question: null,
325
+ pendingQuestions: [],
326
+ error: null,
327
+ lastVisitedAt: null,
328
+ lastAgentActivityAt: null,
329
+ autoState: null,
330
+ };
331
+ }
332
+
333
+ /** @param {string} a @param {string} b */
334
+ function samePath(a, b) {
335
+ return resolve(a) === resolve(b);
336
+ }
337
+
338
+ /**
339
+ * @param {import("../core/store.mjs").Row} row
340
+ * @returns {import("../core/types.mjs").RunStatus}
341
+ */
342
+ function statusFromRow(row) {
343
+ const now = Date.now();
344
+ const s = row.state ?? blankState(row.meta.id);
345
+ return {
346
+ version: 1,
347
+ runId: s.currentRunId ?? "foreground",
348
+ viewId: row.meta.id,
349
+ pid: null,
350
+ startedAt: s.lastActivityAt ?? now,
351
+ endedAt: null,
352
+ exitCode: null,
353
+ kind: "reply",
354
+ prompt: "",
355
+ model: row.meta.defaultModel ?? null,
356
+ semanticState: s.semanticState,
357
+ processState: s.processState,
358
+ summary: s.summary,
359
+ lastActivityAt: s.lastActivityAt,
360
+ currentTool: s.latestTool ? { name: s.latestTool.name, path: s.latestTool.path, summary: s.summary } : null,
361
+ latestAssistantPreview: s.latestAssistantPreview,
362
+ question: s.question,
363
+ pendingQuestions: Array.isArray(s.pendingQuestions) ? s.pendingQuestions : [],
364
+ error: s.error,
365
+ lastAgentActivityAt: s.lastAgentActivityAt ?? null,
366
+ stopReason: null,
367
+ stoppedByUser: false,
368
+ turns: 0,
369
+ toolCount: 0,
370
+ autoState: s.autoState ?? null,
371
+ };
372
+ }
373
+
374
+ /**
375
+ * @param {import("../core/store.mjs").Row} row
376
+ * @param {import("../core/types.mjs").RunStatus} status
377
+ */
378
+ function writeForegroundState(row, status) {
379
+ const projected = projectViewState(status, Date.now(), readState(root, row.meta.id) ?? row.state ?? null);
380
+ // Foreground turns are driven by the interactive Pi process, not a detached
381
+ // runner, so keep currentRunId null. This prevents reconcile()/stop() from
382
+ // treating a foreground turn as a managed background runner pid.
383
+ projected.currentRunId = null;
384
+ writeState(root, projected);
385
+ }
386
+
387
+ /** @param {string} sessionFile */
388
+ function rowForSession(sessionFile) {
389
+ return listRows(root, { includeArchived: true }).find((r) => samePath(r.meta.sessionFile, sessionFile)) ?? null;
390
+ }
391
+
392
+ /**
393
+ * Keep a small warm pool of idle PTY hosts for fast session switching.
394
+ * Busy hosts and attached hosts are never evicted.
395
+ * @param {{ keepViewId?: string|null }} [pruneOpts]
396
+ */
397
+ function pruneWarmHosts(pruneOpts = {}) {
398
+ const maxWarm = envInt("AGENT_BOARD_MAX_WARM_HOSTS", 4, 0, 50, "AGENT_VIEW_MAX_WARM_HOSTS");
399
+ const ttlMs = envInt("AGENT_BOARD_WARM_HOST_TTL_MS", 10 * 60 * 1000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_WARM_HOST_TTL_MS");
400
+ if (maxWarm === 0 && ttlMs === 0) return;
401
+ const now = Date.now();
402
+ const idleHosts = listRows(root)
403
+ .filter((r) => r.meta.id !== pruneOpts.keepViewId)
404
+ .filter((r) => r.hostAlive && !isAgentBusy(r) && (r.host?.attachedClients ?? 0) === 0);
405
+
406
+ for (const row of idleHosts) {
407
+ const idleSince = row.state?.lastActivityAt ?? row.host?.startedAt ?? row.meta.updatedAt;
408
+ if (ttlMs > 0 && now - idleSince > ttlMs) sendHostMessage(row, { type: "terminate" });
409
+ }
410
+
411
+ const survivors = idleHosts
412
+ .filter((r) => {
413
+ const idleSince = r.state?.lastActivityAt ?? r.host?.startedAt ?? r.meta.updatedAt;
414
+ return !(ttlMs > 0 && now - idleSince > ttlMs);
415
+ })
416
+ .sort((a, b) => (a.state?.lastActivityAt ?? a.host?.startedAt ?? 0) - (b.state?.lastActivityAt ?? b.host?.startedAt ?? 0));
417
+ const excess = Math.max(0, survivors.length - maxWarm);
418
+ for (const row of survivors.slice(0, excess)) sendHostMessage(row, { type: "terminate" });
419
+ }
420
+
421
+ /** @param {import("../core/types.mjs").FollowUpKind|import("../core/types.mjs").RunKind|string} kind */
422
+ function runKindForKind(kind) {
423
+ switch (kind) {
424
+ case "plan_request":
425
+ return "plan";
426
+ case "plan_change":
427
+ return "plan_change";
428
+ case "plan_approval":
429
+ return "plan_approval";
430
+ case "plan":
431
+ case "reply":
432
+ case "dispatch":
433
+ return kind;
434
+ default:
435
+ return "reply";
436
+ }
437
+ }
438
+
439
+ /** @param {import("../core/types.mjs").FollowUpItem} item */
440
+ function runKindForFollowUp(item) {
441
+ return runKindForKind(item.kind);
442
+ }
443
+
444
+ /** @param {string} viewId @param {import("../core/types.mjs").FollowUpItem} item */
445
+ function promptForFollowUp(viewId, item) {
446
+ const steering = readSteering(root, viewId);
447
+ switch (item.kind) {
448
+ case "plan_request":
449
+ return buildPlanRequestPrompt(item.text);
450
+ case "plan_approval":
451
+ return buildApprovePlanPrompt(steering.planText);
452
+ case "plan_change":
453
+ return buildPlanChangesPrompt(steering.planText, item.text);
454
+ default:
455
+ return item.text;
456
+ }
457
+ }
458
+
459
+ /** @param {string} viewId */
460
+ function drainNextFollowUp(viewId) {
461
+ const row = loadRow(root, viewId);
462
+ if (!row) return { ok: false, error: "Unknown session" };
463
+ if (!canAutoDrain(row)) return { ok: false, error: "Session is not ready to drain queued follow-ups" };
464
+ const claimed = claimNextFollowUp(root, viewId);
465
+ if (!claimed.ok || !claimed.item) return claimed;
466
+ const item = claimed.item;
467
+ const prompt = promptForFollowUp(viewId, item);
468
+ try {
469
+ if (item.kind === "plan_approval") markExecutingApprovedPlan(root, viewId);
470
+ if (row.hostAlive) {
471
+ const sent = sendHostMessage(row, { type: "input", data: `${prompt}\r` });
472
+ if (!sent.ok) {
473
+ releaseFollowUp(root, viewId, item.id);
474
+ return sent;
475
+ }
476
+ completeFollowUp(root, viewId, item.id);
477
+ appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind: item.kind } });
478
+ return { ok: true, sent: true, item };
479
+ }
480
+ const pty = ptySupport({ refresh: true });
481
+ let runId = null;
482
+ if (pty.ok) launchHost(row.meta, prompt);
483
+ else {
484
+ if (isExternalSession(row.meta)) {
485
+ releaseFollowUp(root, viewId, item.id);
486
+ appendDiagnostic(root, viewId, { source: "queue", level: "warn", code: "follow_up_waiting_for_pty", message: "Adopted session follow-up is waiting for PTY support", details: {} });
487
+ return { ok: false, error: "PTY is required to drain adopted session follow-ups safely" };
488
+ }
489
+ runId = launchForView(row.meta, prompt, runKindForFollowUp(item)).runId;
490
+ }
491
+ completeFollowUp(root, viewId, item.id, { runId });
492
+ appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_started", message: "Queued follow-up started", details: { kind: item.kind, hostMode: pty.ok ? "pty" : "json-runner" } });
493
+ return { ok: true, started: true, item };
494
+ } catch (err) {
495
+ releaseFollowUp(root, viewId, item.id);
496
+ appendDiagnostic(root, viewId, { source: "queue", level: "error", code: "follow_up_drain_failed", message: "Queued follow-up failed to start", details: { error: err instanceof Error ? err.message : String(err) } });
497
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
498
+ }
499
+ }
500
+
501
+ /** @param {import("../core/store.mjs").Row} row @param {any} event */
502
+ function syncRowEvent(row, event) {
503
+ const now = Date.now();
504
+ const status = statusFromRow(row);
505
+ let evidence = readEvidence(root, row.meta.id);
506
+ if (!evidence.viewId) evidence = emptyEvidenceSnapshot({ viewId: row.meta.id, source: "hosted" });
507
+ try {
508
+ reduceEvidence(evidence, event, now);
509
+ writeEvidence(root, evidence);
510
+ } catch (err) {
511
+ appendDiagnostic(root, row.meta.id, { source: "evidence", level: "warn", code: "evidence_reduce_failed", message: "Could not reduce hosted evidence", details: { error: err instanceof Error ? err.message : String(err) } });
512
+ }
513
+
514
+ if (event.type === "input" || event.type === "before_agent_start" || event.type === "agent_start") {
515
+ status.semanticState = "working";
516
+ status.processState = "alive";
517
+ status.currentTool = null;
518
+ status.question = null;
519
+ status.pendingQuestions = [];
520
+ status.error = null;
521
+ status.summary = "Running…";
522
+ status.lastActivityAt = now;
523
+ writeForegroundState(row, status);
524
+ return true;
525
+ }
526
+
527
+ if (event.type === "agent_end") {
528
+ finalizeRun(status, { exitCode: 0 }, now);
529
+ finalizeEvidence(evidence, status, now);
530
+ const steering = readSteering(root, row.meta.id);
531
+ if (steering.status === "plan_requested" || steering.status === "changes_requested") {
532
+ recordPlanReady(root, row.meta.id, { planText: latestEvidenceText(evidence) || status.latestAssistantPreview || evidence.summary || "Plan ready", runId: status.runId });
533
+ status.semanticState = "needs_input";
534
+ status.question = "Approve this plan?";
535
+ status.summary = "Plan ready for approval";
536
+ } else if (queueAutoState(row.meta, status, evidence)) {
537
+ finalizeEvidence(evidence, status, now);
538
+ }
539
+ status.evidenceSummary = summarizeEvidence(evidence);
540
+ writeEvidence(root, evidence);
541
+ writeForegroundState(row, status);
542
+ pruneWarmHosts({ keepViewId: row.meta.id });
543
+ drainNextFollowUp(row.meta.id);
544
+ return true;
545
+ }
546
+
547
+ if (reduceEvent(status, event, now, { interactive: true })) {
548
+ status.processState = "alive";
549
+ writeForegroundState(row, status);
550
+ return true;
551
+ }
552
+ return false;
553
+ }
554
+
555
+ return {
556
+ root,
557
+ /**
558
+ * Create a new background session and launch its first run.
559
+ * Worktree mode is currently disabled, but the dashboard no longer blocks
560
+ * concurrent same-repo sessions on its own.
561
+ * @param {string} text
562
+ * @param {{
563
+ * cwd?: string,
564
+ * worktree?: boolean,
565
+ * writeCapable?: boolean,
566
+ * model?: string|null,
567
+ * thinkingLevel?: "off"|"minimal"|"low"|"medium"|"high"|"xhigh"|"max"|null,
568
+ * }} [dispatchOpts]
569
+ * @returns {{ ok: boolean, viewId?: string, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }}
570
+ */
571
+ dispatch(text, dispatchOpts = {}) {
572
+ const prompt = String(text || "").trim();
573
+ if (!prompt) return { ok: false, error: "Empty task" };
574
+
575
+ const cwd = dispatchOpts.cwd ?? opts.defaultCwd;
576
+ const writeCapable = dispatchOpts.writeCapable ?? true;
577
+ const defaultModel = dispatchOpts.model ?? null;
578
+ const defaultThinking = dispatchOpts.thinkingLevel ?? null;
579
+ const repoRoot = gitRepoRoot(cwd);
580
+
581
+ if (dispatchOpts.worktree) {
582
+ return { ok: false, error: "Worktree mode is currently disabled." };
583
+ }
584
+
585
+ const id = newViewId();
586
+ const meta = createView(root, {
587
+ id,
588
+ name: slugifyTask(prompt),
589
+ cwd,
590
+ repoCwd: cwd,
591
+ repoRoot,
592
+ worktreeMode: "off",
593
+ worktreePath: null,
594
+ defaultModel,
595
+ defaultThinking,
596
+ writeCapable,
597
+ });
598
+ const pty = ptySupport({ refresh: true });
599
+ if (pty.ok) launchHost(meta, prompt);
600
+ else launchForView(meta, prompt, "dispatch");
601
+ queueGeneratedTitle(meta, prompt);
602
+ return {
603
+ ok: true,
604
+ viewId: id,
605
+ hostMode: pty.ok ? "pty" : "json-runner",
606
+ fallbackReason: pty.ok ? undefined : nodePtyFallbackMessage(pty),
607
+ };
608
+ },
609
+
610
+ /**
611
+ * Append a reply to an existing session by launching a new run. Blocks if a run is live.
612
+ * @param {string} viewId
613
+ * @param {string} text
614
+ * @returns {{ ok: boolean, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }}
615
+ */
616
+ reply(viewId, text, replyOpts = {}) {
617
+ const prompt = String(text || "").trim();
618
+ if (!prompt) return { ok: false, error: "Empty reply" };
619
+ const row = loadRow(root, viewId);
620
+ if (!row) return { ok: false, error: "Unknown session" };
621
+ if (hasPendingQuestions(row)) return { ok: false, error: "Attach to answer the pending question" };
622
+ const delivery = replyOpts.delivery ?? "auto";
623
+ const kind = replyOpts.kind ?? "reply";
624
+ if (delivery === "queue" || (delivery === "auto" && isAgentBusy(row))) {
625
+ const queued = enqueueFollowUp(root, viewId, prompt, { kind, delivery, source: "user" });
626
+ if (queued.ok) appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_queued", message: "Follow-up queued", details: { kind, queuedCount: queued.summary?.queuedCount } });
627
+ return queued.ok ? { ok: true, queued: true, summary: queued.summary } : queued;
628
+ }
629
+ if (row.hostAlive) return sendHostMessage(row, { type: "input", data: `${prompt}\r` });
630
+ if (row.alive) return { ok: false, error: "A run is already active for this session" };
631
+ const pty = ptySupport({ refresh: true });
632
+ if (pty.ok) launchHost(row.meta, prompt);
633
+ else {
634
+ if (isExternalSession(row.meta)) return { ok: false, error: "PTY is required to continue an adopted foreground session safely" };
635
+ launchForView(row.meta, prompt, runKindForKind(kind));
636
+ }
637
+ return { ok: true, hostMode: pty.ok ? "pty" : "json-runner", fallbackReason: pty.ok ? undefined : nodePtyFallbackMessage(pty) };
638
+ },
639
+
640
+ /**
641
+ * Stop the active run for a view (SIGTERM the runner → it finalizes as `stopped`).
642
+ * @param {string} viewId
643
+ * @returns {{ ok: boolean, error?: string }}
644
+ */
645
+ stop(viewId) {
646
+ const row = loadRow(root, viewId);
647
+ if (row?.hostAlive) return sendHostMessage(row, { type: "interrupt" });
648
+ const state = readState(root, viewId);
649
+ if (!state?.currentRunId) return { ok: false, error: "No active run" };
650
+ const pid = readPid(root, viewId, state.currentRunId);
651
+ if (!pid) return { ok: false, error: "No runner pid" };
652
+ killProcess(pid);
653
+ return { ok: true };
654
+ },
655
+
656
+ /** @param {string} viewId */
657
+ terminateHost(viewId) {
658
+ const row = loadRow(root, viewId);
659
+ if (!row?.hostAlive) return { ok: false, error: "No live host" };
660
+ return sendHostMessage(row, { type: "terminate" });
661
+ },
662
+
663
+ /**
664
+ * Ensure there is an interactive PTY host for this session. Used for fast attach
665
+ * and dashboard prewarm. Starting an idle host must not alter task state.
666
+ * @param {string} viewId
667
+ * @returns {{ ok: boolean, socketPath?: string, started?: boolean, error?: string, fallbackReason?: string }}
668
+ */
669
+ ensureHost(viewId) {
670
+ const row = loadRow(root, viewId);
671
+ if (!row) return { ok: false, error: "Unknown session" };
672
+ if (row.hostAlive && row.host?.socketPath) return { ok: true, socketPath: row.host.socketPath, started: false };
673
+
674
+ // Default probe semantics: success is cached for the process lifetime and a
675
+ // failed probe retries on a short TTL. Forcing refresh here would spawn a
676
+ // probe process on every keypress-driven prewarm when PTY support is broken.
677
+ const pty = ptySupport();
678
+ if (!pty.ok) return { ok: false, error: "PTY unavailable", fallbackReason: nodePtyFallbackMessage(pty) };
679
+ if (isAgentBusy(row)) return { ok: false, error: "A non-live background run is active for this session" };
680
+ if (!existsSync(row.meta.sessionFile)) return { ok: false, error: "Session file isn't ready yet" };
681
+
682
+ const launched = launchHost(row.meta, null, { markQueued: false });
683
+ pruneWarmHosts({ keepViewId: viewId });
684
+ return { ok: true, socketPath: launched.socketPath, started: true };
685
+ },
686
+
687
+ /** @param {string} viewId */
688
+ prewarmHost(viewId) {
689
+ const row = loadRow(root, viewId);
690
+ if (!row || isAgentBusy(row)) return { ok: false, error: row ? "Session is busy" : "Unknown session" };
691
+ return this.ensureHost(viewId);
692
+ },
693
+
694
+ /** @param {string} viewId */
695
+ attachTarget(viewId) {
696
+ const row = loadRow(root, viewId);
697
+ if (!row) return { kind: "missing" };
698
+ if (row.hostAlive && row.host?.socketPath) {
699
+ return { kind: "pty", socketPath: row.host.socketPath, sessionFile: row.meta.sessionFile };
700
+ }
701
+ return { kind: "session", sessionFile: row.meta.sessionFile };
702
+ },
703
+
704
+ adoptSession(adoptOpts = {}) {
705
+ const sessionFile = String(adoptOpts.sessionFile || "").trim();
706
+ if (!sessionFile) return { ok: false, error: "No session file to adopt" };
707
+ const existing = rowForSession(sessionFile);
708
+ if (existing) {
709
+ existing.meta.archived = false;
710
+ if (adoptOpts.name) existing.meta.name = String(adoptOpts.name).trim() || existing.meta.name;
711
+ writeMeta(root, existing.meta);
712
+ if (!isAgentBusy(existing)) {
713
+ const state = readState(root, existing.meta.id) ?? existing.state ?? blankState(existing.meta.id);
714
+ state.semanticState = "idle";
715
+ state.processState = "exited";
716
+ state.needsInput = false;
717
+ state.hasError = false;
718
+ state.question = null;
719
+ state.pendingQuestions = [];
720
+ state.error = null;
721
+ state.summary = "Backgrounded session";
722
+ state.updatedAt = Date.now();
723
+ state.lastActivityAt = Date.now();
724
+ writeState(root, state);
725
+ }
726
+ appendDiagnostic(root, existing.meta.id, { source: "service", code: "session_adopted", message: "Existing session adopted into Agent Board", details: { reused: true } });
727
+ return { ok: true, viewId: existing.meta.id, reused: true };
728
+ }
729
+ const cwd = adoptOpts.cwd ?? opts.defaultCwd;
730
+ const id = newViewId();
731
+ const repoRoot = gitRepoRoot(cwd);
732
+ const meta = createView(root, {
733
+ id,
734
+ name: adoptOpts.name ?? "background-session",
735
+ cwd,
736
+ repoCwd: cwd,
737
+ repoRoot,
738
+ worktreeMode: "off",
739
+ worktreePath: null,
740
+ defaultModel: adoptOpts.model ?? null,
741
+ defaultThinking: adoptOpts.thinkingLevel ?? null,
742
+ writeCapable: true,
743
+ sessionFile,
744
+ });
745
+ const state = readState(root, id) ?? blankState(id);
746
+ state.semanticState = "idle";
747
+ state.processState = "exited";
748
+ state.summary = "Backgrounded session";
749
+ state.updatedAt = Date.now();
750
+ state.lastActivityAt = Date.now();
751
+ writeState(root, state);
752
+ appendDiagnostic(root, id, { source: "service", code: "session_adopted", message: "Current session adopted into Agent Board", details: { reused: false } });
753
+ return { ok: true, viewId: meta.id, reused: false };
754
+ },
755
+
756
+ getLaunchPrefs() {
757
+ return readLaunchPrefs(root);
758
+ },
759
+
760
+ saveLaunchPrefs(prefs) {
761
+ writeLaunchPrefs(root, prefs ?? {});
762
+ return { ok: true };
763
+ },
764
+
765
+ /** @param {string} viewId @param {boolean} pinned */
766
+ setPinned(viewId, pinned) {
767
+ const meta = loadRow(root, viewId)?.meta;
768
+ if (!meta) return { ok: false, error: "Unknown session" };
769
+ meta.pinned = pinned;
770
+ writeMeta(root, meta);
771
+ return { ok: true };
772
+ },
773
+
774
+ /** @param {string} viewId @param {string} name */
775
+ rename(viewId, name) {
776
+ const clean = String(name || "").trim();
777
+ if (!clean) return { ok: false, error: "Empty name" };
778
+ const meta = loadRow(root, viewId)?.meta;
779
+ if (!meta) return { ok: false, error: "Unknown session" };
780
+ meta.name = clean;
781
+ writeMeta(root, meta);
782
+ return { ok: true };
783
+ },
784
+
785
+ /** @param {string} viewId @returns {{ ok: boolean, error?: string }} */
786
+ markVisited(viewId) {
787
+ return markVisited(viewId);
788
+ },
789
+
790
+ /**
791
+ * Explicitly mark an inactive session as done. Successful runs settle as
792
+ * `idle` until the user reviews and confirms this action from the dashboard.
793
+ * @param {string} viewId
794
+ * @returns {{ ok: boolean, error?: string }}
795
+ */
796
+ markCompleted(viewId) {
797
+ return completeView(viewId);
798
+ },
799
+
800
+ /**
801
+ * Bulk mark sessions done, skipping live/already-done rows.
802
+ * @param {string[]} viewIds
803
+ * @returns {{ ok: boolean, completed: number, skipped: number, completedIds: string[] }}
804
+ */
805
+ queueFollowUp(viewId, text, queueOpts = {}) {
806
+ const res = enqueueFollowUp(root, viewId, text, queueOpts);
807
+ if (res.ok) appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_queued", message: "Follow-up queued", details: { kind: queueOpts.kind ?? "reply" } });
808
+ return res;
809
+ },
810
+
811
+ clearFollowUps(viewId) {
812
+ const res = clearQueuedFollowUps(root, viewId);
813
+ if (res.ok) appendDiagnostic(root, viewId, { source: "queue", code: "follow_ups_cleared", message: "Queued follow-ups cleared", details: { cancelled: res.cancelled } });
814
+ return res;
815
+ },
816
+
817
+ removeLastFollowUp(viewId) {
818
+ const res = removeLastFollowUp(root, viewId);
819
+ if (res.ok) appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_removed", message: "Last queued follow-up removed", details: { itemId: res.item?.id } });
820
+ return res;
821
+ },
822
+
823
+ followUps(viewId) {
824
+ const queue = readFollowUpQueue(root, viewId);
825
+ return { queue, summary: summarizeFollowUpQueue(queue) };
826
+ },
827
+
828
+ drainNextFollowUp(viewId) {
829
+ return drainNextFollowUp(viewId);
830
+ },
831
+
832
+ requestPlan(viewId, text = "") {
833
+ const row = loadRow(root, viewId);
834
+ if (!row) return { ok: false, error: "Unknown session" };
835
+ requestPlanState(root, viewId, { note: text || null });
836
+ if (isAgentBusy(row)) return this.queueFollowUp(viewId, text, { kind: "plan_request", delivery: "queue", source: "steering" });
837
+ return this.reply(viewId, buildPlanRequestPrompt(text), { delivery: "now", kind: "plan_request" });
838
+ },
839
+
840
+ approvePlan(viewId) {
841
+ const row = loadRow(root, viewId);
842
+ const state = readSteering(root, viewId);
843
+ const approved = approvePlanState(root, viewId);
844
+ if (!approved.ok) return approved;
845
+ if (row && isAgentBusy(row)) return this.queueFollowUp(viewId, "approved", { kind: "plan_approval", delivery: "queue", source: "steering" });
846
+ markExecutingApprovedPlan(root, viewId);
847
+ return this.reply(viewId, buildApprovePlanPrompt(state.planText), { delivery: "now", kind: "plan_approval" });
848
+ },
849
+
850
+ requestPlanChanges(viewId, feedback) {
851
+ const row = loadRow(root, viewId);
852
+ const state = readSteering(root, viewId);
853
+ const changed = requestPlanChangesState(root, viewId, feedback);
854
+ if (!changed.ok) return changed;
855
+ if (row && isAgentBusy(row)) return this.queueFollowUp(viewId, feedback, { kind: "plan_change", delivery: "queue", source: "steering" });
856
+ return this.reply(viewId, buildPlanChangesPrompt(state.planText, feedback), { delivery: "now", kind: "plan_change" });
857
+ },
858
+
859
+ steering(viewId) {
860
+ const state = readSteering(root, viewId);
861
+ return { state, summary: summarizeSteering(state) };
862
+ },
863
+
864
+ markCompletedMany(viewIds) {
865
+ const ids = [...new Set((viewIds ?? []).filter(Boolean))];
866
+ let completed = 0;
867
+ let skipped = 0;
868
+ const completedIds = [];
869
+ for (const viewId of ids) {
870
+ const row = loadRow(root, viewId);
871
+ if (!row || row.state?.semanticState === "completed") {
872
+ skipped += 1;
873
+ continue;
874
+ }
875
+ const res = completeView(viewId);
876
+ if (res.ok) {
877
+ completed += 1;
878
+ completedIds.push(viewId);
879
+ } else skipped += 1;
880
+ }
881
+ return { ok: true, completed, skipped, completedIds };
882
+ },
883
+
884
+ /**
885
+ * Soft-delete a row: archive it (removed from the dashboard) but preserve the session
886
+ * file.
887
+ * @param {string} viewId
888
+ */
889
+ archive(viewId) {
890
+ return archiveView(viewId);
891
+ },
892
+
893
+ /**
894
+ * Bulk archive explicit row ids, skipping live/missing rows.
895
+ * @param {string[]} viewIds
896
+ * @returns {{ ok: boolean, archived: number, skipped: number }}
897
+ */
898
+ archiveMany(viewIds) {
899
+ const ids = [...new Set((viewIds ?? []).filter(Boolean))];
900
+ let archived = 0;
901
+ let skipped = 0;
902
+ for (const viewId of ids) {
903
+ const row = loadRow(root, viewId);
904
+ if (!row || isAgentBusy(row)) {
905
+ skipped += 1;
906
+ continue;
907
+ }
908
+ const res = archiveView(viewId);
909
+ if (res.ok) archived += 1;
910
+ else skipped += 1;
911
+ }
912
+ return { ok: true, archived, skipped };
913
+ },
914
+
915
+ /**
916
+ * Archive every non-live visible row in a semantic state. Live rows are skipped
917
+ * so bulk cleanup cannot accidentally kill work.
918
+ * @param {import("../core/types.mjs").SemanticState} state
919
+ * @returns {{ ok: boolean, archived: number, skipped: number, error?: string }}
920
+ */
921
+ archiveByState(state) {
922
+ let archived = 0;
923
+ let skipped = 0;
924
+ for (const row of listRows(root)) {
925
+ if (row.state?.semanticState !== state) continue;
926
+ if (isAgentBusy(row)) {
927
+ skipped += 1;
928
+ continue;
929
+ }
930
+ if (row.hostAlive) sendHostMessage(row, { type: "terminate" });
931
+ row.meta.archived = true;
932
+ writeMeta(root, row.meta);
933
+ archived += 1;
934
+ }
935
+ return { ok: true, archived, skipped };
936
+ },
937
+
938
+ /**
939
+ * Recovery: reconcile rows whose runner died without finalizing (e.g. machine crash
940
+ * or the runner was killed). If a terminal status exists, project it; otherwise mark
941
+ * the row failed/stale. Safe to call on every dashboard open and on session_start.
942
+ * @returns {number} number of rows reconciled.
943
+ */
944
+ reconcile() {
945
+ const now = Date.now();
946
+ let fixed = 0;
947
+ for (const row of listRows(root)) {
948
+ const s = row.state;
949
+ const looksActive = s?.processState === "alive" || s?.semanticState === "queued" || s?.semanticState === "working";
950
+ if (!s || !looksActive) continue;
951
+ if (!s.currentRunId) {
952
+ if (row.host && !row.hostAlive && (row.host.state === "starting" || row.host.state === "alive" || row.host.state === "exited" || row.host.state === "failed")) {
953
+ const failed = row.host.state === "starting" || row.host.state === "alive" || row.host.state === "failed" || Boolean(row.host.error) || (row.host.exitCode !== null && row.host.exitCode !== 0);
954
+ s.semanticState = failed ? "failed" : "idle";
955
+ s.processState = "exited";
956
+ s.hasError = failed;
957
+ s.needsInput = false;
958
+ s.question = null;
959
+ s.pendingQuestions = [];
960
+ s.error = failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null;
961
+ s.summary = failed ? "Failed (PTY host exited)" : "In Progress";
962
+ s.updatedAt = now;
963
+ writeState(root, s);
964
+ appendDiagnostic(root, row.meta.id, { source: "service", level: failed ? "error" : "info", code: "host_reconciled", message: failed ? "PTY host exited before final event" : "PTY host finalized without final event", details: { hostState: row.host.state, exitCode: row.host.exitCode } });
965
+ fixed += 1;
966
+ }
967
+ continue;
968
+ }
969
+ if (row.alive) continue;
970
+ const status = readStatus(root, row.meta.id, s.currentRunId);
971
+ if (status?.endedAt) {
972
+ writeState(root, projectViewState(status, now, readState(root, row.meta.id) ?? row.state ?? null));
973
+ } else {
974
+ s.semanticState = "failed";
975
+ s.processState = "exited";
976
+ s.hasError = true;
977
+ s.needsInput = false;
978
+ s.error = s.error ?? "Runner exited unexpectedly";
979
+ s.summary = "Failed (runner exited)";
980
+ s.updatedAt = now;
981
+ writeState(root, s);
982
+ }
983
+ fixed += 1;
984
+ }
985
+ for (const row of listRows(root)) {
986
+ if ((row.state?.followUps?.queuedCount ?? 0) > 0 && canAutoDrain(row)) {
987
+ const drained = drainNextFollowUp(row.meta.id);
988
+ if (drained.ok) fixed += 1;
989
+ }
990
+ }
991
+ return fixed;
992
+ },
993
+
994
+ /**
995
+ * Mirror lifecycle/events from a managed session that is currently attached in
996
+ * the foreground. Without this, a row that was completed/needs_input can keep
997
+ * looking stale after the user types a follow-up in the real Pi session.
998
+ * @param {string|undefined} sessionFile
999
+ * @param {any} event
1000
+ * @returns {boolean} whether a managed row was updated
1001
+ */
1002
+ syncForegroundEvent(sessionFile, event) {
1003
+ if (!sessionFile || !event?.type) return false;
1004
+ const row = rowForSession(sessionFile);
1005
+ if (!row) return false;
1006
+ return syncRowEvent(row, event);
1007
+ },
1008
+
1009
+ /** @param {string|undefined} viewId @param {any} event */
1010
+ syncHostedEvent(viewId, event) {
1011
+ if (!viewId || !event?.type) return false;
1012
+ const row = loadRow(root, viewId);
1013
+ if (!row) return false;
1014
+ return syncRowEvent(row, event);
1015
+ },
1016
+
1017
+ /** @returns {import("../core/store.mjs").Row[]} all visible rows. */
1018
+ rows() {
1019
+ return listRows(root);
1020
+ },
1021
+
1022
+ /**
1023
+ * Live node-pty / PTY host health snapshot for dashboard chrome.
1024
+ * - `ok` reflects whether this process can currently launch PTY hosts.
1025
+ * - `staleHosts` counts rows whose last persisted host claimed `alive` but the
1026
+ * runner pid is gone, which often explains attach prompts / degraded UX.
1027
+ */
1028
+ ptyHealth() {
1029
+ const support = ptySupport();
1030
+ const rows = listRows(root);
1031
+ const staleHosts = rows.filter((row) => row.host?.state === "alive" && !row.hostAlive).length;
1032
+ const liveHosts = rows.filter((row) => row.hostAlive).length;
1033
+ const stalled = rows.filter((row) => row.state?.diagnostics?.stalled).length;
1034
+ const diagnosticErrors = rows.reduce((sum, row) => sum + (row.state?.diagnostics?.errorCount ?? 0), 0);
1035
+ return {
1036
+ ok: Boolean(support.ok),
1037
+ reason: support.ok ? null : support.reason ?? "PTY unavailable",
1038
+ issue: support.ok ? null : (support.issue ?? diagnoseNodePtyFailure(support.reason ?? null)),
1039
+ staleHosts,
1040
+ liveHosts,
1041
+ stalled,
1042
+ diagnosticErrors,
1043
+ };
1044
+ },
1045
+
1046
+ evidence(viewId) {
1047
+ const row = loadRow(root, viewId);
1048
+ if (!row) return { ok: false, error: "Unknown session" };
1049
+ return {
1050
+ ok: true,
1051
+ evidence: readEvidence(root, viewId),
1052
+ paths: {
1053
+ evidence: P.evidencePath(root, viewId),
1054
+ diagnostics: P.diagnosticsPath(root, viewId),
1055
+ session: row.meta.sessionFile,
1056
+ screenLog: P.screenLogPath(root, viewId),
1057
+ },
1058
+ };
1059
+ },
1060
+
1061
+ diagnostics(viewId, diagOpts = {}) {
1062
+ const row = loadRow(root, viewId);
1063
+ if (!row) return { ok: false, error: "Unknown session" };
1064
+ return { ok: true, events: tailDiagnostics(root, viewId, { limit: diagOpts.limit ?? 50 }) };
1065
+ },
1066
+
1067
+ clearDiagnostics(viewId) {
1068
+ const row = loadRow(root, viewId);
1069
+ if (!row) return { ok: false, error: "Unknown session" };
1070
+ return clearDiagnostics(root, viewId);
1071
+ },
1072
+
1073
+ /** @param {string} viewId @returns {import("../core/store.mjs").Row|null} */
1074
+ row(viewId) {
1075
+ return loadRow(root, viewId);
1076
+ },
1077
+ };
1078
+ }
1079
+
1080
+ /** @param {import("../core/store.mjs").Row} row */
1081
+ function hasPendingQuestions(row) {
1082
+ return Array.isArray(row.state?.pendingQuestions) && row.state.pendingQuestions.length > 0;
1083
+ }
1084
+
1085
+ /** @param {import("../core/store.mjs").Row} row */
1086
+ function isAgentBusy(row) {
1087
+ const st = row.state?.semanticState;
1088
+ return Boolean(row.alive && (st === "queued" || st === "working" || hasPendingQuestions(row)));
1089
+ }
1090
+
1091
+ /** @param {import("../core/types.mjs").ViewMeta} meta */
1092
+ function isExternalSession(meta) {
1093
+ const normalized = resolve(meta.sessionFile).replace(/\\/g, "/");
1094
+ return !normalized.endsWith(`/sessions/${meta.id}.jsonl`);
1095
+ }
1096
+
1097
+ /** @param {import("../core/types.mjs").EvidenceSnapshot} evidence */
1098
+ function latestEvidenceText(evidence) {
1099
+ return evidence.assistantEvidence?.[evidence.assistantEvidence.length - 1]?.text ?? "";
1100
+ }
1101
+
1102
+ /** @param {import("../core/store.mjs").Row} row */
1103
+ function canAutoDrain(row) {
1104
+ const st = row.state?.semanticState;
1105
+ return !isAgentBusy(row) && (st === "idle" || st === "completed");
1106
+ }
1107
+
1108
+ /** @param {import("../core/types.mjs").ViewState} state */
1109
+ function completionSummary(state) {
1110
+ if (!isGenericStatusText(state.summary)) return compactSummary(state.summary);
1111
+ return "Done";
1112
+ }
1113
+
1114
+ /** @param {string} text */
1115
+ function compactSummary(text) {
1116
+ const cleaned = String(text || "").replace(/\s+/g, " ").trim();
1117
+ if (!cleaned) return "Done";
1118
+ const first = firstSentence(cleaned);
1119
+ return truncate(first.length >= 12 ? first : cleaned, 80);
1120
+ }
1121
+
1122
+ /**
1123
+ * Send a one-shot JSONL command to a live host socket.
1124
+ * @param {import("../core/store.mjs").Row} row
1125
+ * @param {Record<string, unknown>} message
1126
+ * @returns {{ ok: boolean, error?: string }}
1127
+ */
1128
+ function sendHostMessage(row, message) {
1129
+ const socketPath = row.host?.socketPath;
1130
+ if (!socketPath) return { ok: false, error: "No host socket" };
1131
+ if (!existsSync(socketPath)) return { ok: false, error: "Host socket is not ready" };
1132
+ try {
1133
+ const socket = createConnection(socketPath);
1134
+ socket.on("connect", () => {
1135
+ socket.write(JSON.stringify(message) + "\n");
1136
+ socket.end();
1137
+ });
1138
+ socket.on("error", () => {});
1139
+ return { ok: true };
1140
+ } catch (err) {
1141
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1142
+ }
1143
+ }
1144
+
1145
+ let cachedPtySupport;
1146
+ const requireForPty = createRequire(import.meta.url);
1147
+ const PTY_SUPPORT_ERROR_TTL_MS = 2_000;
1148
+
1149
+ function ptyHostAvailability(opts = {}) {
1150
+ if (process.env.AGENT_BOARD_DISABLE_PTY === "1" || process.env.AGENT_VIEW_DISABLE_PTY === "1") {
1151
+ return { ok: false, reason: "AGENT_BOARD_DISABLE_PTY=1", issue: diagnoseNodePtyFailure("AGENT_BOARD_DISABLE_PTY=1") };
1152
+ }
1153
+ if (process.env.AGENT_BOARD_FORCE_PTY === "1" || process.env.AGENT_VIEW_FORCE_PTY === "1") return { ok: true };
1154
+ return ptySpawnSupported(opts);
1155
+ }
1156
+
1157
+ function envInt(name, fallback, min, max, legacyName) {
1158
+ const raw = process.env[name] ?? (legacyName ? process.env[legacyName] : undefined);
1159
+ if (raw === undefined || raw === "") return fallback;
1160
+ const n = Number(raw);
1161
+ if (!Number.isFinite(n)) return fallback;
1162
+ return Math.max(min, Math.min(max, Math.floor(n)));
1163
+ }
1164
+
1165
+ export function shouldProbePtySupport(cached, opts = {}, now = Date.now()) {
1166
+ if (!cached) return true;
1167
+ if (cached.ok) return false;
1168
+ if (opts.refresh) return true;
1169
+ const ttlMs = opts.maxAgeMs ?? PTY_SUPPORT_ERROR_TTL_MS;
1170
+ return now - (cached.checkedAt ?? 0) >= ttlMs;
1171
+ }
1172
+
1173
+ function ptySpawnSupported(opts = {}) {
1174
+ const now = Date.now();
1175
+ if (!shouldProbePtySupport(cachedPtySupport, opts, now)) return cachedPtySupport;
1176
+ try {
1177
+ ensureNodePtySpawnHelperExecutable(requireForPty);
1178
+ const pty = requireForPty("node-pty");
1179
+ const proc = pty.spawn(process.execPath, ["-e", "process.exit(0)"], {
1180
+ name: "xterm-256color",
1181
+ cols: 20,
1182
+ rows: 5,
1183
+ cwd: process.cwd(),
1184
+ env: process.env,
1185
+ });
1186
+ proc.kill?.();
1187
+ cachedPtySupport = { ok: true, checkedAt: now };
1188
+ } catch (err) {
1189
+ cachedPtySupport = { ok: false, reason: err instanceof Error ? err.message : String(err), checkedAt: now };
1190
+ cachedPtySupport.issue = diagnoseNodePtyFailure(cachedPtySupport.reason, { probe: probeNodePtyEnvironment(requireForPty) });
1191
+ }
1192
+ return cachedPtySupport;
1193
+ }
1194
+