@zhuxixi/pi-agent-board 0.6.0 → 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.
@@ -0,0 +1,56 @@
1
+ # issue #86 spec:spawn 异步 error 兜底(spawnDetached 统一封装)
2
+
3
+ 日期:2026-09-08 · 状态:已授权全自动推进(用户 2026-09-08 决策)
4
+
5
+ ## 背景与问题
6
+
7
+ `launch.mjs` 4 个启动函数与 `pty-attach.ts openExternalTarget()` 的 spawn 均为 `spawn(...)` + `unref()` 无 `'error'` listener。spawn 启动失败走异步 `'error'` 事件,EventEmitter 无 listener 的 error 直接 throw → uncaughtException → 整个 pi 宿主进程退出。实录:WSL2 上一次 node 二进制瞬时 ENOENT 直接带崩 pi(host.json 已正确落 failed,但进程没活下来走重试)。
8
+
9
+ ## 核心设计
10
+
11
+ ### D1:launch.mjs 新增内部 helper `spawnDetached`
12
+
13
+ ```js
14
+ function spawnDetached(command, args, cwd) {
15
+ const child = spawn(command, args, { cwd, detached: true, stdio: "ignore", env: process.env, windowsHide: true });
16
+ child.on("error", () => {}); // 接住异步启动失败;调用方按 pid==null 记 failed,宿主不崩
17
+ child.unref();
18
+ return child;
19
+ }
20
+ ```
21
+
22
+ `launchRun` / `launchHost` / `launchTitle` / `launchAutoState` 四处收敛到该 helper。helper 不导出(模块内部实现细节,测试通过公开入口行为验证)。
23
+
24
+ ### D2:openExternalTarget 三分支补 error listener
25
+
26
+ darwin/win32/其他三分支统一为 `const child = spawn(...); child.on("error", () => {}); child.unref();`。fire-and-forget 语义与返回值不变(打开链接是 best-effort,失败静默)。参照同文件 L847-848 xclip 既有正确模式。
27
+
28
+ ## 非目标
29
+
30
+ - runner/*.mjs 内的 spawn(跑在分离 runner 进程里,崩了不拖垮 pi,issue 已明确排除);
31
+ - 顶层 `process.on("uncaughtException")` 兜底(会掩盖未知错误,不引入);
32
+ - 改变任何返回值/状态机语义。
33
+
34
+ ## 可测性拆分设计
35
+
36
+ | 单元 | 性质 | 测法 |
37
+ |---|---|---|
38
+ | spawnDetached 异步 error 兜底 | 副作用隔离(仅 child_process) | integration:注入不存在 node 路径,断言返回 pid:null 且进程存活(uncaughtException listener 不触发;node:test 进程崩=测试天然失败) |
39
+ | 4 个公开入口行为不变 | 公开 API | 复用现有 4 测试 + 新增 ENOENT 用例覆盖 launchRun/launchHost/launchTitle/launchAutoState |
40
+ | openExternalTarget | UI 层(c8 阈值外,惯例冒烟保护) | 静态验证 error listener 存在;不单加测试 |
41
+
42
+ ## 验收矩阵
43
+
44
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
45
+ |----|--------|----------|----------|----------|
46
+ | A1 | spawn 失败不带崩进程(4 入口) | 自动化(integration) | `node --test test/launch.test.mjs` | 注入 `/nonexistent/node-ENOENT-test` 后各入口正常返回 pid:null,进程存活 |
47
+ | A2 | 既有行为回归 | 自动化(integration) | `node --test test/launch.test.mjs` | 原 4 测试全绿 |
48
+ | A3 | openExternalTarget error 兜底 | 自动化(static) | 代码审查 + grep 断言 | 三分支均有 error listener |
49
+ | A4 | 全量回归 | 自动化(static/build) | `npm test` + `npm run typecheck` | 568+ 全绿、无类型错误 |
50
+ | U1 | issue 复现脚本对照 | 自动化替代(A1 等价) | issue 自带复现脚本逻辑已并入 A1 | 见 A1 |
51
+
52
+ U1 说明:issue 的复现脚本本质是"注入坏 node 路径 + uncaughtException 监听",A1 测试完全等价覆盖,故不需要独立用户实测。无 U 类纯人工项。
53
+
54
+ ## 风险
55
+
56
+ 极低:纯增量兜底,不改成功路径任何行为;失败路径从"进程崩"变为"走既有 pid==null 记 failed 重试路径"。
@@ -0,0 +1,79 @@
1
+ # issue #90 spec:defaultModel 失效防护(launch 前校验 + child exit 归因)
2
+
3
+ 日期:2026-09-08 · 状态:已授权全自动推进(用户 2026-09-08 决策)
4
+
5
+ ## 背景与问题
6
+
7
+ session 创建时记录的 `meta.defaultModel` 失效后(provider 前缀改名/移除),attach 走「launchHost → child --model 失效 → exit 1 → host exited → 再 attach 再 launch」死循环(实录单 view 数百次 launch_host)。且 runner child exit 不写 error 字段(pty-runner.mjs L217),host.json 只有 exitCode:1 无任何归因线索。
8
+
9
+ ## 核心设计
10
+
11
+ ### D1:launch 前模型校验(断死循环)
12
+
13
+ 1. **core 导出匹配函数**(`src/core/launch-options.mjs`):
14
+ ```js
15
+ /**
16
+ * Whether a stored model reference resolves to a currently-available model.
17
+ * Same rule as the dashboard launch picker: case-insensitive exact "provider/id".
18
+ * @param {string|null|undefined} modelRef
19
+ * @param {Array<{provider: string, id: string}>|undefined|null} availableModels
20
+ * @returns {boolean} true when modelRef is empty/null (no constraint) or matched.
21
+ */
22
+ export function modelRefAvailable(modelRef, availableModels)
23
+ ```
24
+ `modelRef` 空 → true(无校验对象);`availableModels` 空/undefined → true(调用方无法判断时保守放行,不制造新阻塞)。
25
+
26
+ 2. **service 注入**:`createService` 新增 `opts.availableModels`(`() => Array<{provider,id}>`,每次调用实时取值;默认 undefined → 跳过校验)。`src/index.ts` serviceFor 与 `src/commands/agent-board.ts` flag 路径注入 `() => ctx.modelRegistry.getAvailable()`(try/catch → undefined,对齐 agent-board.ts L75-79 现有防御模式)。
27
+
28
+ 3. **统一校验 helper**(service.mjs 模块内):`validateViewModelMeta(meta) → null | string`(null=通过;string=错误消息)。错误消息明确可行动:`Model "X" configured for this session is no longer available — update the view's model or clear defaultModel, then retry attach.`
29
+
30
+ 4. **两个校验点**(service.mjs 仅有的 host spawn 路径):
31
+ - `startHostUnderLease`:在 claim 之前校验(首选;若代码结构上 claim 已发生,则落 failed 清理后返回)→ `{ ok: false, error }`;
32
+ - `adoptClaimedHost`:claim 是既有废弃记录 → 校验失败时 `updateOwnedHost` 落 failed(error 带模型消息)+ 返回 pending。
33
+ - resolver 链路无需改动:ensureHostImpl 的 `{ ok:false, error }` 走现有 `pending(sessionFile, res.error)` → attach-flow notify 展示(渠道现成)。
34
+
35
+ 5. **dashboard.ts L1738 `findLaunchModelByRef` 改为复用 core 导出**(单一事实源;LaunchModel 结构类型 JSDoc 化)。
36
+
37
+ ### D2:child exit 错误归因(补线索)
38
+
39
+ 1. **纯函数**(`src/core/heuristics.mjs` 新增导出):`lastVisibleLogLine(text, maxLen = 200)`——strip ANSI(CSI `\x1b\[[0-9;?]*[ -/]*[@-~]` + OSC `\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)` + 杂项转义),按行切分,返回最后一个非空可见行(`\r` 处理:取每行最后一个 `\r` 段),截断 maxLen。空输入/全空 → null。
40
+ 2. **runner 接线**(`runner/pty-runner.mjs` child exit 回调 L213-223):`exitCode != null && exitCode !== 0` 时 best-effort(try/catch 包裹,绝不影响 exit 路径)读 screen.log 尾部 ~8KB(openSync/readSync seek 尾部,不整文件读)→ `lastVisibleLogLine` → 非 null 则 `update({ ..., error })`。exitCode=0 不改 error。
41
+
42
+ ## 非目标
43
+
44
+ - **失败退避**(issue 建议 2):D1 已断 spawn 死循环;通用退避需跨 claim 持久计数,复杂度/收益不划算,未来场景再单开;
45
+ - **run 路径模型校验**(launchRun 的 model):detach run 失败是一次性的,不循环——观察项;
46
+ - **UI 改动**:notify 渠道现成;
47
+ - 自动 fallback 到其他模型:静默换模型会让用户不知 session 行为已变,fail-fast + 明确提示更安全(issue 建议 1 的两个选项中选 fail-fast)。
48
+
49
+ ## 可测性拆分设计
50
+
51
+ | 单元 | 位置 | 性质 | 测法 |
52
+ |---|---|---|---|
53
+ | `modelRefAvailable` | launch-options.mjs | 纯函数 | 真值表:大小写/null/空列表/部分匹配不匹配 |
54
+ | `validateViewModelMeta` + ensure 路径 | service.mjs | 副作用隔离(注入 availableModels + launchHost spy) | integration:失效模型 → ok:false + launchHost 零调用 + 无 starting claim 残留;有效/null/未注入三对照 |
55
+ | adopt 路径校验 | service.mjs | 同上 | integration:废弃 claim + 失效模型 → 落 failed + 不 spawn |
56
+ | `lastVisibleLogLine` | heuristics.mjs | 纯函数 | 单测:ANSI/OSC/`\r`/空行/截断/空输入 |
57
+ | runner exit 归因 | pty-runner.mjs | 集成 | pty-runner.integration.test.mjs 模式:fake child exit 1 + 预置 screen.log → host.json.error 含错误行;exit 0 → error 不变 |
58
+
59
+ ## 验收矩阵
60
+
61
+ | ID | 功能点 | 验收方式 | 具体验证 | 通过标准 |
62
+ |----|--------|----------|----------|----------|
63
+ | A1 | modelRefAvailable 匹配规则 | 自动化(unit) | `node --test test/launch-options.test.mjs`(或既有对应文件) | 真值表全过 |
64
+ | A2 | 失效模型 attach 不再 spawn | 自动化(integration) | `node --test test/host-resolver.test.mjs` / service 测试 | ok:false、launchHost spy 零调用、无 starting claim 残留、error 消息含模型名 |
65
+ | A3 | 有效模型/null 模型回归 | 自动化(integration) | 同上 | 正常 launch(claim → spawn) |
66
+ | A4 | 未注入 availableModels 向后兼容 | 自动化(integration) | 同上 | 跳过校验正常 launch |
67
+ | A5 | adopt 路径失效模型 | 自动化(integration) | 同上 | 落 failed + 不 spawn |
68
+ | A6 | lastVisibleLogLine 提取 | 自动化(unit) | heuristics 测试 | ANSI/截断/空行用例全过 |
69
+ | A7 | runner exit≠0 归因 | 自动化(integration) | runner 测试 | host.json.error 含 screen.log 尾部错误行;exit 0 不写 |
70
+ | A8 | 全量回归 | 自动化(static/build) | `npm test` + `npm run typecheck` | 608+ 全绿 |
71
+ | U1 | 真实失效 view 实测 | 用户实测 | 合并后:view_539a5e9e20 恢复失效 defaultModel=glm/glm-5.3 → attach → 观察 notify 与 diagnostics | notify 明确提示模型失效;diagnostics 不再累积 launch_host;改回有效模型后 attach 成功 |
72
+
73
+ U1 需重启 pi(git 包不热重载),合并后由用户执行并回 issue 记录。
74
+
75
+ ## 风险与降级
76
+
77
+ - availableModels 为空数组时保守放行(不制造新阻塞)——校验仅在能确定"失效"时拦截;
78
+ - 校验失败路径每次 attach 仅一次文件级操作,无进程开销;
79
+ - D2 全 try/catch best-effort,exit 路径行为不变。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -10,13 +10,14 @@
10
10
  import { spawn } from "node:child_process";
