pi-async 0.0.0-stage → 0.2.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ 本文件记录用户可见变更。格式参考 Keep a Changelog,版本遵循 SemVer。
4
+
5
+ ## 0.2.0 — 2026-10-16
6
+
7
+ 自 0.1.0 起的 16 个提交,主体是 Wave1/Wave2 缺陷修复,附带少量新能力与若干
8
+ **用户可见的行为变更**。升级前请看「Breaking / 行为变更」一节。
9
+
10
+ ### Breaking / 行为变更
11
+
12
+ - **`async_status` 默认不再返回历史任务**(#22)。0.1.0 默认 `includeHistory !== false`
13
+ (含历史),现在默认**仅活跃**(running/pending);需要历史要显式传
14
+ `includeHistory: true`,且最多返回最近 50 条。不传参的调用方会看到列表变短。
15
+ - **`output_matches` 的正则现在以 `m` 编译**(REV-10)。`^` / `$` 从「整段锚点」变成
16
+ 「行锚点」,多行输出下匹配结果可能变化。
17
+ - **per-task activity id 形状变更**:`pi-async/<taskId>` → `plugin/pi-async/<taskId>`
18
+ (REV-8)。OSC 7501 / legion viz 侧可见。
19
+ - **`async_logs` 输出文本清洗范围扩大**:从 `stripAnsi` 改为同时剥离裸 C0 控制字符,
20
+ 并用 `truncateUtf8Bytes` 替代按字节 `subarray` 截断 —— 含 BEL/BS/FF 或多字节字符时
21
+ 返回文本与 0.1.0 不同。
22
+ - **新增校验会拒绝原先被接受的调用**:
23
+ - `json_path_exists.path` 必须以 `$` 开头(新增 `pattern: "^\\$"`)
24
+ - `async_run` 在非 sync 模式传 `timeoutMs`、非 delayed 模式传 `delayMs` 会被拒(#42/#45)
25
+ - `output_matches` 拒绝嵌套量词(ReDoS 防护),匹配窗口限制 64 KiB
26
+
27
+ ### Added
28
+
29
+ - 新增环境变量 `PI_ASYNC_RETENTION_MS`(默认 7 天)与 `PI_ASYNC_RETENTION_COUNT`
30
+ (默认 100),用于按「时间 ∪ 数量」并集保留终态任务产物(#25)。
31
+ - `async_kill` 返回新增 `killKind`、`alreadyTerminal`、`warning`;`isError` 改为条件值(#33/#6)。
32
+ - 通知 XML 新增 `kill-kind` / `failure-kind` / `summary-truncated` 属性(#37)。
33
+ - `async_wait` 结果项新增可选 `killKind` / `failureKind`;`WaitOutcome` 增加混合超时态。
34
+ - 内部新增 `src/core/duration.ts`、`src/tui/width.ts`、`src/core/env.ts` 收敛重复实现。
35
+
36
+ ### Fixed
37
+
38
+ - TUI:`displayWidth` / `truncateToWidth` 按显示单元截断(#7);Esc 层级、elapsed、
39
+ pending、footer、border、theme.fg 修复(Wave1);`PI_ASYNC_DOCK_SHORTCUT=off`
40
+ 现在真正生效(#30)。
41
+ - 核心:watch ReDoS 与匹配窗口、reload 前代 registry 关停、UTF-8 截断、C0/XML 非法字符
42
+ (Wave2);sidecar/zombie 进程组清理、`wait` 消费(Wave1);空 env 不再被解析成 0(#21)。
43
+ - 通知:killKind / failureKind / summaryTruncated 接线补全(#37)。
44
+ - 仓库:移除误提交的 62MB `docs/core` ELF dump(#50),`.gitignore` 收紧为 `/core`
45
+ 以免误伤 `src/core/`。
46
+
47
+ ### 未变更
48
+
49
+ - 5 个工具(`async_run` / `async_wait` / `async_logs` / `async_status` / `async_kill`)
50
+ 的注册面与参数名集合不变;没有删除任何工具、参数或环境变量。
51
+
52
+ ## 0.1.0 — 2026-10-15
53
+
54
+ 首个发布版本。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-async contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # pi-async
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pi extension for running shell commands asynchronously with bounded output capture, completion notifications, and a task panel.
4
+
5
+ ## Install
6
+
7
+ Install the package from a local path with Pi's package manager:
8
+
9
+ ```text
10
+ pi install /path/to/pi-async
11
+ ```
12
+
13
+ For development, load the extension directly (for example from this checkout):
14
+
15
+ ```sh
16
+ pi -e ./extensions/index.ts
17
+ ```
18
+
19
+ The extension uses the Pi host's project working directory to store task metadata and captured output in `.pi/async/<session-id>-<pid>/`.
20
+
21
+ ## Tools and commands
22
+
23
+ The extension registers five tools:
24
+
25
+ | Tool | Purpose |
26
+ |---|---|
27
+ | `async_run` | Start a background, delayed, or synchronous shell command. |
28
+ | `async_wait` | Wait for task completion or return a `notReady` result on timeout. |
29
+ | `async_logs` | Read bounded, ANSI-stripped captured output. |
30
+ | `async_status` | List tasks and their current state. |
31
+ | `async_kill` | Terminate a task and its process group. |
32
+
33
+ | Command / key | Action |
34
+ |---|---|
35
+ | `/async` | Open the task panel. |
36
+ | `/async-clear` | Clear completed-task history badges. |
37
+ | `Ctrl+Alt+A` | Open the task panel (when the host allows the shortcut; `/async` remains available as fallback). |
38
+ | `↑` / `↓` / `j` / `k` | Select tasks; in task details, scroll output. |
39
+ | `Enter` | Open selected task details. |
40
+ | `h` | Toggle history in the task list. |
41
+ | `k` | Kill a running task (press again to confirm). |
42
+ | `R` | Rerun the selected task command. |
43
+ | `c` | Copy the captured output file path. |
44
+ | `Esc` / `q` | Return from details / close the panel. |
45
+
46
+ The panel and rail are only enabled in the main Pi TUI session; RPC, print, and child contexts retain the tool surface without TUI entrypoints.
47
+
48
+ ## Configuration
49
+
50
+ | Environment variable | Default | Description |
51
+ |---|---:|---|
52
+ | `PI_ASYNC_MAX_OUTPUT_BYTES` | `20971520` (20 MiB) | Maximum captured output per task; exceeding it terminates the task with `output_cap`. |
53
+ | `PI_ASYNC_MAX_TASKS` | `32` | Maximum concurrent running and pending tasks. |
54
+ | `PI_ASYNC_HEARTBEAT_MS` | `60000` | Settle-guard heartbeat interval in milliseconds. |
55
+ | `PI_ASYNC_DOCK_SHORTCUT` | `ctrl+alt+a` | Configured panel shortcut; the command remains available as fallback. |
56
+ | `PI_ASYNC_RETENTION_MS` | `604800000` (7 days) | How long terminal-task artifacts are retained; union with `RETENTION_COUNT`. |
57
+ | `PI_ASYNC_RETENTION_COUNT` | `100` | Max newest terminal tasks kept per dataDir; union with `RETENTION_MS`. |
58
+ | `PI_ASYNC_RAIL_LINGER_MS` | `30000` | How long completed/failed/killed tasks stay on the rail before disappearing. |
59
+
60
+ ## Legion child sessions
61
+
62
+ In a legion child session, collect task results with `async_wait` before returning a verdict. The settle guard reminds the agent about uncollected tasks, but the completion notification may not be delivered across turns until the upstream busy-hold contract lands. See [CONTRACT.md §6](docs/CONTRACT.md#6-唤醒链路与-legion-兼容冻结) for the frozen notification and collection requirements.
63
+
64
+ ## Known limitations
65
+
66
+ - Crash-durability is not guaranteed: metadata is atomically replaced, but captured output is not fsynced. After a crash, orphaned tasks and delayed tasks are not automatically restored; recovery is limited to `/reload` in the same running process.
67
+ - Busy-hold behavior depends on upstream pi-legion issue [#156](https://github.com/zzjcool/pi-legion/issues/156); the extension does not patch Pi or legion.
68
+ - TUI rail and panel are available only in the main TUI session, not in RPC/print/child contexts.
69
+ - `pi --print` notification delivery requires an available configured provider. If no provider is available, end-to-end print-mode verification remains pending a user run.
70
+
71
+ ## Development checks
72
+
73
+ ```sh
74
+ npm run typecheck
75
+ npm test
76
+ ```
77
+
78
+ ## Container QA
79
+
80
+ Run the plugin inside a clean container against the local model endpoint:
81
+
82
+ ```sh
83
+ cd test/docker
84
+ make run # start container + drop into pi (default: workbuddy/hy4-preview-ioa)
85
+ make smoke # L1 model reachable -> L2 tools advertised -> L3 a tool really runs
86
+ make qa-up && make qa-tui SCENARIO=s1 # deterministic TUI scenario
87
+ ```
88
+
89
+ `make run` reuses an already-running container, so it never tears down your session.
90
+
91
+ The container QA setup itself lives in `test/docker/` (not shipped in the npm
92
+ package). See its README in the repository for the two containers, the required
93
+ mounts, and why L3 must not be skipped.
94
+
95
+ ## Known upstream issue (print mode)
96
+
97
+ `pi --print` + background task completing **after** the agent settles: the wake happens but the woken
98
+ run's `turn_end` boundary fails with `Extension error (<boundary>): turn_end could not resolve the
99
+ persisted assistant entry ID` (pi 1.2.0 core, reproduced identically with pi-background-tasks —
100
+ upstream issue https://github.com/earendil-works/pi/issues/11001). **RPC mode (legion subagents) and
101
+ turn-internal waits are unaffected.**
@@ -0,0 +1,308 @@
1
+ # pi-async 冻结契约 v1
2
+
3
+ 状态:**FROZEN**(2026-10-15) | 仓库:/root/code/pi-async | Node ≥ 22.19, pi ≥ 1.2.0(TUI)
4
+ 修订规则:任何字段/语义变更必须走 REV 条目登记原因,worker 不得自行解释矛盾。
5
+
6
+ ## 0. 定位与范围
7
+
8
+ pi-async 是 pi 的异步任务运行时扩展:任务注册表 + 四种任务模式 + 完成唤醒 +
9
+ 主会话 TUI(轨道图 + 全屏面板)+ OSC 7501 busy 契约对齐(zzjcool/pi-legion#156)。
10
+
11
+ **刻意不做**(禁止 worker 越界):
12
+ - 不做 delegate/attested/Fusion 子 agent(pi-background-tasks 已覆盖,我们只管 shell 任务)
13
+ - 不改 pi-legion / pi 本体(busy-hold 走上游 issue #156 + 补充评论,我们不 fork)
14
+ - 不做 SSH/remote 执行、不做 tmux/PTY attach(后续版本)
15
+ - 不做 MCP server 化
16
+
17
+ ## 1. 目录与文件布局
18
+
19
+ ```
20
+ pi-async/
21
+ ├── package.json # name: pi-async, type: module, keywords 含 pi-package/pi-extension
22
+ ├── tsconfig.json
23
+ ├── extensions/index.ts # 唯一扩展入口(package.json "pi".extensions 指向)
24
+ ├── src/
25
+ │ ├── core/
26
+ │ │ ├── registry.ts # TaskRegistry:任务生命周期、终态、通知
27
+ │ │ ├── task.ts # Task 类型、状态机、snapshot
28
+ │ │ ├── spawn.ts # detached spawn + 进程组 kill 升级链
29
+ │ │ ├── output.ts # 输出捕获、tail 读取、ANSI 剥离
30
+ │ │ ├── watch.ts # 轮询 + 条件匹配引擎
31
+ │ │ ├── notify.ts # 完成通知/唤醒、抑制闩(每任务一条,不合并)
32
+ │ │ └── bus.ts # 预留 eventbus + OSC activity 上报
33
+ │ ├── tools/
34
+ │ │ └── index.ts # 5 个工具的注册与 schema
35
+ │ ├── tui/
36
+ │ │ ├── rail.ts # 任务轨道图 widget(≤10 行)
37
+ │ │ ├── panel.ts # 全屏面板(列表+详情)
38
+ │ │ └── status.ts # footer setStatus + setActivity
39
+ │ └── guard/
40
+ │ └── settle-guard.ts # agent_before_settle 守卫 + 心跳
41
+ ├── vendor/reference/ # 已就位(只读参考,不编译)
42
+ └── docs/
43
+ ```
44
+
45
+ 运行时数据(不进仓库):`.pi/async/<session-id>-<pid>/<task-id>.output` + `<task-id>.json`(atomic write)。
46
+
47
+ ## 2. 任务状态机(冻结)
48
+
49
+ ```
50
+ pending --(delay 到期/spawn 成功)--> running --┬-- exit 0 --> completed
51
+ ├-- exit ≠0 --> failed
52
+ ├-- timeout/output_cap --> failed (kind 标注)
53
+ └-- kill --> killed
54
+ ```
55
+
56
+ - 状态词汇:`pending | running | completed | failed | killed`;terminal = 后三者。
57
+ - KillKind:`user | timeout | output_cap | shutdown | watch_failure`(进程被主动杀掉的原因)。
58
+ - FailureKind:`lost | blind_exhausted | watch_timeout`(非「退出码失败」、需与 KillKind 并列区分的失败原因)。
59
+ - `lost`:reload 发现 pid 已死(§10)。
60
+ - `blind_exhausted`:watch blind_checks 耗尽。
61
+ - `watch_timeout`:watch `timeoutMs` 到期。
62
+ - **双字段、不统一 reason(REV-11)**:KillKind 答「谁杀的/为何杀进程」;FailureKind 答「任务为何 failed」。
63
+ 禁止发明第三套 `reason` 枚举把两者揉扁——通知与投影字段各自透传已有字段即可。
64
+ - delayed 任务在 pending 期可 cancel(→ killed, kind=user)。
65
+ - `notified` 持久闩(写进 metadata,reload 不重置),单次投递;发送失败回滚重试 ≤3 次。
66
+
67
+ ## 3. 任务模式与启动参数(async_run 工具,冻结)
68
+
69
+ ```ts
70
+ async_run({
71
+ command: string, // 必填
72
+ name?: string, // 展示名;缺省从 command 派生(≤48 chars)
73
+ mode: "background" | "sync" | "delayed", // 默认 background
74
+ // ── delayed ──
75
+ delayMs?: number, // mode=delayed 必填;pending 到期启动
76
+ // ── sync ──
77
+ timeoutMs: number, // mode=sync 必填(schema enforce);
78
+ // 到点:onTimeout="promote" → 转 background 继续;
79
+ // onTimeout="abort" → Esc 语义,等待中断任务保留
80
+ onTimeout?: "promote" | "abort", // 默认 promote
81
+ // ── 通用 ──
82
+ taskTimeoutMs?: number, // 任务自身寿命;缺省 = 无限(watch 必填除外,见 §4)
83
+ notifyOnCompletion?: boolean, // 默认 true
84
+ triggerOnCompletion?: boolean, // 默认 true:完成 sendMessage({triggerTurn:true}) 唤醒
85
+ env?: Record<string, string>, // 追加到 spawn env
86
+ watch?: WatchSpec, // 见 §4;模式仅 background/delayed
87
+ })
88
+ ```
89
+
90
+ **返回(receipt)**:`{ taskId, name, status, mode, outputHint }`;sync 模式返回
91
+ `{ ...receipt, exitCode, outputTail }`。所有任务**同步启动**:spawn 成功即返回 `running`;
92
+ spawn 失败立即抛错,无重试等待。admission 检查为 closed / maxTasks / 空命令,均同步完成。
93
+ shutdown 先 drain 再退出。
94
+ (**REV-6 裁决**:原「≤30s admission 预检」从未实现且与同步 spawn 设计冲突,改为描述现状;见 #29。)
95
+
96
+ **工具集(5 个)**:`async_run` / `async_wait` / `async_logs` / `async_status` / `async_kill`。
97
+ 命令面:`/async`(开面板)、`/async-clear`(清历史徽章)。
98
+
99
+ ## 4. WatchSpec(照抄 better-bg 条件系统,冻结)
100
+
101
+ ```ts
102
+ interface WatchSpec {
103
+ intervalMs: number; // 默认 30_000,min 1_000
104
+ timeoutMs: number; // watch 必填:watch 期限(schema enforce)
105
+ success_when: Condition[]; // 任一命中 → 任务完成语义(不干预进程,见下)
106
+ failure_when: Condition[]; // 任一命中 → kill(kind=watch_failure)
107
+ blind_checks?: number; // 默认 3:连续 N 次「检查基础设施失败」(readOutput 抛错等)
108
+ // → failed;「尚未可判定」为 pending,不累计(见下方 REV-2)
109
+ }
110
+ type Condition =
111
+ | { type: "exit_code"; equals: number }
112
+ | { type: "stdout_contains"; value: string }
113
+ | { type: "stderr_contains"; value: string }
114
+ | { type: "json_path_exists"; path: string } // 必须以 $ 开头,如 $.a.b[0];启动前校验;tool schema 含 pattern/description(#12)
115
+ | { type: "output_matches"; regex: string } // 合并输出流匹配(设备码等);编译带 m(^/$ 按行)
116
+ ```
117
+
118
+ - `success_when` 命中 → **不杀进程**:任务标 completed,通知带命中详情;进程按 taskTimeoutMs/kill 自然管理。
119
+ (区别于 better-bg 的 check 命令模式:我们 watch 的是**已存在任务**的输出/退出。)
120
+ - 条件在 async_run 时**启动前校验**,非法 regex/JSON path 直接拒绝(不产生 pending 任务)。
121
+ - **blind_checks 语义(REV-2 澄清)**:仅计「检查基础设施失败」(readOutput 抛错等);
122
+ 「尚未可判定」(exit_code 未退出 / json_path 输出尚非 JSON)为 **pending 态,不累计 blind、照常
123
+ 继续轮询**。两者是不同语义,禁止合并。条件持续 pending 时**不**触发 blind 兜底,仅由
124
+ `timeoutMs` 结束 watch(WatchSpec 字段注释已与本条对齐,见 #27)。
125
+ - promptGuidelines(写进工具 description):check 不要 `|| true` 自吞错误;未知态映射失败;
126
+ 结构化输出优先 jq -e;`output_matches` 的 regex 须加锚定防止过早匹配(编译固定带 `m`,
127
+ `^`/`$` 按行边界,故 `^READY$` 可命中多行输出中的某一行)。
128
+ - **watch 失败性终态的进程处置(REV-4 裁决)**:`failure_when` 命中、blind 耗尽、watch timeout
129
+ 三条 watch 失败路径在 finalize 任务的同时**必须 kill 进程组**(与 `failure_when → killed(watch_failure)`
130
+ 同语义);仅 `success_when`(D4)不干预进程。杀失败时响亮上报(kill 链已有 catch),
131
+ 绝不允许「终态任务 + 活进程」残留导致 async_kill 静默不杀或 shutdown 挂起。
132
+ - **终态标注(REV-11)**:`failure_when` → `killed` + `killKind=watch_failure`;blind 耗尽 /
133
+ watch timeout → `failed` + `killKind=watch_failure` + 对应 `failureKind`
134
+ (`blind_exhausted` / `watch_timeout`)。三条都杀进程,用 status+failureKind 区分语义。
135
+ - **REV-10 裁决(#48)**:`output_matches` 以 `new RegExp(regex, "m")` 编译(validate 与求值同)。
136
+ 契约原先要求锚定(`^READY$`)防止过早匹配,但无 `m` 时 `^`/`$` 只锚整段输出,多行流上锚定
137
+ 永不相交。加 `m` 使锚定与意图一致;整段匹配请用不含 `^`/`$` 的模式或显式 `\A`/`\z` 等价写法。
138
+
139
+ ## 5. async_wait(唤醒原语,冻结)
140
+
141
+ ```ts
142
+ async_wait({ taskIds: string[], timeoutMs: number }) // timeoutMs 必填(schema enforce)
143
+ // 返回三态(REV-7):
144
+ // → 全终态:{ results: Array<{taskId, status, exitCode?, outputTail}> }
145
+ // → 超时且无已完成:{ notReady: Array<{taskId, status: "running"|"pending", elapsedMs}> }(不杀任务)
146
+ // → 混合超时:{ results, notReady }(已完成走 waitResult/consume,抑制通知 chatter;未就绪不杀)
147
+ ```
148
+
149
+ - 事件驱动:await 内部 waiter(任务 finalize 时 resolve),**禁止 sleep 轮询**(patty 教训)。
150
+ - 支持 AbortSignal(Esc);中断等待不中断任务。
151
+ - 消费即抑制:async_wait 取回结果的任务,完成通知被 `outputConsumed` 闩抑制(防 chatter)。
152
+ - **REV-7 裁决(#43)**:原契约只写「全终态 → results」与「超时 → notReady」两极,导致混合超时
153
+ 路径丢弃已完成结果、且无法对已完成侧置 `outputConsumed`(通知抑制失效)。正式登记超集三态:
154
+ 判据仍是「有 notReady ⇒ 超时分支」;`results` 可选并存。实现见 #56,本 REV 只改契约。
155
+
156
+ ## 6. 唤醒链路与 legion 兼容(冻结)
157
+
158
+ | 环境 | triggerOnCompletion 行为 |
159
+ |---|---|
160
+ | 主会话(TUI/RPC/print) | `pi.sendMessage({customType:"async-task-notification"}, {deliverAs:"followUp", triggerTurn:true})`;idle → 立即新 turn;busy → followUp 队列 |
161
+ | legion child,turn 内 | 同上 |
162
+ | legion child,跨 turn | **hold 模式**:settle 前守卫上报 busy(见 §7);上游 #156 busy-hold 落地前,通知可能无法送达——**契约要求 agent 在 legion 环境用 async_wait 收割后再报 verdict**(守卫强制提醒,最多 3 次) |
163
+
164
+ - 环境检测:`PI_LEGION_NODE_ID` 存在 → legion child 环境,settle 守卫默认开启。
165
+ - 通知 XML:`<async-task-notification>` + task-id/name/status/exit-code/**kill-kind**/**failure-kind**/output-file/summary/**summary-truncated**/guidance
166
+ (guidance 仅 failed 带,completed 纯信息——patty chatter 教训)。
167
+ - `kill-kind` / `failure-kind`:有值才输出(与 snapshot 同名语义);模型据此区分非零退出 / output_cap / watch 失败 / lost,无需先 round-trip `async_status`(#37)。
168
+ - `summary-truncated`:summary 因 output_cap 或通知 tail 上限被截断时输出 `true`,避免把截断前缀误读为完整输出。
169
+ - **失败原因矩阵(REV-11)**:
170
+
171
+ | 原因 | status | killKind | failureKind | 通知要点 |
172
+ |---|---|---|---|---|
173
+ | 非零退出 | failed | — | — | exit-code |
174
+ | output cap | failed | output_cap | — | kill-kind + 常带 summary-truncated |
175
+ | failure_when 命中 | killed | watch_failure | — | kill-kind(status=killed 已区分) |
176
+ | blind 耗尽 | failed | watch_failure | blind_exhausted | kill-kind + failure-kind |
177
+ | watch timeout | failed | watch_failure | watch_timeout | kill-kind + failure-kind |
178
+ | reload lost | failed | — | lost | failure-kind |
179
+ - **REV-9 裁决(#47)**:v1 **每任务一条**完成通知(各带己方 summary/output-file);**不合并**。
180
+ 模块图原先写「合并」为假承诺——同时完成会各发一条并各可能 `triggerTurn`。防 chatter 靠
181
+ `outputConsumed` 闩与 failed-only guidance(D5),不靠跨任务 debounce。真合并需改多任务 XML
182
+ schema + 窗宽策略,另开后续 REV(可派 Drift)。
183
+ - 心跳:有 running/pending 任务期间,每 60s `pi.appendEntry` 一条 heartbeat(触发 legion
184
+ `collectArtifactGrowing` 宽限续期);session jsonl 增长由守卫的提醒消息自然产生,心跳为兜底。
185
+
186
+ ## 7. Settle 守卫 + OSC busy 上报(冻结,对齐 #156 插件契约)
187
+
188
+ - `agent_before_settle` 时存在未终态任务 → append 一条「N 个异步任务未收割(taskId 列表),先
189
+ async_wait/async_status 处理再报 verdict」+ `continue: true`;**每 run 重新武装,累计 ≤3 次**,
190
+ 超过后放行但 verdict 提示里保留未决清单。
191
+ - OSC busy 上报(feature-detect,无 API 则跳过):
192
+ - `ui.setActivity("pi-async", { message: "N async tasks" })`,全清时 `undefined`
193
+ - 每任务 `id=plugin/pi-async/<task-id>`,raise/clear 与任务状态同步
194
+ - **REV-8 裁决(#26)**:实现必须带 `plugin/` 前缀(对齐 legion
195
+ `docs/contracts/osc-busy-plugin.md` 的 `plugin/<name>/…`);聚合源仍为 `pi-async`(§7 冻结)。
196
+ 此前实现漏前缀会导致宿主按 `plugin/` 路由时 per-task activity 静默无效。
197
+ - **禁止 printf OSC**;尊重 `PI_PROGRAM_STATUS`
198
+ - 上报时机:任务启动、终态、以及 `agent_before_settle`(闭合 retire 竞态窗口的关键,见 #156 补充评论)
199
+ - RPC(legion child)内:setActivity 依赖 legion `extension_activity` 转发(#156 落地后生效),
200
+ 未落地时静默降级,不影响工具面。
201
+
202
+ ## 8. TUI(冻结,仅主会话)
203
+
204
+ - guard:所有 TUI 路径 `ctx.mode === "tui"`(hasUI 陷阱);widget/面板/shortcut 全部跳过 rpc/json/print。
205
+ - **轨道图 widget**(`setWidget("pi-async-rail", factory, {placement:"aboveEditor"})`):
206
+ ≤10 行;每行一个 live 任务 `▶ name · state · elapsed`(running=类型色 / completed=settled 色 /
207
+ failed=error / killed=dim;配色走 legion palette 延迟解析 + OKLab 撞色检测);wheel 滚动;
208
+ press→latch→click 命中任务 → 打开详情。空闲无任务时不渲染。
209
+ - **footer**:`setStatus("pi-async", " N running · M done ")`。
210
+ - **全屏面板**(`/async` 或 `Ctrl+Alt+A`):`ctx.ui.custom({overlay:true, overlayOptions:
211
+ {width:"100%", maxHeight:"100%", anchor:"top-left", row:0, col:0}})` 自绘 mask+frame
212
+ (legion live-overlay 模式);左列任务列表(含历史,`h` 切换),点击/Enter 进详情:
213
+ identity/status/elapsed/command/metadata + 输出 tail(128KiB UI 缓冲,12 行跟随滚动,上滚暂停);
214
+ 键位 `k` kill(二次确认)/`R` rerun/`c` 复制输出路径/`Esc q` 关闭。
215
+ - **running 任务在列表直接显示最新输出行**(footer pill 模式,ANSI 剥离)——设备码场景用户无需
216
+ agent 中转即可看到输出。
217
+ - shortcut 注册冲突处理:被保留键位占用则回退 `/async` 命令,不硬抢。
218
+ - **早期输出 peek**:`async_logs` 对 running 任务即时可读;`async_run` 返回的 receipt 带
219
+ `outputHint` 提示 agent 设备码等早期输出可直接 peek。
220
+
221
+ ## 9. 输出与预算(冻结)
222
+
223
+ - 写盘 cap:默认 20 MiB(`PI_ASYNC_MAX_OUTPUT_BYTES`),超限 → kill(kind=output_cap) → failed。
224
+ - 模型读取:`async_logs { taskId, maxBytes≤50KiB, tail=true }`,truncated 带全量路径提示(`fullPath` 有效至被 §10 retention prune)。
225
+ - ANSI/CSI/OSC 剥离:`async_logs` 返回与 TUI 显示前必须剥离(安全项,防输出注入转义序列)。
226
+ - sync 返回 outputTail ≤ 8 KiB / 200 行(whichever first)。
227
+
228
+ ## 10. 杀伤与清理(冻结)
229
+
230
+ - detached 进程组 spawn(POSIX);kill:SIGTERM → 3s grace → 组 SIGKILL → 有界探测;
231
+ 无法证明死亡 → 响亮报错「Descendant processes may have leaked」。
232
+ - `session_shutdown`:幂等清理(admission close + drain + 全任务 kill(kind=shutdown));
233
+ **不承诺 crash-durability**(metadata 原子写,output 不 fsync)。
234
+ - reload:registry 重建后扫描 `.pi/async/<session-id>-*`,running 且 pid 已死 → 标 failed(lost);
235
+ delayed pending → 恢复计时(reload 保留);**切换 session 不迁移**(旧目录只读留档)。
236
+ **REV-5 裁决(F3)**:reload 恢复仅限同进程存活期内的 /reload(dataDir 按进程固定
237
+ `<session-id>-<pid>`);跨 pid 崩溃遗留目录只读留档、不恢复,崩溃后遗留的 pending 延迟任务
238
+ 不会自动续跑。理由:跨 pid 收养需处理写入归属与幂等重建,与 v1 不承诺 crash-durability 的
239
+ 定位一致。README 已知限制同步标注。
240
+ - **磁盘产物 retention(#25)**:每个 dataDir 内,终态任务的 `.json` / `.output` 按 age+count
241
+ 并集策略 prune(启动 `reloadFromDisk` 末尾跑一次;每次 finalize 后再轻量触发)。
242
+ - 保留并集:按 `finishedAt ?? createdAt` 降序的最新 `PI_ASYNC_RETENTION_COUNT` 个终态任务,
243
+ **或** 年龄仍小于 `PI_ASYNC_RETENTION_MS` 的终态任务;其余终态任务删除产物并从内存移除。
244
+ - 非终态(running/pending)永不 prune。
245
+ - `async_logs` 返回的 `fullPath` **有效至被 prune**(过期后文件可能已删;缺文件时
246
+ `logs()` 仍安全返回空文本,不抛错)。措辞待 Loom 确认是否需在工具面增加 `missing` 标志。
247
+ - 跨 session 目录(`<session-id>-<pid>`)的整目录回收不在本条范围(仍只读留档)。
248
+
249
+ ## 11. 配置与环境变量
250
+
251
+ | 变量 | 默认 | 说明 |
252
+ |---|---|---|
253
+ | `PI_ASYNC_MAX_OUTPUT_BYTES` | 20 MiB | 写盘 cap |
254
+ | `PI_ASYNC_MAX_TASKS` | 32 | 注册表上限,超了拒绝新任务 |
255
+ | `PI_ASYNC_HEARTBEAT_MS` | 60_000 | settle 守卫心跳 |
256
+ | `PI_ASYNC_DOCK_SHORTCUT` | "ctrl+alt+a" | 可 off |
257
+ | `PI_ASYNC_RETENTION_MS` | 604_800_000(7 天) | 终态任务产物保留时长;与 COUNT 取并集 |
258
+ | `PI_ASYNC_RETENTION_COUNT` | 100 | 每个 dataDir 最多保留的「最新」终态任务数;与 MS 取并集 |
259
+
260
+ ## 12. 工程约束
261
+
262
+ - peerDependencies:`@earendil-works/pi-coding-agent`、`@earendil-works/pi-tui`(**不进 dependencies**,
263
+ jiti 虚拟模块映射要求)。
264
+ - 语言 TS + node:test;验证命令:`npm run typecheck` && `npm test`(worker verify 门禁用)。
265
+ - vendor/reference 不编译不 import,只作参考。
266
+ - MIT License;legion viz 整块复用处保留 attribution 注释。
267
+
268
+ ## 13. D 编号决策(已定,reviewer 对照用)
269
+
270
+ | # | 决策 |
271
+ |---|---|
272
+ | D1 | 不做子 agent 委派(delegate/attested/Fusion),只做 shell 任务运行时 |
273
+ | D2 | sync 模式 timeout 必填,默认 promote 而非报错(逃生口,对齐社区惯例) |
274
+ | D3 | async_wait timeout 必填;返回 notReady 不杀任务 |
275
+ | D4 | success_when 命中不杀进程(completed 是观察结论,进程归 taskTimeout/kill 管理) |
276
+ | D5 | 通知 completed 纯信息、failed 才带 guidance;outputConsumed 闩防 chatter |
277
+ | D6 | TUI 仅主会话;subagent 自动退化纯工具面(mode guard) |
278
+ | D7 | OSC busy 上报 feature-detect + settle 前上报闭合竞态(#156 契约);未落地静默降级 |
279
+ | D8 | 不改 legion/pi 本体;busy-hold 走上游(#156 + 补充评论已发) |
280
+ | D9 | delayed:reload 恢复计时,切 session 不迁移 |
281
+ | D10 | settle 守卫每 run 重武装、累计 ≤3 次,超限放行但保留未决清单提示 |
282
+
283
+ ## 14. 验收清单(verify 门禁)
284
+
285
+ 1. `npm run typecheck` && `npm test` 全绿
286
+ 2. background 任务:启动→立即 receipt→完成通知唤醒(print/RPC 模式实测:`pi --print` 会话收到
287
+ `<async-task-notification>` 并继续 turn)
288
+ 3. sync:短命令阻塞返回 exitCode;长命令 timeoutMs 到点 promote 转 background,任务不死
289
+ 4. delayed:delayMs 后启动;pending 期 cancel;reload 后恢复
290
+ 5. watch:stdout_contains/output_matches 命中 completed(含多行上 `^READY$` 因 m 命中);
291
+ failure_when 命中 killed(watch_failure);非法 regex 启动前拒绝
292
+ 6. async_wait:多任务等待全部终态;timeout 返回 notReady 且任务存活;混合超时同时含 results+notReady(已完成侧已 consume);Esc 中断不杀任务
293
+ 7. 输出安全:注入 ANSI 转义的任务输出,async_logs 返回已剥离
294
+ 8. kill:进程组全灭(子进程不残留);20MiB 超限 kill(output_cap)
295
+ 9. settle 守卫:有未决任务时 agent_before_settle 注入提醒并 continue;3 次后放行
296
+ 10. TUI(人工):widget 轨道图渲染 + 点击进面板详情 + 输出 tail 跟随;RPC 下无 TUI 路径执行
297
+
298
+ ## 15. 加固层(2026-10-15 事故后追加)
299
+
300
+ - **factory API 契约测试**:pi 1.2.0 的 factory-time ExtensionAPI **没有** `mode`/`ui`/`cwd`(探针实测,
301
+ 仅 registerTool/on/sendMessage/appendEntry/sessionManager 等)。TUI 绑定只能从事件 ctx
302
+ (`pi.on(event, (event, ctx) => ...)`)获取。`tests/i-factory-contract.test.ts` 固化该形状:
303
+ mock 的 factory pi **禁止**发明真实宿主没有的字段(此前两起生产事故均源于 mock 幻觉字段)。
304
+ - **工具返回值契约**:pi 不自动包装扩展工具返回值;全部 execute 必须经 `toolResult()` 包装成
305
+ `{content:[{type:"text"}], details, isError}`(TUI getTextOutput 直接 `result.content.filter`)。
306
+ `tests/a-tool-result-contract.test.ts` 逐工具断言。
307
+ - **rail 终态滞留**:completed/failed/killed 任务在轨道图上保留 `PI_ASYNC_RAIL_LINGER_MS`
308
+ (默认 30s)供用户看到结果,之后自动消失;running/pending 恒显示;全空闲渲染空(§8)。
@@ -0,0 +1,138 @@
1
+ import { join } from "node:path";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { clearTaskActivity, raiseTaskActivity, reportActivity } from "../src/core/bus.ts";
4
+ import { sendCompletionNotification } from "../src/core/notify.ts";
5
+ import { TaskRegistry } from "../src/core/registry.ts";
6
+ import { SettleGuard } from "../src/guard/settle-guard.ts";
7
+ import { registerTools } from "../src/tools/index.ts";
8
+ import { registerRail } from "../src/tui/rail.ts";
9
+ import { disposeStatus, initStatus } from "../src/tui/status.ts";
10
+ import { registerPanelCommands } from "../src/tui/panel.ts";
11
+ import { bindTuiHost } from "../src/tui/host.ts";
12
+ import { isTerminal } from "../src/core/task.ts";
13
+
14
+ export type PiCtx = ExtensionAPI;
15
+
16
+ type RuntimeContext = PiCtx & {
17
+ cwd?: string;
18
+ pid?: number;
19
+ sessionId?: string;
20
+ getSessionId?: () => string | undefined;
21
+ sessionManager?: { getSessionId?: () => string | undefined };
22
+ mode?: string;
23
+ on?: (event: string, handler: (...args: unknown[]) => unknown) => unknown;
24
+ registerTool?: (...args: unknown[]) => unknown;
25
+ };
26
+
27
+ function registryDataDir(pi: PiCtx): string {
28
+ const context = pi as RuntimeContext;
29
+ const pid = Number.isInteger(context.pid) && (context.pid ?? 0) > 0 ? context.pid ?? process.pid : process.pid;
30
+ const sessionId = context.sessionManager?.getSessionId?.() ?? context.getSessionId?.() ?? context.sessionId;
31
+ const safeSessionId = (sessionId || String(process.pid)).replace(/[^\w.-]/g, "_");
32
+ const cwd = typeof context.cwd === "string" && context.cwd.length > 0 ? context.cwd : process.cwd();
33
+ return join(cwd, ".pi", "async", `${safeSessionId}-${pid}`);
34
+ }
35
+
36
+ /** Previous init generation; /reload re-runs init without session_shutdown (#44). */
37
+ let activeRegistry: TaskRegistry | undefined;
38
+ let activeDisposeTui: (() => void) | undefined;
39
+
40
+ /** Wire the task runtime to the host API, degrading optional Pi features gracefully. */
41
+ export async function init(pi: PiCtx): Promise<void> {
42
+ // Reload replaces the extension runtime; retire the previous generation first so
43
+ // its processes, watch loops, and timers are released (pi docs: cleanup must be
44
+ // idempotent across reload / session replacement / exit).
45
+ if (activeRegistry) {
46
+ try {
47
+ activeDisposeTui?.();
48
+ } catch { /* best effort */ }
49
+ activeDisposeTui = undefined;
50
+ try {
51
+ await activeRegistry.shutdown();
52
+ } catch (error) {
53
+ console.error("pi-async: previous registry shutdown during reload failed", error);
54
+ }
55
+ activeRegistry = undefined;
56
+ }
57
+ const registry = new TaskRegistry({ dataDir: registryDataDir(pi) });
58
+ activeRegistry = registry;
59
+ // NOTE: pi's factory-time ExtensionAPI exposes no `mode`/`ui` — those live on
60
+ // event-handler contexts (pi.on("session_start", (event, ctx) => ...)). TUI
61
+ // registration must therefore bind lazily from the first event context.
62
+ let tui = false;
63
+ const bindTui = (ctx: unknown): void => {
64
+ if (!bindTuiHost(ctx)) return;
65
+ tui = true;
66
+ initStatus(pi, registry);
67
+ registerRail(pi, registry);
68
+ registerPanelCommands(pi, registry);
69
+ };
70
+
71
+ registry.setEvents({
72
+ onFinalize: (snapshot) => {
73
+ clearTaskActivity(pi, snapshot.taskId);
74
+ reportActivity(pi, registry.activeCount());
75
+ if (tui) initStatus(pi, registry);
76
+ if (snapshot.metadata.notifyOnCompletion === false) return;
77
+ const outputFile = String(snapshot.metadata.outputFile ?? "");
78
+ const logSlice = registry.logs(snapshot.taskId, { maxBytes: 8192, tail: true });
79
+ const outputTail = logSlice.text;
80
+ const guidance = snapshot.status === "failed" ? `Inspect task output with async_logs (taskId: ${snapshot.taskId}).` : undefined;
81
+ const summaryTruncated = snapshot.metadata.capped === true || logSlice.truncated;
82
+ void sendCompletionNotification(pi, registry, {
83
+ taskId: snapshot.taskId,
84
+ name: snapshot.name,
85
+ status: snapshot.status,
86
+ ...(snapshot.exitCode === undefined ? {} : { exitCode: snapshot.exitCode }),
87
+ outputFile,
88
+ ...(outputTail ? { summary: outputTail } : {}),
89
+ ...(guidance ? { guidance } : {}),
90
+ ...(snapshot.killKind ? { killKind: snapshot.killKind } : {}),
91
+ ...(snapshot.failureKind ? { failureKind: snapshot.failureKind } : {}),
92
+ ...(summaryTruncated ? { summaryTruncated: true } : {}),
93
+ }, { triggerTurn: snapshot.metadata.triggerOnCompletion !== false })
94
+ .catch((error: unknown) => console.error(`pi-async: completion notification failed for ${snapshot.taskId}`, error));
95
+ },
96
+ onStateChange: (snapshot) => {
97
+ const active = registry.activeCount();
98
+ reportActivity(pi, active);
99
+ if (isTerminal(snapshot.status)) clearTaskActivity(pi, snapshot.taskId);
100
+ else raiseTaskActivity(pi, snapshot.taskId);
101
+ if (tui) initStatus(pi, registry);
102
+ },
103
+ });
104
+
105
+ try {
106
+ await registry.reloadFromDisk();
107
+ } catch (error) {
108
+ console.error("pi-async: failed to reload task state; continuing with an empty registry", error);
109
+ }
110
+
111
+ const context = pi as RuntimeContext;
112
+ if (typeof context.registerTool === "function") registerTools(pi, registry);
113
+ // Bind TUI surfaces from the first event context (factory API has no mode/ui).
114
+ if (typeof context.on === "function") {
115
+ context.on("session_start", (_event: unknown, ctx: unknown) => bindTui(ctx));
116
+ context.on("message_start", (_event: unknown, ctx: unknown) => bindTui(ctx));
117
+ }
118
+ if (typeof context.on === "function") new SettleGuard(pi, registry).register();
119
+
120
+ activeDisposeTui = () => {
121
+ if (tui) disposeStatus();
122
+ };
123
+
124
+ let shutdownPromise: Promise<void> | undefined;
125
+ if (typeof context.on === "function") {
126
+ context.on("session_shutdown", async () => {
127
+ if (!shutdownPromise) shutdownPromise = (async () => {
128
+ activeDisposeTui?.();
129
+ activeDisposeTui = undefined;
130
+ if (activeRegistry === registry) activeRegistry = undefined;
131
+ await registry.shutdown();
132
+ })();
133
+ await shutdownPromise;
134
+ });
135
+ }
136
+ }
137
+
138
+ export default init;