@zhuxixi/pi-agent-board 0.5.2 → 0.6.1

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 (61) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +43 -5
  3. package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
  4. package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
  5. package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
  6. package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
  7. package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
  8. package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
  9. package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
  10. package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
  11. package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
  12. package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
  13. package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +30 -0
  14. package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +68 -0
  15. package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +125 -0
  16. package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +56 -0
  17. package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +96 -0
  18. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
  19. package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
  20. package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
  21. package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
  22. package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
  23. package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
  24. package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
  25. package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
  26. package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
  27. package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
  28. package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
  29. package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +58 -0
  30. package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +52 -0
  31. package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +87 -0
  32. package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +56 -0
  33. package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +79 -0
  34. package/package.json +83 -81
  35. package/runner/job-runner.mjs +2 -2
  36. package/runner/pty-runner.mjs +626 -3
  37. package/runner/state-runner.mjs +3 -0
  38. package/runner/title-runner.mjs +1 -1
  39. package/scripts/release_helper.mjs +277 -0
  40. package/src/commands/agent-board.ts +47 -35
  41. package/src/commands/attach-decision.mjs +66 -0
  42. package/src/commands/attach-flow.ts +45 -39
  43. package/src/commands/bg.ts +9 -0
  44. package/src/core/code-refs.mjs +85 -33
  45. package/src/core/evidence.mjs +2 -2
  46. package/src/core/heuristics.mjs +75 -2
  47. package/src/core/host-coordination.mjs +182 -0
  48. package/src/core/host-crash.mjs +43 -3
  49. package/src/core/host-probe.mjs +196 -0
  50. package/src/core/launch-options.mjs +17 -0
  51. package/src/core/launch.mjs +35 -35
  52. package/src/core/locks.mjs +196 -1
  53. package/src/core/paths.mjs +24 -0
  54. package/src/core/store.mjs +164 -5
  55. package/src/core/types.mjs +17 -1
  56. package/src/core/warm-host-sweeper.mjs +150 -0
  57. package/src/index.ts +40 -3
  58. package/src/runtime/service.mjs +1071 -108
  59. package/src/ui/dashboard-decisions.mjs +55 -0
  60. package/src/ui/dashboard.ts +67 -13
  61. package/src/ui/pty-attach.ts +23 -5
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { existsSync, readdirSync, statSync } from "node:fs";
7
7
  import { atomicWriteJson, ensureDir, readJson } from "./atomic.mjs";
8
+ import { sameHostOwner } from "./host-coordination.mjs";
9
+ import { tryAcquireOwnedViewLock } from "./locks.mjs";
8
10
  import * as P from "./paths.mjs";
9
11
  import { isAlive } from "./pid.mjs";
10
12
  import { readCodeRefs, summarizeCodeRefs } from "./code-refs-store.mjs";
@@ -120,9 +122,159 @@ export function writeHost(root, host) {
120
122
  atomicWriteJson(P.hostPath(root, host.viewId), host);
121
123
  }
122
124
 