11
11
  import { createRequire } from "node:module";
12
12
  import { createServer } from "node:net";
13
- import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
13
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { appendLine, readJson } from "../src/core/atomic.mjs";
17
17
  import { appendDiagnostic } from "../src/core/diagnostics.mjs";
18
18
  import { finalizeHostCrash } from "../src/core/host-crash.mjs";
19
19
  import { ownsEndpoint, shouldYieldRunner } from "../src/core/host-coordination.mjs";
20
+ import { lastVisibleLogLine } from "../src/core/heuristics.mjs";
20
21
  import { acquireOwnedViewLock } from "../src/core/locks.mjs";
21
22
  import * as P from "../src/core/paths.mjs";
22
23
  import { appendBoundedScreenLog, reconcileScreenLog } from "../src/core/screen-log.mjs";
@@ -39,6 +40,48 @@ const HEARTBEAT_MS = 1000;
39
40
  const HOST_ACK_DEDUP_MAX = 1000;
40
41
  /** Max time an owned runner waits to take the per-view host-start lease (issue #70). */
41
42
  const HOST_RUNNER_LOCK_WAIT_MS = 5_000;
43
+ /** How much of the screen.log tail to scan when attributing an abnormal child
44
+ * exit (issue #90). Tail-only: the log can be 100MB+. */
45
+ const EXIT_LOG_TAIL_BYTES = 8_192;
46
+
47
+ /** Tail-read the last `maxBytes` of a file without loading it whole.
48
+ * @param {string} path
49
+ * @param {number} maxBytes
50
+ * @returns {string}
51
+ */
52
+ function readScreenLogTail(path, maxBytes) {
53
+ let fd;
54
+ try {
55
+ fd = openSync(path, "r");
56
+ const size = fstatSync(fd).size;
57
+ const start = Math.max(0, size - maxBytes);
58
+ const length = size - start;
59
+ const buffer = Buffer.allocUnsafe(length);
60
+ readSync(fd, buffer, 0, length, start);
61
+ return buffer.toString("utf8");
62
+ } finally {
63
+ try { if (fd != null) closeSync(fd); } catch { /* best effort */ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Best-effort error attribution for an abnormal child exit (issue #90): the
69
+ * child's failure reason (e.g. "Model X not found") exists only in the raw
70
+ * screen log, so surface its last visible line in host.json.error. Never
71
+ * throws — this runs on the exit path.
72
+ * @param {number} exitCode
73
+ * @param {string} screenLogPath
74
+ * @returns {{} | { error: string }}
75
+ */
76
+ function attributedExitError(exitCode, screenLogPath) {
77
+ if (exitCode === 0) return {};
78
+ try {
79
+ const line = lastVisibleLogLine(readScreenLogTail(screenLogPath, EXIT_LOG_TAIL_BYTES));
80
+ return line ? { error: line } : {};
81
+ } catch {
82
+ return {};
83
+ }
84
+ }
42
85
 
43
86
  function main() {
44
87
  const configPath = process.argv[2];
@@ -214,7 +257,9 @@ function legacyMain(config) {
214
257
  // After a crash the handler already persisted "failed" and broadcast
215
258
  // exit; this callback must not overwrite that state.
216
259
  if (!crashed) {
217
- update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null });
260
+ // Attribute only a NATURAL abnormal exit (issue #90): after a deliberate
261
+ // stop (shutdownStarted) the child was killed by us — no error line.
262
+ update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null, ...(shutdownStarted ? {} : attributedExitError(exitCode, screenLog)) });
218
263
  editorEmpty = null;
219
264
  broadcast({ type: "editor_state", empty: null });
220
265
  broadcast({ type: "exit", exitCode });
@@ -512,6 +557,13 @@ async function ownedMain(config) {
512
557
  readyAt: null,
513
558
  stopRequestedAt: null,
514
559
  stopReason: reason,
560
+ // Attribute abnormal NATURAL child exits only (issue #90): reason
561
+ // "child_exit" with a non-zero code. Stops ("signal") and crashes
562
+ // have their own attribution (stopReason / crash finalize), and a
563
+ // SIGTERM'd healthy child must not capture a junk error line.
564
+ ...(reason === "child_exit" && cur.error == null
565
+ ? attributedExitError(requestedExitCode ?? exitCode ?? 0, screenLog)
566
+ : {}),
515
567
  }));
516
568
  }
