@zhuxixi/pi-agent-board 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -2
- package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +30 -0
- package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +68 -0
- package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +125 -0
- package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +56 -0
- package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +96 -0
- package/docs/superpowers/plans/2026-09-09-claimpid-blocks-replace.md +267 -0
- package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +58 -0
- package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +52 -0
- package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +87 -0
- package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +56 -0
- package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +79 -0
- package/docs/superpowers/specs/2026-09-09-claimpid-blocks-replace-design.md +147 -0
- package/package.json +1 -1
- package/runner/pty-runner.mjs +54 -2
- package/src/commands/agent-board.ts +9 -0
- package/src/commands/bg.ts +9 -0
- package/src/core/heuristics.mjs +35 -0
- package/src/core/host-coordination.mjs +32 -7
- package/src/core/launch-options.mjs +17 -0
- package/src/core/launch.mjs +32 -34
- package/src/index.ts +11 -2
- package/src/runtime/service.mjs +109 -4
- package/src/ui/dashboard.ts +41 -1
- package/src/ui/pty-attach.ts +23 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ conventional commits by `scripts/release_helper.mjs`. Entries are
|
|
|
5
5
|
forward-only: they begin with the first release after this file landed —
|
|
6
6
|
for earlier history, see the git log and the pull-request list.
|
|
7
7
|
|
|
8
|
+
## [0.6.2] - 2026-09-09
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
|
|
12
|
+
- **host**: claim role no longer blocks terminal host replacement (#99) (#100)
|
|
13
|
+
|
|
14
|
+
[0.6.2]: https://github.com/zhuxixi/pi-agent-board/compare/v0.6.1...v0.6.2
|
|
15
|
+
|
|
16
|
+
## [0.6.1] - 2026-09-08
|
|
17
|
+
|
|
18
|
+
### Fixes
|
|
19
|
+
|
|
20
|
+
- add an unconditional Ctrl+Left detach chord to the attach surface (issue #89) (#97)
|
|
21
|
+
- force full-clear repaint on dashboard mount and content shrink (issue #88) (#96)
|
|
22
|
+
- fail host launch fast on a provably stale defaultModel + exit attribution (issue #90) (#94)
|
|
23
|
+
- finalize provably-dead legacy hosts in attach resolver and self-heal (issue #87) (#93)
|
|
24
|
+
- swallow async spawn errors in detached runner launches (issue #86) (#92)
|
|
25
|
+
|
|
26
|
+
[0.6.1]: https://github.com/zhuxixi/pi-agent-board/compare/v0.6.0...v0.6.1
|
|
27
|
+
|
|
8
28
|
## [0.6.0] - 2026-09-07
|
|
9
29
|
|
|
10
30
|
### Features
|
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ From the board:
|
|
|
93
93
|
- In Peek, press `r` to reply without attaching.
|
|
94
94
|
- Press `v` for a read-only transcript, or `e` for evidence and diagnostics.
|
|
95
95
|
- Press `Enter`, `Right`, or `>` to attach to the real Pi session.
|
|
96
|
-
- In PTY attach mode, press `Left` on an empty child input line to return to the board. `Ctrl+]` is not a detach key — it is passed through to the child Pi editor. When the host is disconnected, `Left` always exits.
|
|
96
|
+
- In PTY attach mode, press `Left` on an empty child input line to return to the board, or `Ctrl+Left` at any time (even mid-draft). `Ctrl+]` is not a detach key — it is passed through to the child Pi editor. When the host is disconnected, `Left` always exits.
|
|
97
97
|
|
|
98
98
|
## Dashboard Workflow
|
|
99
99
|
|
|
@@ -192,7 +192,7 @@ The `e` view shows durable session evidence, including changed files, commands a
|
|
|
192
192
|
|
|
193
193
|
### PTY attach
|
|
194
194
|
|
|
195
|
-
PTY attach opens the real interactive Pi session. On an empty child input line, use `Left` to detach and return to the board; while you are editing text, `Left` is forwarded to the Pi editor, and a disconnected host can always be exited with `Left`. `Ctrl+]` is not a detach key — it is passed through to the child Pi editor. While attached, `PageUp`, `PageDown`, `Home`, `End`, and the mouse wheel scroll local scrollback. Mouse drag or double-click selects and copies text, clicks open detected links, and middle-click paste is available on systems with the required X11 tooling.
|
|
195
|
+
PTY attach opens the real interactive Pi session. On an empty child input line, use `Left` to detach and return to the board; while you are editing text, `Left` is forwarded to the Pi editor, `Ctrl+Left` detaches regardless of editor state, and a disconnected host can always be exited with `Left`. `Ctrl+]` is not a detach key — it is passed through to the child Pi editor. While attached, `PageUp`, `PageDown`, `Home`, `End`, and the mouse wheel scroll local scrollback. Mouse drag or double-click selects and copies text, clicks open detected links, and middle-click paste is available on systems with the required X11 tooling.
|
|
196
196
|
|
|
197
197
|
The attach surface can forward terminal clipboard and image/file passthrough sequences. These behaviors can be disabled individually in [Configuration](#configuration). Cold hosts may briefly show a loading/reconnect surface while their PTY becomes ready.
|
|
198
198
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# issue #89 plan:Ctrl+← detach 和弦实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:Ctrl+← 分支 + header 文案(A1, A2, A4)
|
|
8
|
+
|
|
9
|
+
文件:`src/ui/pty-attach.ts`
|
|
10
|
+
|
|
11
|
+
1. `handleInput` 在 `Key.left` 分支(~L247)之前插入 Ctrl+← 分支(spec D1 代码原样,含注释)。确认 `Key.ctrl` 已从 pi-tui import(文件顶部现有 `Key` import——matchesKey/Key 都在用,无需新 import)。
|
|
12
|
+
2. header 两处文案(~L282 `← detach`、~L298 `← to detach`)→ `←/Ctrl+← detach` / `←/Ctrl+← to detach`。
|
|
13
|
+
|
|
14
|
+
### T2:测试扩建(A1-A4)
|
|
15
|
+
|
|
16
|
+
文件:`test-support/detach-gate-smoke.ts` + `test/pty-attach-detach-gate.test.mjs`
|
|
17
|
+
|
|
18
|
+
1. harness 加场景(照现有场景范式:makeAttach + writeToTerm + handleInput 注入):
|
|
19
|
+
- **ctrlLeftDetachesOnDraft**:造一个 editor_state=draft(或写非空内容行进 buffer——看现有 `leftEditorStateBlocksDetachOnDraft` 场景怎么造 draft)→ `handleInput("\x1b[1;5D")` → didDetach() === true;
|
|
20
|
+
- **ctrlLeftDetachesOnEmptyInput**:空编辑器场景(照现有 C 场景)→ 同序列 → didDetach() === true;
|
|
21
|
+
- **headerMentionsCtrlLeft**:`attach.render(80)` 输出行里含 "Ctrl+←"(header 在 render 输出组装;注意 attaching 状态——renderLoading 的 L298 在 loading 时显示,L282 在正常帧。两个都改后任一断言即可,取正常帧:先 writeToTerm 一点输出让组件脱离 loading)。
|
|
22
|
+
2. 测试文件加 3 条断言(照现有断言风格)。
|
|
23
|
+
|
|
24
|
+
### T3:全量回归(A5)
|
|
25
|
+
|
|
26
|
+
`npm test`(基线 618)+ `npm run typecheck`。
|
|
27
|
+
|
|
28
|
+
## 验收对账
|
|
29
|
+
- A1/A2/A4 → T2 · A3 → 现有断言回归 · A5 → T3
|
|
30
|
+
- U1(用户实测,合并后):attach 活跃 session 输入文字后 Ctrl+← 退出。
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# issue #88 plan:dashboard 花屏修复实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:dashboard.ts render 包装(A1, A2, A3)
|
|
8
|
+
|
|
9
|
+
文件:`src/ui/dashboard.ts`(唯一运行时代码改动文件,.ts 层满足 jiti 可重载约束)
|
|
10
|
+
|
|
11
|
+
1. 组件新增字段(class 字段区,~L126-147 附近):
|
|
12
|
+
```ts
|
|
13
|
+
/** First frame after mount must clear the screen: pi-tui's first render
|
|
14
|
+
* "assumes clean screen" (fullRender(false)) and overlays never get
|
|
15
|
+
* clearOnShrink — crash output / dirty bottoms would persist (issue #88). */
|
|
16
|
+
private needsFullClear = true;
|
|
17
|
+
/** Content line count BEFORE fitToHeight padding (padding always fills the
|
|
18
|
+
* terminal height, so padded counts never shrink — shrink must be detected
|
|
19
|
+
* on content lines). */
|
|
20
|
+
private lastContentLineCount: number | null = null;
|
|
21
|
+
private frameContentLineCount = 0;
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
2. `fitToHeight` 开头记录 pad 前行数:`this.frameContentLineCount = lines.length;`
|
|
25
|
+
|
|
26
|
+
3. 现有 `render(width: number): string[]`(L1158 起)整体改名为 `private renderLines(width: number): string[]`,新增包装:
|
|
27
|
+
```ts
|
|
28
|
+
render(width: number): string[] {
|
|
29
|
+
const lines = this.renderLines(width);
|
|
30
|
+
// Self-heal frames (issue #88): pi-tui disables clearOnShrink under overlays,
|
|
31
|
+
// so a content shrink would leave stale rows forever. Force a full clear on
|
|
32
|
+
// the first frame and on any content-line shrink. requestRender(true) is
|
|
33
|
+
// nextTick-async — safe to call from inside render.
|
|
34
|
+
if (this.needsFullClear || (this.lastContentLineCount != null && this.frameContentLineCount < this.lastContentLineCount)) {
|
|
35
|
+
this.needsFullClear = false;
|
|
36
|
+
this.tui.requestRender(true);
|
|
37
|
+
}
|
|
38
|
+
this.lastContentLineCount = this.frameContentLineCount;
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
注意点:
|
|
44
|
+
- `renderLines` 保持原签名与所有 return 分支不变(仅改名);
|
|
45
|
+
- `this.tui.requestRender` 在 Component 的 TUI 类型上存在(pi-tui 导出类型含它;若类型缺 force 参数签名,用 `(this.tui as any).requestRender(true)` 并在注释说明,或查 TUI 类型定义确认——typecheck 必须过);
|
|
46
|
+
- 不改 `src/core/dashboard-render.mjs`(requestDashboardRender 保持差分语义,现有测试不动)。
|
|
47
|
+
|
|
48
|
+
### T2:冒烟测试(A1, A2)
|
|
49
|
+
|
|
50
|
+
1. 新增 `test-support/dashboard-shrink-render.ts`(范式照搬 dashboard-refs-render.ts:真实 root + createView + service + fake tui/theme/keybindings):
|
|
51
|
+
- fake tui 的 `requestRender` 记录调用参数(spy),`terminal.rows` 固定 40;
|
|
52
|
+
- 造 3 个 view → 第 1 次 render(160)(首帧);
|
|
53
|
+
- 第 2 次 render(160)(同数据,无变化);
|
|
54
|
+
- 删掉 1 个 view(service 的删除 API——先看 refs-render 或 service 里 deleteView/removeView 叫什么;若无删除 API,用第三个 view 的 cwd folder 折叠/直接操作 store 删目录亦可,目标是让内容行数减少);
|
|
55
|
+
- 第 3 次 render(160)(内容行数减少);
|
|
56
|
+
- 输出 JSON:`{ calls: [...] , ok: true }`(requestRender 的参数序列)。
|
|
57
|
+
2. `test/dashboard-render.test.mjs` 新增用例(execFileSync + --experimental-transform-types,照抄 refs 用例)断言:
|
|
58
|
+
- 首帧后 calls 含 `[true]`(D1);
|
|
59
|
+
- 第二帧(无变化)无新增 `[true]`;
|
|
60
|
+
- 第三帧(收缩)后再次出现 `[true]`(D2)。
|
|
61
|
+
|
|
62
|
+
### T3:全量回归(A4)
|
|
63
|
+
|
|
64
|
+
`npm test`(基线 617)+ `npm run typecheck`。
|
|
65
|
+
|
|
66
|
+
## 验收对账
|
|
67
|
+
- A1/A2 → T2 断言 · A3 → diff 审查(运行时改动仅 dashboard.ts)· A4 → T3
|
|
68
|
+
- U1(用户实测,合并后):密集增删 session 观察无残影。
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# issue #87 plan:legacy 死 host 安全回收实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:纯决策函数 `canFinalizeLegacyHost`(A1)
|
|
8
|
+
|
|
9
|
+
**文件**:`src/core/host-coordination.mjs`(新增导出,放 `classifyProbeResult` 附近)
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
/**
|
|
13
|
+
* Whether a legacy (pre-instanceId) host record can be safely finalized as
|
|
14
|
+
* exited: the runner pid is provably dead (a reused pid reads alive →
|
|
15
|
+
* conservative false) AND the endpoint is unreachable. Satisfies spec §10.1's
|
|
16
|
+
* "never recovered" conservatism — recovery only fires when identity is
|
|
17
|
+
* certain (issue #87).
|
|
18
|
+
* @param {{ host: HostStatus|null, hostPid: number|null|undefined, hostPidAlive: boolean, probeClassification: string }} input
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function canFinalizeLegacyHost({ host, hostPid, hostPidAlive, probeClassification }) {
|
|
22
|
+
if (!host || host.instanceId != null) return false;
|
|
23
|
+
if (host.state !== "starting" && host.state !== "alive") return false;
|
|
24
|
+
if (hostPid == null || hostPidAlive) return false;
|
|
25
|
+
return probeClassification === "missing" || probeClassification === "stale";
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**测试**:`test/host-coordination.test.mjs` 新增真值表用例(在文件既有风格下):
|
|
30
|
+
- 全条件满足(starting/alive × missing/stale 四组合)→ true
|
|
31
|
+
- instanceId 非 null → false
|
|
32
|
+
- state ∈ {exited, failed, stopping} → false
|
|
33
|
+
- hostPid null / hostPidAlive true → false
|
|
34
|
+
- classification ∈ {ready, starting, occupied, unknown} → false
|
|
35
|
+
- host null → false
|
|
36
|
+
|
|
37
|
+
### T2:resolver 两处改造(A2-A5)
|
|
38
|
+
|
|
39
|
+
**文件**:`src/runtime/service.mjs`
|
|
40
|
+
|
|
41
|
+
1. **import 更新**:`readHostPid` 加入 store.mjs import 列表;`canFinalizeLegacyHost` 加入 host-coordination.mjs import(L28 现有 `canReplaceHost` 同处)。
|
|
42
|
+
|
|
43
|
+
2. **新增模块内 helper**(resolver 函数前,模块作用域):
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
/**
|
|
47
|
+
* Finalize a provably-dead legacy host as exited so the resolver loop's next
|
|
48
|
+
* iteration sees hostActive=false and claims a fresh new-protocol host
|
|
49
|
+
* (issue #87). Legacy records have no instanceId — no concurrent owner exists,
|
|
50
|
+
* so an unfenced writeHost is safe; the claim path is serialized by the
|
|
51
|
+
* host-start lease.
|
|
52
|
+
* @returns {boolean} true when finalized (caller should `continue` the loop).
|
|
53
|
+
*/
|
|
54
|
+
function finalizeDeadLegacyHost(root, viewId, host, probeClassification) {
|
|
55
|
+
const hostPid = Object.hasOwn(host, "runnerPid") ? host.runnerPid : readHostPid(root, viewId);
|
|
56
|
+
if (!canFinalizeLegacyHost({ host, hostPid, hostPidAlive: isAlive(hostPid), probeClassification })) return false;
|
|
57
|
+
writeHost(root, { ...host, state: "exited", endedAt: Date.now(), lastSeenAt: Date.now(), error: "legacy host finalized: runner pid dead (issue #87)" });
|
|
58
|
+
try {
|
|
59
|
+
appendDiagnostic(root, viewId, {
|
|
60
|
+
source: "service", level: "info", code: "legacy_host_finalized",
|
|
61
|
+
message: `Finalized stale legacy host (pid ${hostPid} dead, probe ${probeClassification}) — next attach claims a fresh host`,
|
|
62
|
+
details: { hostPid, probeClassification, previousState: host.state },
|
|
63
|
+
});
|
|
64
|
+
} catch { /* best effort */ }
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
注意:`writeHost` 签名是 `(root, host)`(host 内含 viewId),与 updateOwnedHost 不同。appendDiagnostic 签名参照文件内现有调用(`appendDiagnostic(root, viewId, {...})`,如 recoverHost 附近用法——实现时以现有调用为准)。
|
|
70
|
+
|
|
71
|
+
3. **resolver legacy alive 分支**(现 L963 附近):
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
} else if (legacy) {
|
|
75
|
+
// Legacy host unreachable. spec §10.1 says never recover — unless the
|
|
76
|
+
// runner pid is provably dead, in which case finalize and self-heal
|
|
77
|
+
// (issue #87).
|
|
78
|
+
if (finalizeDeadLegacyHost(root, viewId, host, probe.classification)) {
|
|
79
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
return pending(sessionFile, "legacy host unreachable — manual restart needed");
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
4. **resolver starting-legacy 分支**(现 L953-957 附近,`withinGrace || legacy` 等待分支):
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
if (withinGrace || legacy) {
|
|
90
|
+
// A legacy starting host whose runner is provably dead would wait out
|
|
91
|
+
// the grace window forever (withinGrace is always true for legacy) —
|
|
92
|
+
// finalize it now instead (issue #87).
|
|
93
|
+
if (legacy && finalizeDeadLegacyHost(root, viewId, host, probe.classification)) {
|
|
94
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Normal cold start (or a legacy starting host — legacy is never recovered,
|
|
98
|
+
// spec §10.1): wait out the grace window.
|
|
99
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
(此分支 probe 已在上方 L939 执行,classification 可得。)
|
|
105
|
+
|
|
106
|
+
**测试**:`test/host-resolver.test.mjs` 新增 4 个测试(fixtures 复用 hostRecord/scriptProbe/instantSleep/resolverService;launchHost override 模仿现有 adoption 测试的成功 spawn 模式):
|
|
107
|
+
|
|
108
|
+
- **A2 自愈闭环**:legacy alive host(hostRecord:instanceId:null、state:"alive"、**无 runnerPid 属性**——用 delete 或构造时排除)+ host-pid.json 写死 pid(writeHostPid 或 readHostPid 对应写法,参照 store.mjs 导出;用不可能存活的 pid 如 999999——注意须确认该 pid 在测试机不存在,现有 fixture aliveHost 已用 999999 表达"死 pid",同法)+ scriptProbe(["missing"]) + launchHost override 成功 spawn → 断言:resolveAttachTarget 最终返回 kind:"pty"(新 host);readHost 状态变迁——最终 host 是新 instance(launchHost 写入 starting);diagnostics 含 legacy_host_finalized。
|
|
109
|
+
- 实现细节:claim/launch 后 readHost 读到的是新 host 记录,验证 finalize 发生要靠 diagnostics 断言 + resolve 结果非 pending。
|
|
110
|
+
- **A3 保守分支**:同上但 host-pid.json 写 `process.pid`(活 pid)→ 断言返回 pending(reason 含 "legacy host unreachable")且 host.json 未被改写(state 仍 alive)。
|
|
111
|
+
- **A4 starting-legacy 回收**:legacy starting host(claimAt 久远超 grace,pid 死)+ probe missing + launchHost spawn 成功 → 断言不自 deadline timeout、最终 kind:"pty"、diagnostics 含 legacy_host_finalized。
|
|
112
|
+
- **A5 unknown 不回收**:pid 死 + scriptProbe(["unknown"]) → 断言 pending 且不写盘。
|
|
113
|
+
|
|
114
|
+
⚠️ fixture 要点:hostRecord 默认写 `runnerPid: null`——这会让 `Object.hasOwn(host, "runnerPid")` 为 true,fallback 不到 host-pid.json。legacy fixture 必须构造**没有 runnerPid 属性**的记录(hostRecord 加 `legacy: true` 选项删除 instanceId/runnerPid 等字段,或新写 `legacyHostRecord` helper),并配 `writeHostPid(root, viewId, pid)` 写镜像。
|
|
115
|
+
|
|
116
|
+
### T3:全量回归(A6)
|
|
117
|
+
|
|
118
|
+
`npm test` + `npm run typecheck`(基线 602)。
|
|
119
|
+
|
|
120
|
+
## 验收对账
|
|
121
|
+
|
|
122
|
+
- A1 → T1 真值表单测
|
|
123
|
+
- A2/A3/A4/A5 → T2 四个集成测试
|
|
124
|
+
- A6 → T3
|
|
125
|
+
- U1(用户实测,合并前):本机 6 个真实卡死 legacy view attach 验证——主 session 合并前提示用户执行,或合并后实测反馈。
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# issue #86 plan:spawn 异步 error 兜底实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:launch.mjs spawnDetached helper + 4 处收敛(A1, A2)
|
|
8
|
+
|
|
9
|
+
**改动**(`src/core/launch.mjs`):
|
|
10
|
+
1. 文件底部(或 import 后)新增模块内 helper:
|
|
11
|
+
```js
|
|
12
|
+
function spawnDetached(command, args, cwd) {
|
|
13
|
+
const child = spawn(command, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
detached: true,
|
|
16
|
+
stdio: "ignore",
|
|
17
|
+
env: process.env,
|
|
18
|
+
// Windows: detached children get their own console window unless
|
|
19
|
+
// suppressed (CREATE_NO_WINDOW; no-op on POSIX) — issue #49.
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
});
|
|
22
|
+
// Swallow async spawn failures (e.g. transient ENOENT on the node binary):
|
|
23
|
+
// without an 'error' listener the EventEmitter rethrows as uncaughtException
|
|
24
|
+
// and takes down the whole host Pi process (issue #86). Callers already
|
|
25
|
+
// record state "failed" via the pid == null branch.
|
|
26
|
+
child.on("error", () => {});
|
|
27
|
+
child.unref();
|
|
28
|
+
return child;
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
2. `launchRun` / `launchHost` / `launchTitle` / `launchAutoState` 四处的 `spawn(...)` + `child.unref()` 收敛为 `const child = spawnDetached(node, [opts.runnerScript, configPath], config.cwd);`,返回值逻辑(`child.pid ?? null`、writePid 等)不变。
|
|
32
|
+
|
|
33
|
+
**测试**(`test/launch.test.mjs`,复用现有注入模式):
|
|
34
|
+
- 新增 1 个测试:注入 `node: "/nonexistent/node-ENOENT-test"`,依次调用 4 个入口,断言:各自正常返回 `{ pid: null }`;`process.on("uncaughtException")` spy 不被触发;等待 ~300ms 让异步 error 有机会冒泡(进程崩 = 测试天然失败)。
|
|
35
|
+
- 现有 4 个测试不动(A2 回归)。
|
|
36
|
+
|
|
37
|
+
### T2:pty-attach.ts openExternalTarget error 兜底(A3)
|
|
38
|
+
|
|
39
|
+
**改动**(`src/ui/pty-attach.ts` L1150-1167):三分支统一为:
|
|
40
|
+
```ts
|
|
41
|
+
const child = spawn("xdg-open", [sanitized], { detached: true, stdio: "ignore" });
|
|
42
|
+
child.on("error", () => {});
|
|
43
|
+
child.unref();
|
|
44
|
+
```
|
|
45
|
+
(darwin `open`、win32 `cmd /c start` 同理)。参照 L847-848 xclip 既有模式。
|
|
46
|
+
|
|
47
|
+
### T3:全量回归(A4)
|
|
48
|
+
|
|
49
|
+
`npm test` + `npm run typecheck`,568+ 全绿。
|
|
50
|
+
|
|
51
|
+
## 验收对账
|
|
52
|
+
|
|
53
|
+
- A1 → T1 新测试
|
|
54
|
+
- A2 → T1 既有测试回归
|
|
55
|
+
- A3 → T2 代码审查 + `rg 'child.on\("error"' src/ui/pty-attach.ts` 断言三分支(A3 static)
|
|
56
|
+
- A4 → T3
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# issue #90 plan:defaultModel 失效防护实现
|
|
2
|
+
|
|
3
|
+
日期:2026-09-08 · spec:docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md
|
|
4
|
+
|
|
5
|
+
## 任务拆解
|
|
6
|
+
|
|
7
|
+
### T1:core 匹配函数 + service 注入(A1, A4)
|
|
8
|
+
|
|
9
|
+
1. `src/core/launch-options.mjs` 新增导出(spec D1.1 签名原样):
|
|
10
|
+
```js
|
|
11
|
+
export function modelRefAvailable(modelRef, availableModels) {
|
|
12
|
+
const ref = String(modelRef ?? "").trim().toLowerCase();
|
|
13
|
+
if (!ref) return true; // no constraint
|
|
14
|
+
if (!availableModels || availableModels.length === 0) return true; // can't judge → allow
|
|
15
|
+
return availableModels.some((m) => `${m.provider}/${m.id}`.toLowerCase() === ref);
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
2. 单测:若已有 `test/launch-options*.test.mjs` 则加入,否则新建。真值表:精确匹配(大小写混合)→ true;不匹配 → false;null/空串 → true;availableModels undefined/[] → true;部分匹配("glm-5.3" 不配 provider)→ false。
|
|
19
|
+
3. `src/ui/dashboard.ts` L1738 `findLaunchModelByRef` 改为内部调用 core 的匹配逻辑(保持返回 LaunchModel 对象:find 复用 `modelRefAvailable` 不可行——它返回 boolean;改为直接复用 core 里新增的共享比较,或最简单:dashboard 本地函数改为 `models.find(m => modelRefAvailable(ref, [m])) ?? null`。选最简单且单一事实源的写法)。
|
|
20
|
+
- 注:若这步让 diff 变脏(import 路径等),可以跳过,dashboard 本地函数保持——在 plan 偏差里说明即可。优先级低。
|
|
21
|
+
|
|
22
|
+
### T2:service 校验接线(A2, A3, A5)
|
|
23
|
+
|
|
24
|
+
1. `createService` opts 新增:`availableModels`(`() => Array<{provider:string,id:string}> | undefined`;默认 undefined → 跳过校验)。JSDoc typedef 更新(文件内 createService opts 注释处)。
|
|
25
|
+
2. service.mjs 模块内新增 helper:
|
|
26
|
+
```js
|
|
27
|
+
/** @returns {string|null} error message when the view's defaultModel is provably unavailable. */
|
|
28
|
+
function validateViewModelMeta(meta) {
|
|
29
|
+
const model = meta.defaultModel ?? null;
|
|
30
|
+
if (!model || !optsAvailableModels) return null;
|
|
31
|
+
const list = optsAvailableModels() ?? undefined;
|
|
32
|
+
if (modelRefAvailable(model, list)) return null;
|
|
33
|
+
return `Model "${model}" configured for this session is no longer available — update the view's model (or clear defaultModel) and retry attach.`;
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
(注意 availableModels() 调用本身 try/catch → undefined。)
|
|
37
|
+
3. `startHostUnderLease`(L205):在 canReplaceHost 检查之后、instanceId 生成之前插入:
|
|
38
|
+
```js
|
|
39
|
+
const modelError = validateViewModelMeta(meta);
|
|
40
|
+
if (modelError) return { ok: false, error: modelError };
|
|
41
|
+
```
|
|
42
|
+
(校验失败不留 claim、不 spawn。)
|
|
43
|
+
4. `adoptClaimedHost`(L775 起):config 构建前插入校验;失败时:
|
|
44
|
+
```js
|
|
45
|
+
updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: modelError, claimPid: null, claimIdentity: null }));
|
|
46
|
+
return { ok: true, pending: true, socketPath: null, instanceId };
|
|
47
|
+
```
|
|
48
|
+
(对齐该函数既有 spawnError 失败处理模式。)
|
|
49
|
+
5. `src/index.ts` serviceFor 与 `src/commands/agent-board.ts` flag 路径注入:
|
|
50
|
+
```ts
|
|
51
|
+
availableModels: () => { try { return ctx.modelRegistry.getAvailable(); } catch { return undefined; } },
|
|
52
|
+
```
|
|
53
|
+
(agent-board.ts flag 路径 L115 的 createService 调用同款注入;dashboard 路径 L95 已有 availableModels 变量但那是 UI deps,service 注入仍需单独传。)
|
|
54
|
+
6. 集成测试(`test/host-resolver.test.mjs` 或 `test/service.test.mjs`,看 ensure 测试在哪更顺):
|
|
55
|
+
- A2:view meta defaultModel="glm/glm-5.3",注入 availableModels: () => [{provider:"zai-coding-cn",id:"glm-5.3"}],launchHost spy → ensureHost("v1") 断言 ok:false、error 含模型名、spy 零调用、readHost 无 starting 残留;
|
|
56
|
+
- A3 对照:availableModels 含 glm/glm-5.3 → 正常 claim+spawn(断言 started/started pending 路径与现状一致);
|
|
57
|
+
- A4:不注入 availableModels → 跳过校验正常 launch;
|
|
58
|
+
- A5:废弃 starting claim(adopt 路径,参照现有 adoption 测试 fixture)+ 失效模型 → 断言落 failed、不 spawn。
|
|
59
|
+
|
|
60
|
+
### T3:runner exit 归因(A6, A7)
|
|
61
|
+
|
|
62
|
+
1. `src/core/heuristics.mjs` 新增导出:
|
|
63
|
+
```js
|
|
64
|
+
/**
|
|
65
|
+
* Last non-empty visible line of a raw terminal log chunk (ANSI/OSC stripped,
|
|
66
|
+
* per-line carriage-return resolved). @returns {string|null}
|
|
67
|
+
*/
|
|
68
|
+
export function lastVisibleLogLine(text, maxLen = 200)
|
|
69
|
+
```
|
|
70
|
+
实现:strip `/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g`(OSC)→ `/\x1b\[[0-9;?]*[ -/]*[@-~]/g`(CSI)→ 其他 `\x1b.` 单字符转义 → 按 `\n` 切分 → 每行取最后一个 `\r` 之后段 → trim → 过滤空 → 取最后一行 → truncate(maxLen)(复用同文件 truncate)。
|
|
71
|
+
2. 单测(heuristics 测试文件):ANSI 颜色、OSC 链接、`\r` 覆盖行、空行跳过、截断、空输入 → null。
|
|
72
|
+
3. `runner/pty-runner.mjs` child exit 回调(L213-223 区域):
|
|
73
|
+
```js
|
|
74
|
+
if (!crashed) {
|
|
75
|
+
let exitError = null;
|
|
76
|
+
if (exitCode !== 0) {
|
|
77
|
+
try {
|
|
78
|
+
const tail = readScreenLogTail(screenLog, 8192); // openSync/readSync seek 尾部
|
|
79
|
+
exitError = lastVisibleLogLine(tail);
|
|
80
|
+
} catch { /* best effort */ }
|
|
81
|
+
}
|
|
82
|
+
update({ state: "exited", endedAt: Date.now(), exitCode, childPid: null, ...(exitError ? { error: exitError } : {}) });
|
|
83
|
+
...
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
(screenLog 变量在 L62 作用域内可见;确认该回调能访问到。readScreenLogTail 写成 runner 内私有小函数。)
|
|
87
|
+
4. 集成测试(test/pty-runner.integration.test.mjs):用 test-support/ 下 fake pi 模式(看 fake-slow-start-pi.mjs;若无"立即 exit 1 + 输出错误"的 fake 就新增一个 `fake-failing-pi.mjs`:打印 `Error: Model "glm/glm-5.3" not found.` 后 exit 1)→ spawn runner → 等 exit → 断言 readHost 的 error 含 "Model" / "not found";对照 exit 0(现有 fake)→ error 字段保持 null。注意 hasNodePty skip 守卫参照现有测试。
|
|
88
|
+
|
|
89
|
+
### T4:全量回归(A8)
|
|
90
|
+
|
|
91
|
+
`npm test` + `npm run typecheck`(基线 608)。
|
|
92
|
+
|
|
93
|
+
## 验收对账
|
|
94
|
+
|
|
95
|
+
- A1 → T1.2 · A2/A3/A4/A5 → T2.6 · A6 → T3.2 · A7 → T3.4 · A8 → T4
|
|
96
|
+
- U1(用户实测,合并后):view_539a5e9e20 恢复失效 defaultModel → attach 看 notify + diagnostics 不再累积;改回有效模型 attach 成功。
|