123
- /** @param {string} root @param {string} viewId @param {number|null} pid */
124
- export function writeHostPid(root, viewId, pid) {
125
- atomicWriteJson(P.hostPidPath(root, viewId), { pid, at: Date.now() });
125
+ /**
126
+ * @param {string} root
127
+ * @param {string} viewId
128
+ * @param {number|null} pid
129
+ * @param {{ instanceId?: string, identity?: object|null }} [extra] merged into the mirror record
130
+ */
131
+ export function writeHostPid(root, viewId, pid, extra) {
132
+ atomicWriteJson(P.hostPidPath(root, viewId), { pid, at: Date.now(), ...(extra ?? {}) });
133
+ }
134
+
135
+ /**
136
+ * Host states that represent an unreplaced claim: no replacement may be
137
+ * started while a host record is in one of these states (issue #70).
138
+ * @param {HostStatus|null} host
139
+ */
140
+ function hostClaimActive(host) {
141
+ return Boolean(host && (host.state === "starting" || host.state === "alive" || host.state === "stopping"));
142
+ }
143
+
144
+ /**
145
+ * Atomically create the provisional `starting` host record for a new instance.
146
+ * The ONLY entry point allowed to move "no claim / reclaimable terminal state"
147
+ * into `starting` (issue #70). Refuses when an active claim exists or the
148
+ * host-meta lease is contended; the fresh record always nulls runner/child/
149
+ * ready/stop fields so no stale owner data survives the handover.
150
+ * @param {string} root
151
+ * @param {Partial<HostStatus> & { viewId: string, instanceId: string }} provisionalHost
152
+ * @param {{ heldStartLease?: unknown }} [opts] reserved for host-start lease nesting;
153
+ * host-meta is always acquired independently here (short critical section).
154
+ * @returns {{ claimed: boolean, host: HostStatus|null }}
155
+ */
156
+ export function claimHost(root, provisionalHost, opts = {}) {
157
+ const lock = tryAcquireOwnedViewLock(root, provisionalHost.viewId, "host-meta");
158
+ if (!lock.acquired) return { claimed: false, host: null };
159
+ try {
160
+ const existing = readHost(root, provisionalHost.viewId);
161
+ if (hostClaimActive(existing)) return { claimed: false, host: existing };
162
+ const now = Date.now();
163
+ /** @type {HostStatus} */
164
+ const record = {
165
+ version: 1,
166
+ viewId: provisionalHost.viewId,
167
+ mode: "pty",
168
+ instanceId: provisionalHost.instanceId,
169
+ configPath: provisionalHost.configPath ?? null,
170
+ socketPath: provisionalHost.socketPath ?? null,
171
+ claimAt: provisionalHost.claimAt ?? now,
172
+ claimPid: provisionalHost.claimPid ?? null,
173
+ claimIdentity: provisionalHost.claimIdentity ?? null,
174
+ // A fresh claim owns nothing yet: force-clear every field a superseded
175
+ // instance might have left behind.
176
+ runnerPid: null,
177
+ runnerIdentity: null,
178
+ runnerSpawnedAt: null,
179
+ childPid: null,
180
+ childIdentity: null,
181
+ childSpawnedAt: null,
182
+ readyAt: null,
183
+ stopRequestedAt: null,
184
+ revokeToken: null,
185
+ stopReason: null,
186
+ state: "starting",
187
+ startedAt: provisionalHost.claimAt ?? now,
188
+ lastSeenAt: now,
189
+ endedAt: null,
190
+ exitCode: null,
191
+ error: null,
192
+ cols: provisionalHost.cols ?? 120,
193
+ rows: provisionalHost.rows ?? 36,
194
+ attachedClients: 0,
195
+ };
196
+ writeHost(root, record);
197
+ // Mirror is best-effort; host.json is the authority for the new protocol.
198
+ try {
199
+ writeHostPid(root, provisionalHost.viewId, record.claimPid, {
200
+ instanceId: record.instanceId,
201
+ identity: record.claimIdentity,
202
+ });
203
+ } catch { /* best effort */ }
204
+ return { claimed: true, host: record };
205
+ } finally {
206
+ lock.lease.release();
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Bounded contention retry for owner-fenced host writes (issue #70, PR #84 CI
212
+ * wave 2). Heartbeat/client-merge writes hold the host-meta lease for only a
213
+ * few milliseconds, but a one-shot acquire can land inside that window and
214
+ * return busy — a revoke or recovery write that silently no-ops is a real
215
+ * reliability bug, not just a test race. Both `busy` (live owner) and
216
+ * `blocked` (identity-less short hold — updateOwnedHost itself acquires
217
+ * host-meta without a reclaimable identity, so concurrent fenced writes look
218
+ * blocked to each other) are transient here: retry a few times with a short
219
+ * synchronous sleep before giving up. A genuinely orphaned host-meta lock
220
+ * (holder SIGKILLed mid-hold) survives the window and surfaces as retryable
221
+ * not-updated — never as ownership loss.
222
+ */
223
+ const UPDATE_LOCK_BUSY_ATTEMPTS = 3;
224
+ const UPDATE_LOCK_BUSY_SLEEP_MS = 20;
225
+
226
+ /**
227
+ * Owner-fenced compare-and-write for host.json. Re-reads the view's record
228
+ * under the host-meta lease and only applies `mutate` while it still belongs
229
+ * to `expectedInstanceId`; a superseded instance's late heartbeat/crash/exit
230
+ * writes are rejected with `ownerChanged: true` and the current host (issue
231
+ * #70). Lease contention that survives the bounded retry returns not-updated
232
+ * with `ownerChanged: false` — callers treat that as retryable (heartbeats
233
+ * simply retry next tick; revokes/recovery re-attempt).
234
+ * @param {string} root
235
+ * @param {string} viewId
236
+ * @param {string} expectedInstanceId
237
+ * @param {(host: HostStatus) => HostStatus} mutate returns a new record
238
+ * @param {{ heldStartLease?: unknown, lockImpl?: typeof tryAcquireOwnedViewLock }} [opts]
239
+ * heldStartLease is reserved for host-start lease nesting; lockImpl injects
240
+ * the host-meta acquisition for deterministic contention tests.
241
+ * @returns {{ updated: boolean, ownerChanged: boolean, host: HostStatus|null }}
242
+ */
243
+ export function updateOwnedHost(root, viewId, expectedInstanceId, mutate, opts = {}) {
244
+ const acquireHostMeta = opts.lockImpl ?? tryAcquireOwnedViewLock;
245
+ let lock;
246
+ for (let attempt = 0; ; attempt++) {
247
+ lock = acquireHostMeta(root, viewId, "host-meta");
248
+ if (lock.acquired) break;
249
+ // busy and blocked are both millisecond-scale holds for host-meta;
250
+ // neither is ownership information — only the fenced read below is.
251
+ if (attempt >= UPDATE_LOCK_BUSY_ATTEMPTS - 1) {
252
+ return { updated: false, ownerChanged: false, host: null };
253
+ }
254
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, UPDATE_LOCK_BUSY_SLEEP_MS);
255
+ }
256
+ try {
257
+ const host = readHost(root, viewId);
258
+ if (!host || !sameHostOwner(host, expectedInstanceId)) {
259
+ return { updated: false, ownerChanged: true, host };
260
+ }
261
+ const next = mutate(host);
262
+ // The mutate result must still belong to the expected instance: a buggy or
263
+ // hijacked callback that swaps the owner token is never written (issue #70).
264
+ if (!sameHostOwner(next, expectedInstanceId)) {
265
+ return { updated: false, ownerChanged: true, host };
266
+ }
267
+ writeHost(root, next);
268
+ try {
269
+ writeHostPid(root, viewId, next.runnerPid ?? null, {
270
+ instanceId: next.instanceId,
271
+ identity: next.runnerIdentity ?? null,
272
+ });
273
+ } catch { /* best effort */ }
274
+ return { updated: true, ownerChanged: false, host: next };
275
+ } finally {
276
+ lock.lease.release();
277
+ }
126
278
  }
127
279
 
128
280
  /** @param {string} root @param {string} viewId @returns {number|null} */
@@ -177,6 +329,8 @@ function mtime(dir) {
177
329
  * @property {ViewState|null} state
178
330
  * @property {boolean} alive Whether the row's current run pid/foreground activity is alive.
179
331
  * @property {boolean} hostAlive Whether a PTY host/socket is alive and attachable.
332
+ * @property {boolean} hostActive Whether an unreplaced host claim exists (starting/alive/stopping).
333
+ * @property {boolean} hostReady Whether the host claims readiness (alive + readyAt); metadata hint only.
180
334
  * @property {HostStatus|null} host
181
335
  * @property {import("./types.mjs").ReviewSummary} [review]
182
336
  * @property {import("./types.mjs").DiagnosticSummary} [diagnostics]
@@ -202,9 +356,14 @@ export function loadRow(root, viewId) {
202
356
  // poll, but foreground extension events mirror processState into state.json.
203
357
  if (!alive && enrichedState?.processState === "alive") alive = true;
204
358
  const host = readHost(root, viewId);
205
- const hostPid = host?.runnerPid ?? readHostPid(root, viewId);
359
+ // New-protocol records carry an explicit runnerPid (null until spawn
360
+ // confirms); only legacy records without the property fall back to the
361
+ // host-pid mirror (issue #70).
362
+ const hostPid = host && Object.hasOwn(host, "runnerPid") ? host.runnerPid : readHostPid(root, viewId);
206
363
  const hostAlive = Boolean(host && (host.state === "alive" || host.state === "starting") && isAlive(hostPid));
207
- return { meta, state: enrichedState, alive, hostAlive, host, ...summaries };
364
+ const hostActive = Boolean(host && (host.state === "starting" || host.state === "alive" || host.state === "stopping"));
365
+ const hostReady = Boolean(hostActive && host.state === "alive" && host.readyAt != null);
366
+ return { meta, state: enrichedState, alive, hostAlive, hostActive, hostReady, host, ...summaries };
208
367
  }
209
368
 
210
369
  /** @param {string} root @param {string} viewId */
@@ -6,7 +6,7 @@
6
6
  /** Semantic (task) state of a row. @typedef {"queued"|"working"|"needs_input"|"idle"|"completed"|"failed"|"stopped"} SemanticState */
7
7
  /** Process/liveness state. @typedef {"alive"|"exited"} ProcessState */
8
8
  /** PTY host mode. @typedef {"json-runner"|"pty"} HostMode */
9
- /** PTY host liveness. @typedef {"starting"|"alive"|"exited"|"failed"} HostState */
9
+ /** PTY host liveness. @typedef {"starting"|"alive"|"stopping"|"exited"|"failed"} HostState */
10
10
  /** How a run was kicked off. @typedef {"dispatch"|"reply"|"plan"|"plan_change"|"plan_approval"} RunKind */
11
11
  /** Worktree isolation mode for a row. @typedef {"off"|"worktree"} WorktreeMode */
12
12
  /** Diagnostic severity. @typedef {"info"|"warn"|"error"} DiagnosticLevel */
@@ -141,6 +141,19 @@ export const GROUP_LABELS = {
141
141
  * @property {number} rows
142
142
  * @property {number} attachedClients
143
143
  * @property {boolean} [attachedEver] Whether any client attached to this host.
144
+ * @property {string|null} [instanceId] Owner/fencing token for this host launch (issue #70).
145
+ * @property {string|null} [configPath] Per-instance config file this host was launched with.
146
+ * @property {number|null} [claimAt] When the provisional claim was written (epoch ms).
147
+ * @property {number|null} [claimPid] PID of the service process that wrote the claim.
148
+ * @property {{pid:number,startToken:string|null}|null} [claimIdentity] Launch-time identity of the claiming service process.
149
+ * @property {{pid:number,startToken:string|null}|null} [runnerIdentity] Stable identity of the runner process.
150
+ * @property {number|null} [runnerSpawnedAt] When the runner spawn was confirmed (null = not yet spawned).
151
+ * @property {{pid:number,startToken:string|null}|null} [childIdentity] Stable identity of the child Pi process.
152
+ * @property {number|null} [childSpawnedAt] When the child spawn was confirmed (null = not yet spawned).
153
+ * @property {number|null} [readyAt] Set only after endpoint bind + child creation; alive implies readyAt != null.
154
+ * @property {number|null} [stopRequestedAt] When a revoke was requested via the host record.
155
+ * @property {string|null} [revokeToken] Random token written by requestHostStop to fence duplicate revokes.
156
+ * @property {string|null} [stopReason] Human-readable reason recorded with the revoke.
144
157
  */
145
158
 
146
159
  /**
@@ -213,6 +226,9 @@ export const GROUP_LABELS = {
213
226
  * @property {number} cols
214
227
  * @property {number} rows
215
228
  * @property {number|null} screenLogMaxBytes per-view screen.log write cap; null = runner default
229
+ * @property {string|null} [instanceId] Owner/fencing token for this host launch (issue #70).
230
+ * @property {string|null} [configPath] Per-instance config file path; each launch owns its own so concurrent launches never share a config.
231
+ * @property {string|null} [socketPath] Per-instance control endpoint the spawned runner must bind.
216
232
  */
217
233
 
218
234
  /**
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Warm-host sweep reclaim (issue #75).
3
+ *
4
+ * Idle PTY hosts must not live forever: after detach the runner is a detached
5
+ * orphan that nothing else will reap (Windows has no parent-death cascade, and
6
+ * the dashboard host may exit without a cleanup hook). The design intent
7
+ * (AGENT_BOARD_WARM_HOST_TTL_MS / MAX_WARM_HOSTS) already exists in
8
+ * service.mjs pruneWarmHosts but only fires lazily on attach/prewarm/dispatch.
9
+ * This module extracts the pure eviction decision plus a periodic sweeper so
10
+ * the TTL actually runs.
11
+ */
12
+
13
+ /** @param {import("./store.mjs").Row} row */
14
+ export function hasPendingQuestions(row) {
15
+ return Array.isArray(row.state?.pendingQuestions) && row.state.pendingQuestions.length > 0;
16
+ }
17
+
18
+ /**
19
+ * Busy = an agent run is active (queued/working) or the session is waiting on
20
+ * user questions. Idle/completed/failed/stopped are never busy.
21
+ * @param {import("./store.mjs").Row} row
22
+ */
23
+ export function isAgentBusy(row) {
24
+ const st = row.state?.semanticState;
25
+ return Boolean(row.alive && (st === "queued" || st === "working" || hasPendingQuestions(row)));
26
+ }
27
+
28
+ /**
29
+ * Pure eviction decision for warm PTY hosts. No IO, no env: every threshold is
30
+ * passed in so the logic is directly unit-testable.
31
+ *
32
+ * idle = host alive && !busy && no attached clients && not keepViewId.
33
+ * graceMs exempts freshly started hosts (attach handoff race: ensureHost has
34
+ * started a host but the client has not connected yet).
35
+ * TTL eviction runs first; survivors over maxWarm are evicted oldest-first by
36
+ * idleSince = max(state.lastActivityAt, host.startedAt) || meta.updatedAt (host-level
37
+ * idle counts from host start, so a stale lastActivityAt from a previous host
38
+ * incarnation never makes a fresh host look idle longer than it has been up).
39
+ *
40
+ * @param {Array<import("./store.mjs").Row>} rows
41
+ * @param {{ now: number, maxWarm: number, ttlMs: number, graceMs?: number, keepViewId?: string|null }} o
42
+ * @returns {{ ttlEvicted: string[], excessEvicted: string[] }} viewIds, ttl group first
43
+ */
44
+ export function selectIdleHostsToEvict(rows, { now, maxWarm, ttlMs, graceMs = 0, keepViewId = null }) {
45
+ // Both-zero = eviction disabled entirely. Callers (service.pruneWarmHosts)
46
+ // may additionally early-return for the same reason.
47
+ if (maxWarm === 0 && ttlMs === 0) return { ttlEvicted: [], excessEvicted: [] };
48
+ const idle = [];
49
+ for (const row of rows) {
50
+ if (keepViewId != null && row.meta.id === keepViewId) continue;
51
+ if (!row.hostAlive) continue;
52
+ if (isAgentBusy(row)) continue;
53
+ if ((row.host?.attachedClients ?? 0) !== 0) continue;
54
+ const startedAt = row.host?.startedAt;
55
+ if (graceMs > 0 && startedAt != null && now - startedAt < graceMs) continue;
56
+ // Host-level idle counts from host start: a stale lastActivityAt from a
57
+ // previous host incarnation must not make a freshly prewarmed host look
58
+ // idle for longer than it has actually been up (issue #75 review #1).
59
+ const idleSince = Math.max(row.state?.lastActivityAt ?? 0, startedAt ?? 0) || row.meta.updatedAt;
60
+ idle.push({ id: row.meta.id, idleSince });
61
+ }
62
+ const ttlEvicted = [];
63
+ const survivors = [];
64
+ for (const it of idle) {
65
+ // Keep the historical semantics: ttlMs === 0 disables the ttl branch
66
+ // (only the maxWarm cap applies); both zero disables eviction entirely.
67
+ if (ttlMs > 0 && now - it.idleSince > ttlMs) ttlEvicted.push(it.id);
68
+ else survivors.push(it);
69
+ }
70
+ survivors.sort((a, b) => a.idleSince - b.idleSince);
71
+ const excess = Math.max(0, survivors.length - maxWarm);
72
+ return { ttlEvicted, excessEvicted: survivors.slice(0, excess).map((it) => it.id) };
73
+ }
74
+
75
+ /**
76
+ * Periodic sweeper for warm hosts. The interval timer is unref'd so it never
77
+ * holds the host pi's event loop open on exit. intervalMs <= 0 disables the
78
+ * periodic part; sweepNow() always works.
79
+ *
80
+ * stop() only stops the periodic timer: sweepNow() stays available afterwards
81
+ * (the lifecycle wiring calls sweepNow() again on shutdown/dispose). start()
82
+ * is idempotent and may restart the timer after a stop.
83
+ * @param {{ sweep: () => void, intervalMs: number }} o
84
+ */
85
+ export function createWarmHostSweeper({ sweep, intervalMs }) {
86
+ let timer = null;
87
+ return {
88
+ active: true,
89
+ start() {
90
+ if (timer !== null) return;
91
+ if (intervalMs > 0) {
92
+ timer = setInterval(() => {
93
+ try { sweep(); } catch { /* best-effort */ }
94
+ }, intervalMs);
95
+ if (typeof timer.unref === "function") timer.unref();
96
+ }
97
+ },
98
+ sweepNow() {
99
+ try { sweep(); } catch { /* best-effort */ }
100
+ },
101
+ stop() {
102
+ if (timer !== null) {
103
+ clearInterval(timer);
104
+ timer = null;
105
+ }
106
+ },
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Wire the sweeper to a host pi extension lifetime: sweep once on attach
112
+ * (reclaims hosts leaked by a previous host that died without cleanup), run
113
+ * periodically, and sweep again on session_shutdown (host pi exiting or the
114
+ * extension instance being reloaded for a session switch).
115
+ *
116
+ * Child pi processes (AGENT_BOARD_CHILD=1 / AGENT_VIEW_CHILD=1) must never
117
+ * sweep: they share the same board root and would terminate their own runner
118
+ * (suicide chain). They get a strict no-op.
119
+ *
120
+ * @param {{ on?: (event: any, fn: () => void) => any }} pi — any, not a narrower
121
+ * string-keyed signature: pi's ExtensionAPI.on is a union of literal event-name
122
+ * overloads and would otherwise fail structural assignment from TypeScript
123
+ * callers (contravariant parameter check).
124
+ * @param {{ isHostedChild: boolean, sweep: () => void, intervalMs: number }} o
125
+ */
126
+ export function attachWarmHostSweeper(pi, { isHostedChild, sweep, intervalMs }) {
127
+ // Child pi processes and board-spawned non-host workers must never sweep:
128
+ // children would terminate their own runner (suicide chain); workers
129
+ // (job/state runners set AGENT_BOARD_NO_SWEEP=1) would churn the shared root.
130
+ if (isHostedChild || process.env.AGENT_BOARD_NO_SWEEP === "1") {
131
+ return { active: false, dispose() {} };
132
+ }
133
+ const sweeper = createWarmHostSweeper({ sweep, intervalMs });
134
+ let shutdown = false;
135
+ const onShutdown = () => {
136
+ if (shutdown) return;
137
+ shutdown = true;
138
+ sweeper.sweepNow();
139
+ sweeper.stop();
140
+ };
141
+ pi.on?.("session_shutdown", onShutdown);
142
+ sweeper.start();
143
+ sweeper.sweepNow(); // 回收上一个宿主(可能非正常退出)遗留的 warm hosts
144
+ return {
145
+ active: true,
146
+ dispose() {
147
+ onShutdown();
148
+ },
149
+ };
150
+ }
package/src/index.ts CHANGED
@@ -11,7 +11,8 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
11
11
  import { resolvePiInvocation } from "./core/invocation.mjs";
12
12
  import { controlSocketPathFor, defaultRoot } from "./core/paths.mjs";
13
13
  import { listRows } from "./core/store.mjs";
14
- import { createService } from "./runtime/service.mjs";
14
+ import { createService, envInt } from "./runtime/service.mjs";
15
+ import { attachWarmHostSweeper } from "./core/warm-host-sweeper.mjs";
15
16
  import { openDashboard, registerAgentBoardCommand } from "./commands/agent-board.js";
16
17
  import { registerBgCommand } from "./commands/bg.js";
17
18
 
@@ -39,6 +40,33 @@ export default function piAgentBoard(pi: ExtensionAPI): void {
39
40
  piArgsPrefix,
40
41
  getThinkingLevel: () => pi.getThinkingLevel(),
41
42
  };
43
+
44
+ // Warm-host reclaim (issue #75): idle PTY hosts must not leak forever after
45
+ // detach. Sweep once now (reclaims hosts orphaned by a previous host pi that
46
+ // exited without cleanup), then periodically; sweep again on shutdown.
47
+ // Child pi processes skip entirely (they share the board root and would
48
+ // terminate their own runner). serviceForContext is defined before the attach
49
+ // call because the sweeper sweeps synchronously on attach and the sweep
50
+ // closure references it (no TDZ).
51
+ const serviceForContext = () =>
52
+ createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: process.cwd() });
53
+ let sweepService: ReturnType<typeof createService> | null = null;
54
+ attachWarmHostSweeper(pi, {
55
+ isHostedChild,
56
+ sweep: () => {
57
+ try {
58
+ // Reuse one service instance per extension lifetime: createService
59
+ // schedules a screen-log GC on construction, so per-tick creation
60
+ // would turn an event-driven cost into a timed one.
61
+ sweepService ??= serviceForContext();
62
+ sweepService.pruneWarmHosts();
63
+ } catch {
64
+ /* best-effort: never break the session over a sweep */
65
+ }
66
+ },
67
+ intervalMs: envInt("AGENT_BOARD_SWEEP_INTERVAL_MS", 60_000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_SWEEP_INTERVAL_MS"),
68
+ });
69
+
42
70
  registerAgentBoardCommand(pi, commandOpts);
43
71
  registerBgCommand(pi, commandOpts);
44
72
  pi.registerFlag("agent-board", {
@@ -48,8 +76,17 @@ export default function piAgentBoard(pi: ExtensionAPI): void {
48
76
  });
49
77
 
50
78
  // Footer status: reconcile stale rows and surface how many need attention.
79
+ // Live model list for the stale-defaultModel launch guard (issue #90); the
80
+ // service calls it per validation, and undefined conservatively skips.
81
+ const availableModelsFor = (ctx: ExtensionContext) => () => {
82
+ try {
83
+ return ctx.modelRegistry.getAvailable();
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ };
51
88
  const serviceFor = (ctx: ExtensionContext) =>
52
- createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd });
89
+ createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd, availableModels: availableModelsFor(ctx) });
53
90
 
54
91
  const updateStatus = (ctx: ExtensionContext) => {
55
92
  try {
@@ -84,7 +121,7 @@ export default function piAgentBoard(pi: ExtensionAPI): void {
84
121
  }
85
122
  updateStatus(ctx);
86
123
  if (event.reason === "startup" && !isHostedChild && pi.getFlag("agent-board") === true && ctx.hasUI) {
87
- const service = createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd });
124
+ const service = createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd, availableModels: availableModelsFor(ctx) });
88
125
  service.reconcile();
89
126
  ctx.ui.setWorkingVisible(false);
90
127
  ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));