517
569
  // Endpoint cleanup: only the exact inode this instance bound.
@@ -42,6 +42,15 @@ export function registerAgentBoardCommand(pi: ExtensionAPI, opts: AgentBoardComm
42
42
  piCommand: opts.piCommand,
43
43
  piArgsPrefix: opts.piArgsPrefix,
44
44
  defaultCwd: ctx.cwd,
45
+ // Stale-defaultModel launch guard (issue #90): live list, undefined
46
+ // when the registry is unavailable so validation conservatively skips.
47
+ availableModels: () => {
48
+ try {
49
+ return ctx.modelRegistry.getAvailable();
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ },
45
54
  });
46
55
 
47
56
  if (!ctx.hasUI) {
@@ -40,6 +40,15 @@ async function handleBgCommand(args: string, ctx: ExtensionCommandContext, opts:
40
40
  piCommand: opts.piCommand,
41
41
  piArgsPrefix: opts.piArgsPrefix,
42
42
  defaultCwd: ctx.cwd,
43
+ // Stale-defaultModel launch guard (issue #90): live list, undefined
44
+ // when the registry is unavailable so validation conservatively skips.
45
+ availableModels: () => {
46
+ try {
47
+ return ctx.modelRegistry.getAvailable();
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ },
43
52
  });
44
53
  const model = modelRef(ctx.model as any);
45
54
  const adopted = service.adoptSession({
@@ -259,6 +259,41 @@ export function truncate(s, n) {
259
259
  return `${stripLoneSurrogates(str.slice(0, end))}…`;
260
260
  }
261
261
 
262
+ const OSC_SEQUENCE_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
263
+ const CSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
264
+ const OTHER_ESCAPE_RE = /\x1b./g;
265
+
266
+ /**
267
+ * Last non-empty visible line of a raw terminal log chunk: strips OSC/CSI/ESC
268
+ * escape sequences, resolves per-line carriage-return overwrites (progress
269
+ * bars / boot spinners), and returns the final remaining line truncated to
270
+ * `maxLen`. Used to attribute a failed child's exit reason from screen.log
271
+ * into host.json (issue #90). Returns null for empty/all-invisible input.
272
+ * @param {string|null|undefined} text
273
+ * @param {number} [maxLen]
274
+ * @returns {string|null}
275
+ */
276
+ export function lastVisibleLogLine(text, maxLen = 200) {
277
+ if (!text) return null;
278
+ const visible = String(text)
279
+ .replace(OSC_SEQUENCE_RE, "")
280
+ .replace(CSI_SEQUENCE_RE, "")
281
+ .replace(OTHER_ESCAPE_RE, "")
282
+ // A \r directly before \n is a line terminator (CRLF), not an overwrite
283
+ // marker — normalize it away so the line content survives.
284
+ .replace(/\r\n/g, "\n");
285
+ const lines = visible.split("\n");
286
+ for (let i = lines.length - 1; i >= 0; i--) {
287
+ // Trailing \r chars are line-terminator junk (PTY ONLCR + the program's own
288
+ // CRLF stack up as \r\r\n) — drop them, then resolve interior \r overwrites.
289
+ const cleaned = lines[i].replace(/\r+$/, "");
290
+ const cr = cleaned.lastIndexOf("\r");
291
+ const line = (cr >= 0 ? cleaned.slice(cr + 1) : cleaned).trim();
292
+ if (line) return truncate(line, maxLen);
293
+ }
294
+ return null;
295
+ }
296
+
262
297
  /**
263
298
  * Compact relative age, e.g. "10s", "2m", "3h", "4d".
264
299
  * @param {number} fromMs
@@ -122,6 +122,29 @@ export function classifyProbeResult(result) {
122
122
  return "starting";
123
123
  }
124
124
 
125
+ /**
126
+ * Whether a legacy (pre-instanceId) host record can be safely finalized as
127
+ * `exited` so the next attach claims a fresh host. Spec §10.1 keeps legacy
128
+ * hosts unrecovered because their identity is unverifiable — but a provably
129
+ * dead runner pid IS a certain identity (a reused pid reads alive, which
130
+ * conservatively lands here as false), and an unreachable endpoint (missing
131
+ * socket file / refused socket) rules out a merely slow host. All four
132
+ * conditions must hold at once (issue #87).
133
+ * @param {{
134
+ * host: HostStatus|null|undefined,
135
+ * hostPid: number|null|undefined,
136
+ * hostPidAlive: boolean,
137
+ * probeClassification: string,
138
+ * }} input
139
+ * @returns {boolean}
140
+ */
141
+ export function canFinalizeLegacyHost({ host, hostPid, hostPidAlive, probeClassification }) {
142
+ if (!host || host.instanceId != null) return false;
143
+ if (host.state !== "starting" && host.state !== "alive") return false;
144
+ if (hostPid == null || hostPidAlive) return false;
145
+ return probeClassification === "missing" || probeClassification === "stale";
146
+ }
147
+
125
148
  /** Host states in which a claim exists and must not be duplicated. */
126
149
  const ACTIVE_STATES = new Set(["starting", "alive", "stopping"]);
127
150
 
@@ -43,6 +43,23 @@ export function canonicalModelRef(model) {
43
43
  return model ? `${model.provider}/${model.id}` : "";
44
44
  }
45
45
 
46
+ /**
47
+ * Whether a stored model reference resolves to a currently-available model.
48
+ * Same rule as the dashboard launch picker: case-insensitive exact "provider/id".
49
+ * A null/empty reference is trivially available (no constraint), and an
50
+ * empty/unavailable model list must not block launches (the caller cannot judge
51
+ * availability, so the conservative answer is allow — issue #90).
52
+ * @param {string|null|undefined} modelRef
53
+ * @param {LaunchModelLike[]|undefined|null} availableModels
54
+ * @returns {boolean}
55
+ */
56
+ export function modelRefAvailable(modelRef, availableModels) {
57
+ const ref = String(modelRef ?? "").trim().toLowerCase();
58
+ if (!ref) return true;
59
+ if (!availableModels || availableModels.length === 0) return true;
60
+ return availableModels.some((model) => `${model.provider}/${model.id}`.toLowerCase() === ref);
61
+ }
62
+
46
63
  /** @param {LaunchModelLike|null|undefined} a @param {LaunchModelLike|null|undefined} b */
47
64
  export function sameModel(a, b) {
48
65
  return Boolean(a && b && a.provider === b.provider && a.id === b.id);
@@ -16,6 +16,34 @@ import { writePid } from "./store.mjs";
16
16
  /** @typedef {import("./types.mjs").TitleConfig} TitleConfig */
17
17
  /** @typedef {import("./types.mjs").AutoStateConfig} AutoStateConfig */
18
18
 
19
+ /**
20
+ * Spawn a fully detached runner child with async spawn failures made harmless.
21
+ *
22
+ * A spawn that fails to start (e.g. a transient ENOENT on the node binary,
23
+ * issue #86) reports asynchronously via the 'error' event; without a listener
24
+ * the EventEmitter rethrows it as an uncaughtException and takes down the
25
+ * whole host Pi process. Callers already handle the failure gracefully via
26
+ * the pid == null branch (state "failed").
27
+ * @param {string} command
28
+ * @param {string[]} args
29
+ * @param {string} cwd
30
+ * @returns {import("node:child_process").ChildProcess}
31
+ */
32
+ function spawnDetached(command, args, cwd) {
33
+ const child = spawn(command, args, {
34
+ cwd,
35
+ detached: true,
36
+ stdio: "ignore",
37
+ env: process.env,
38
+ // Windows: detached children get their own console window unless
39
+ // suppressed (CREATE_NO_WINDOW; no-op on POSIX) — issue #49.
40
+ windowsHide: true,
41
+ });
42
+ child.on("error", () => {});
43
+ child.unref();
44
+ return child;
45
+ }
46
+
19
47
  /**
20
48
  * @param {string} root
21
49
  * @param {RunConfig} config
@@ -28,16 +56,7 @@ export function launchRun(root, config, opts) {
28
56
  atomicWriteJson(configPath, config);
29
57
 
30
58
  const node = opts.node ?? resolveNode();
31
- const child = spawn(node, [opts.runnerScript, configPath], {
32
- cwd: config.cwd,
33
- detached: true,
34
- stdio: "ignore",
35
- env: process.env,
36
- // Windows: detached children get their own console window unless
37
- // suppressed (CREATE_NO_WINDOW; no-op on POSIX) — issue #49.
38
- windowsHide: true,
39
- });
40
- child.unref();
59
+ const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
41
60
 
42
61
  const pid = child.pid ?? null;
43
62
  // Record the *runner/monitor* pid for liveness polling (the worker pid is tracked
@@ -61,14 +80,7 @@ export function launchHost(root, config, opts) {
61
80
  atomicWriteJson(configPath, config);
62
81
 
63
82
  const node = opts.node ?? resolveNode();
64
- const child = spawn(node, [opts.runnerScript, configPath], {
65
- cwd: config.cwd,
66
- detached: true,
67
- stdio: "ignore",
68
- env: process.env,
69
- windowsHide: true,
70
- });
71
- child.unref();
83
+ const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
72
84
 
73
85
  return { pid: child.pid ?? null, configPath };
74
86
  }
@@ -86,14 +98,7 @@ export function launchTitle(root, config, opts) {
86
98
  atomicWriteJson(configPath, config);
87
99
 
88
100
  const node = opts.node ?? resolveNode();
89
- const child = spawn(node, [opts.runnerScript, configPath], {
90
- cwd: config.cwd,
91
- detached: true,
92
- stdio: "ignore",
93
- env: process.env,
94
- windowsHide: true,
95
- });
96
- child.unref();
101
+ const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
97
102
 
98
103
  return { pid: child.pid ?? null, configPath };
99
104
  }
@@ -111,14 +116,7 @@ export function launchAutoState(root, config, opts) {
111
116
  atomicWriteJson(configPath, config);
112
117
 
113
118
  const node = opts.node ?? resolveNode();
114
- const child = spawn(node, [opts.runnerScript, configPath], {
115
- cwd: config.cwd,
116
- detached: true,
117
- stdio: "ignore",
118
- env: process.env,
119
- windowsHide: true,
120
- });
121
- child.unref();
119
+ const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);
122
120
 
123
121
  return { pid: child.pid ?? null, configPath };
124
122
  }
package/src/index.ts CHANGED
@@ -76,8 +76,17 @@ export default function piAgentBoard(pi: ExtensionAPI): void {
76
76
  });
77
77
 
78
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
+ };
79
88
  const serviceFor = (ctx: ExtensionContext) =>
80
- 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) });
81
90
 
82
91
  const updateStatus = (ctx: ExtensionContext) => {
83
92
  try {
@@ -112,7 +121,7 @@ export default function piAgentBoard(pi: ExtensionAPI): void {
112
121
  }
113
122
  updateStatus(ctx);
114
123
  if (event.reason === "startup" && !isHostedChild && pi.getFlag("agent-board") === true && ctx.hasUI) {
115
- 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) });
116
125
  service.reconcile();
117
126
  ctx.ui.setWorkingVisible(false);
118
127
  ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));