pi-async 0.0.0-stage → 0.1.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/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,81 @@
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
+
57
+ ## Legion child sessions
58
+
59
+ 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.
60
+
61
+ ## Known limitations
62
+
63
+ - 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.
64
+ - 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.
65
+ - TUI rail and panel are available only in the main TUI session, not in RPC/print/child contexts.
66
+ - `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.
67
+
68
+ ## Development checks
69
+
70
+ ```sh
71
+ npm run typecheck
72
+ npm test
73
+ ```
74
+
75
+ ## Known upstream issue (print mode)
76
+
77
+ `pi --print` + background task completing **after** the agent settles: the wake happens but the woken
78
+ run's `turn_end` boundary fails with `Extension error (<boundary>): turn_end could not resolve the
79
+ persisted assistant entry ID` (pi 1.2.0 core, reproduced identically with pi-background-tasks —
80
+ upstream issue https://github.com/earendil-works/pi/issues/11001). **RPC mode (legion subagents) and
81
+ turn-internal waits are unaffected.**
@@ -0,0 +1,257 @@
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`(watch 条件命中 failure_when →
58
+ 主动 kill,任务记 killed,killKind=watch_failure)。
59
+ - delayed 任务在 pending 期可 cancel(→ killed, kind=user)。
60
+ - `notified` 持久闩(写进 metadata,reload 不重置),单次投递;发送失败回滚重试 ≤3 次。
61
+
62
+ ## 3. 任务模式与启动参数(async_run 工具,冻结)
63
+
64
+ ```ts
65
+ async_run({
66
+ command: string, // 必填
67
+ name?: string, // 展示名;缺省从 command 派生(≤48 chars)
68
+ mode: "background" | "sync" | "delayed", // 默认 background
69
+ // ── delayed ──
70
+ delayMs?: number, // mode=delayed 必填;pending 到期启动
71
+ // ── sync ──
72
+ timeoutMs: number, // mode=sync 必填(schema enforce);
73
+ // 到点:onTimeout="promote" → 转 background 继续;
74
+ // onTimeout="abort" → Esc 语义,等待中断任务保留
75
+ onTimeout?: "promote" | "abort", // 默认 promote
76
+ // ── 通用 ──
77
+ taskTimeoutMs?: number, // 任务自身寿命;缺省 = 无限(watch 必填除外,见 §4)
78
+ notifyOnCompletion?: boolean, // 默认 true
79
+ triggerOnCompletion?: boolean, // 默认 true:完成 sendMessage({triggerTurn:true}) 唤醒
80
+ env?: Record<string, string>, // 追加到 spawn env
81
+ watch?: WatchSpec, // 见 §4;模式仅 background/delayed
82
+ })
83
+ ```
84
+
85
+ **返回(receipt)**:`{ taskId, name, status, mode, outputHint }`;sync 模式返回
86
+ `{ ...receipt, exitCode, outputTail }`。所有任务启动 ≤30s admission 预检,shutdown 先 drain 再退出。
87
+
88
+ **工具集(5 个)**:`async_run` / `async_wait` / `async_logs` / `async_status` / `async_kill`。
89
+ 命令面:`/async`(开面板)、`/async-clear`(清历史徽章)。
90
+
91
+ ## 4. WatchSpec(照抄 better-bg 条件系统,冻结)
92
+
93
+ ```ts
94
+ interface WatchSpec {
95
+ intervalMs: number; // 默认 30_000,min 1_000
96
+ timeoutMs: number; // watch 必填:watch 期限(schema enforce)
97
+ success_when: Condition[]; // 任一命中 → 任务完成语义(不干预进程,见下)
98
+ failure_when: Condition[]; // 任一命中 → kill(kind=watch_failure)
99
+ blind_checks?: number; // 默认 3:连续 N 次无法求值 → failed
100
+ }
101
+ type Condition =
102
+ | { type: "exit_code"; equals: number }
103
+ | { type: "stdout_contains"; value: string }
104
+ | { type: "stderr_contains"; value: string }
105
+ | { type: "json_path_exists"; path: string } // $.a.b[0] 形式,启动前校验
106
+ | { type: "output_matches"; regex: string } // 我们新增:对合并输出流匹配(设备码等)
107
+ ```
108
+
109
+ - `success_when` 命中 → **不杀进程**:任务标 completed,通知带命中详情;进程按 taskTimeoutMs/kill 自然管理。
110
+ (区别于 better-bg 的 check 命令模式:我们 watch 的是**已存在任务**的输出/退出。)
111
+ - 条件在 async_run 时**启动前校验**,非法 regex/JSON path 直接拒绝(不产生 pending 任务)。
112
+ - **blind_checks 语义(REV-2 澄清)**:仅计「检查基础设施失败」(readOutput 抐错等);
113
+ 「尚未可判定」(exit_code 未退出 / json_path 输出尚非 JSON)为 **pending 态,不累计 blind、照常
114
+ 继续轮询**。两者是不同语义,禁止合并。
115
+ - promptGuidelines(写进工具 description):check 不要 `|| true` 自吞错误;未知态映射失败;
116
+ 结构化输出优先 jq -e;`output_matches` 的 regex 须加锚定防止过早匹配。
117
+ - **watch 失败性终态的进程处置(REV-4 裁决)**:`failure_when` 命中、blind 耗尽、watch timeout
118
+ 三条 watch 失败路径在 finalize 任务的同时**必须 kill 进程组**(与 `failure_when → killed(watch_failure)`
119
+ 同语义);仅 `success_when`(D4)不干预进程。杀失败时响亮上报(kill 链已有 catch),
120
+ 绝不允许「终态任务 + 活进程」残留导致 async_kill 静默不杀或 shutdown 挂起。
121
+
122
+ ## 5. async_wait(唤醒原语,冻结)
123
+
124
+ ```ts
125
+ async_wait({ taskIds: string[], timeoutMs: number }) // timeoutMs 必填(schema enforce)
126
+ // → 全部到达终态:{ results: Array<{taskId, status, exitCode?, outputTail}> }
127
+ // → 超时:{ notReady: Array<{taskId, status: "running"|"pending", elapsedMs}> }(不杀任务)
128
+ ```
129
+
130
+ - 事件驱动:await 内部 waiter(任务 finalize 时 resolve),**禁止 sleep 轮询**(patty 教训)。
131
+ - 支持 AbortSignal(Esc);中断等待不中断任务。
132
+ - 消费即抑制:async_wait 取回结果的任务,完成通知被 `outputConsumed` 闩抑制(防 chatter)。
133
+
134
+ ## 6. 唤醒链路与 legion 兼容(冻结)
135
+
136
+ | 环境 | triggerOnCompletion 行为 |
137
+ |---|---|
138
+ | 主会话(TUI/RPC/print) | `pi.sendMessage({customType:"async-task-notification"}, {deliverAs:"followUp", triggerTurn:true})`;idle → 立即新 turn;busy → followUp 队列 |
139
+ | legion child,turn 内 | 同上 |
140
+ | legion child,跨 turn | **hold 模式**:settle 前守卫上报 busy(见 §7);上游 #156 busy-hold 落地前,通知可能无法送达——**契约要求 agent 在 legion 环境用 async_wait 收割后再报 verdict**(守卫强制提醒,最多 3 次) |
141
+
142
+ - 环境检测:`PI_LEGION_NODE_ID` 存在 → legion child 环境,settle 守卫默认开启。
143
+ - 通知 XML:`<async-task-notification>` + task-id/name/status/exit-code/output-file/summary/guidance
144
+ (guidance 仅 failed 带,completed 纯信息——patty chatter 教训)。
145
+ - 心跳:有 running/pending 任务期间,每 60s `pi.appendEntry` 一条 heartbeat(触发 legion
146
+ `collectArtifactGrowing` 宽限续期);session jsonl 增长由守卫的提醒消息自然产生,心跳为兜底。
147
+
148
+ ## 7. Settle 守卫 + OSC busy 上报(冻结,对齐 #156 插件契约)
149
+
150
+ - `agent_before_settle` 时存在未终态任务 → append 一条「N 个异步任务未收割(taskId 列表),先
151
+ async_wait/async_status 处理再报 verdict」+ `continue: true`;**每 run 重新武装,累计 ≤3 次**,
152
+ 超过后放行但 verdict 提示里保留未决清单。
153
+ - OSC busy 上报(feature-detect,无 API 则跳过):
154
+ - `ui.setActivity("pi-async", { message: "N async tasks" })`,全清时 `undefined`
155
+ - 每任务 `id=plugin/pi-async/<task-id>`,raise/clear 与任务状态同步
156
+ - **禁止 printf OSC**;尊重 `PI_PROGRAM_STATUS`
157
+ - 上报时机:任务启动、终态、以及 `agent_before_settle`(闭合 retire 竞态窗口的关键,见 #156 补充评论)
158
+ - RPC(legion child)内:setActivity 依赖 legion `extension_activity` 转发(#156 落地后生效),
159
+ 未落地时静默降级,不影响工具面。
160
+
161
+ ## 8. TUI(冻结,仅主会话)
162
+
163
+ - guard:所有 TUI 路径 `ctx.mode === "tui"`(hasUI 陷阱);widget/面板/shortcut 全部跳过 rpc/json/print。
164
+ - **轨道图 widget**(`setWidget("pi-async-rail", factory, {placement:"aboveEditor"})`):
165
+ ≤10 行;每行一个 live 任务 `▶ name · state · elapsed`(running=类型色 / completed=settled 色 /
166
+ failed=error / killed=dim;配色走 legion palette 延迟解析 + OKLab 撞色检测);wheel 滚动;
167
+ press→latch→click 命中任务 → 打开详情。空闲无任务时不渲染。
168
+ - **footer**:`setStatus("pi-async", " N running · M done ")`。
169
+ - **全屏面板**(`/async` 或 `Ctrl+Alt+A`):`ctx.ui.custom({overlay:true, overlayOptions:
170
+ {width:"100%", maxHeight:"100%", anchor:"top-left", row:0, col:0}})` 自绘 mask+frame
171
+ (legion live-overlay 模式);左列任务列表(含历史,`h` 切换),点击/Enter 进详情:
172
+ identity/status/elapsed/command/metadata + 输出 tail(128KiB UI 缓冲,12 行跟随滚动,上滚暂停);
173
+ 键位 `k` kill(二次确认)/`R` rerun/`c` 复制输出路径/`Esc q` 关闭。
174
+ - **running 任务在列表直接显示最新输出行**(footer pill 模式,ANSI 剥离)——设备码场景用户无需
175
+ agent 中转即可看到输出。
176
+ - shortcut 注册冲突处理:被保留键位占用则回退 `/async` 命令,不硬抢。
177
+ - **早期输出 peek**:`async_logs` 对 running 任务即时可读;`async_run` 返回的 receipt 带
178
+ `outputHint` 提示 agent 设备码等早期输出可直接 peek。
179
+
180
+ ## 9. 输出与预算(冻结)
181
+
182
+ - 写盘 cap:默认 20 MiB(`PI_ASYNC_MAX_OUTPUT_BYTES`),超限 → kill(kind=output_cap) → failed。
183
+ - 模型读取:`async_logs { taskId, maxBytes≤50KiB, tail=true }`,truncated 带全量路径提示。
184
+ - ANSI/CSI/OSC 剥离:`async_logs` 返回与 TUI 显示前必须剥离(安全项,防输出注入转义序列)。
185
+ - sync 返回 outputTail ≤ 8 KiB / 200 行(whichever first)。
186
+
187
+ ## 10. 杀伤与清理(冻结)
188
+
189
+ - detached 进程组 spawn(POSIX);kill:SIGTERM → 3s grace → 组 SIGKILL → 有界探测;
190
+ 无法证明死亡 → 响亮报错「Descendant processes may have leaked」。
191
+ - `session_shutdown`:幂等清理(admission close + drain + 全任务 kill(kind=shutdown));
192
+ **不承诺 crash-durability**(metadata 原子写,output 不 fsync)。
193
+ - reload:registry 重建后扫描 `.pi/async/<session-id>-*`,running 且 pid 已死 → 标 failed(lost);
194
+ delayed pending → 恢复计时(reload 保留);**切换 session 不迁移**(旧目录只读留档)。
195
+ **REV-5 裁决(F3)**:reload 恢复仅限同进程存活期内的 /reload(dataDir 按进程固定
196
+ `<session-id>-<pid>`);跨 pid 崩溃遗留目录只读留档、不恢复,崩溃后遗留的 pending 延迟任务
197
+ 不会自动续跑。理由:跨 pid 收养需处理写入归属与幂等重建,与 v1 不承诺 crash-durability 的
198
+ 定位一致。README 已知限制同步标注。
199
+
200
+ ## 11. 配置与环境变量
201
+
202
+ | 变量 | 默认 | 说明 |
203
+ |---|---|---|
204
+ | `PI_ASYNC_MAX_OUTPUT_BYTES` | 20 MiB | 写盘 cap |
205
+ | `PI_ASYNC_MAX_TASKS` | 32 | 注册表上限,超了拒绝新任务 |
206
+ | `PI_ASYNC_HEARTBEAT_MS` | 60_000 | settle 守卫心跳 |
207
+ | `PI_ASYNC_DOCK_SHORTCUT` | "ctrl+alt+a" | 可 off |
208
+
209
+ ## 12. 工程约束
210
+
211
+ - peerDependencies:`@earendil-works/pi-coding-agent`、`@earendil-works/pi-tui`(**不进 dependencies**,
212
+ jiti 虚拟模块映射要求)。
213
+ - 语言 TS + node:test;验证命令:`npm run typecheck` && `npm test`(worker verify 门禁用)。
214
+ - vendor/reference 不编译不 import,只作参考。
215
+ - MIT License;legion viz 整块复用处保留 attribution 注释。
216
+
217
+ ## 13. D 编号决策(已定,reviewer 对照用)
218
+
219
+ | # | 决策 |
220
+ |---|---|
221
+ | D1 | 不做子 agent 委派(delegate/attested/Fusion),只做 shell 任务运行时 |
222
+ | D2 | sync 模式 timeout 必填,默认 promote 而非报错(逃生口,对齐社区惯例) |
223
+ | D3 | async_wait timeout 必填;返回 notReady 不杀任务 |
224
+ | D4 | success_when 命中不杀进程(completed 是观察结论,进程归 taskTimeout/kill 管理) |
225
+ | D5 | 通知 completed 纯信息、failed 才带 guidance;outputConsumed 闩防 chatter |
226
+ | D6 | TUI 仅主会话;subagent 自动退化纯工具面(mode guard) |
227
+ | D7 | OSC busy 上报 feature-detect + settle 前上报闭合竞态(#156 契约);未落地静默降级 |
228
+ | D8 | 不改 legion/pi 本体;busy-hold 走上游(#156 + 补充评论已发) |
229
+ | D9 | delayed:reload 恢复计时,切 session 不迁移 |
230
+ | D10 | settle 守卫每 run 重武装、累计 ≤3 次,超限放行但保留未决清单提示 |
231
+
232
+ ## 14. 验收清单(verify 门禁)
233
+
234
+ 1. `npm run typecheck` && `npm test` 全绿
235
+ 2. background 任务:启动→立即 receipt→完成通知唤醒(print/RPC 模式实测:`pi --print` 会话收到
236
+ `<async-task-notification>` 并继续 turn)
237
+ 3. sync:短命令阻塞返回 exitCode;长命令 timeoutMs 到点 promote 转 background,任务不死
238
+ 4. delayed:delayMs 后启动;pending 期 cancel;reload 后恢复
239
+ 5. watch:stdout_contains/output_matches 命中 completed;failure_when 命中 killed(watch_failure);
240
+ 非法 regex 启动前拒绝
241
+ 6. async_wait:多任务等待全部终态;timeout 返回 notReady 且任务存活;Esc 中断不杀任务
242
+ 7. 输出安全:注入 ANSI 转义的任务输出,async_logs 返回已剥离
243
+ 8. kill:进程组全灭(子进程不残留);20MiB 超限 kill(output_cap)
244
+ 9. settle 守卫:有未决任务时 agent_before_settle 注入提醒并 continue;3 次后放行
245
+ 10. TUI(人工):widget 轨道图渲染 + 点击进面板详情 + 输出 tail 跟随;RPC 下无 TUI 路径执行
246
+
247
+ ## 15. 加固层(2026-10-15 事故后追加)
248
+
249
+ - **factory API 契约测试**:pi 1.2.0 的 factory-time ExtensionAPI **没有** `mode`/`ui`/`cwd`(探针实测,
250
+ 仅 registerTool/on/sendMessage/appendEntry/sessionManager 等)。TUI 绑定只能从事件 ctx
251
+ (`pi.on(event, (event, ctx) => ...)`)获取。`tests/i-factory-contract.test.ts` 固化该形状:
252
+ mock 的 factory pi **禁止**发明真实宿主没有的字段(此前两起生产事故均源于 mock 幻觉字段)。
253
+ - **工具返回值契约**:pi 不自动包装扩展工具返回值;全部 execute 必须经 `toolResult()` 包装成
254
+ `{content:[{type:"text"}], details, isError}`(TUI getTextOutput 直接 `result.content.filter`)。
255
+ `tests/a-tool-result-contract.test.ts` 逐工具断言。
256
+ - **rail 终态滞留**:completed/failed/killed 任务在轨道图上保留 `PI_ASYNC_RAIL_LINGER_MS`
257
+ (默认 30s)供用户看到结果,之后自动消失;running/pending 恒显示;全空闲渲染空(§8)。
@@ -0,0 +1,107 @@
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
+ /** Wire the task runtime to the host API, degrading optional Pi features gracefully. */
37
+ export async function init(pi: PiCtx): Promise<void> {
38
+ const registry = new TaskRegistry({ dataDir: registryDataDir(pi) });
39
+ // NOTE: pi's factory-time ExtensionAPI exposes no `mode`/`ui` — those live on
40
+ // event-handler contexts (pi.on("session_start", (event, ctx) => ...)). TUI
41
+ // registration must therefore bind lazily from the first event context.
42
+ let tui = false;
43
+ const bindTui = (ctx: unknown): void => {
44
+ if (!bindTuiHost(ctx)) return;
45
+ tui = true;
46
+ initStatus(pi, registry);
47
+ registerRail(pi, registry);
48
+ registerPanelCommands(pi, registry);
49
+ };
50
+
51
+ registry.setEvents({
52
+ onFinalize: (snapshot) => {
53
+ clearTaskActivity(pi, snapshot.taskId);
54
+ reportActivity(pi, registry.activeCount());
55
+ if (tui) initStatus(pi, registry);
56
+ if (snapshot.metadata.notifyOnCompletion === false) return;
57
+ const outputFile = String(snapshot.metadata.outputFile ?? "");
58
+ const outputTail = registry.logs(snapshot.taskId, { maxBytes: 8192, tail: true }).text;
59
+ const guidance = snapshot.status === "failed" ? `Inspect task output with async_logs (taskId: ${snapshot.taskId}).` : undefined;
60
+ void sendCompletionNotification(pi, registry, {
61
+ taskId: snapshot.taskId,
62
+ name: snapshot.name,
63
+ status: snapshot.status,
64
+ ...(snapshot.exitCode === undefined ? {} : { exitCode: snapshot.exitCode }),
65
+ outputFile,
66
+ ...(outputTail ? { summary: outputTail } : {}),
67
+ ...(guidance ? { guidance } : {}),
68
+ }, { triggerTurn: snapshot.metadata.triggerOnCompletion !== false })
69
+ .catch((error: unknown) => console.error(`pi-async: completion notification failed for ${snapshot.taskId}`, error));
70
+ },
71
+ onStateChange: (snapshot) => {
72
+ const active = registry.activeCount();
73
+ reportActivity(pi, active);
74
+ if (isTerminal(snapshot.status)) clearTaskActivity(pi, snapshot.taskId);
75
+ else raiseTaskActivity(pi, snapshot.taskId);
76
+ if (tui) initStatus(pi, registry);
77
+ },
78
+ });
79
+
80
+ try {
81
+ await registry.reloadFromDisk();
82
+ } catch (error) {
83
+ console.error("pi-async: failed to reload task state; continuing with an empty registry", error);
84
+ }
85
+
86
+ const context = pi as RuntimeContext;
87
+ if (typeof context.registerTool === "function") registerTools(pi, registry);
88
+ // Bind TUI surfaces from the first event context (factory API has no mode/ui).
89
+ if (typeof context.on === "function") {
90
+ context.on("session_start", (_event: unknown, ctx: unknown) => bindTui(ctx));
91
+ context.on("message_start", (_event: unknown, ctx: unknown) => bindTui(ctx));
92
+ }
93
+ if (typeof context.on === "function") new SettleGuard(pi, registry).register();
94
+
95
+ let shutdownPromise: Promise<void> | undefined;
96
+ if (typeof context.on === "function") {
97
+ context.on("session_shutdown", async () => {
98
+ if (!shutdownPromise) shutdownPromise = (async () => {
99
+ if (tui) disposeStatus();
100
+ await registry.shutdown();
101
+ })();
102
+ await shutdownPromise;
103
+ });
104
+ }
105
+ }
106
+
107
+ export default init;
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "pi-async",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Pi extension for async shell tasks: background/delayed/sync modes, output-matching watches, event-driven waiting, settle guard for legion children, and a TUI task rail — completion notifications wake your session.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "zzjcool",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/zzjcool/pi-async.git"
11
+ },
12
+ "homepage": "https://github.com/zzjcool/pi-async#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/zzjcool/pi-async/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "pi",
20
+ "coding-agent",
21
+ "async",
22
+ "background-tasks",
23
+ "task-manager",
24
+ "shell",
25
+ "watch",
26
+ "delayed",
27
+ "pi-legion"
28
+ ],
29
+ "engines": {
30
+ "node": ">=22.19.0"
31
+ },
32
+ "pi": {
33
+ "extensions": [
34
+ "./extensions/index.ts"
35
+ ]
36
+ },
37
+ "files": [
38
+ "extensions/",
39
+ "src/",
40
+ "README.md",
41
+ "LICENSE",
42
+ "docs/CONTRACT.md"
43
+ ],
44
+ "scripts": {
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "node --experimental-strip-types --test tests/*.test.ts"
47
+ },
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-coding-agent": "^1.2.0",
50
+ "@earendil-works/pi-tui": "^1.2.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^22.19.19",
54
+ "typescript": "^5.9.3"
55
+ }
56
+ }
@@ -0,0 +1,28 @@
1
+ import type { PiCtx } from "./notify.ts";
2
+
3
+ /** Report aggregate async work through Pi's activity API when the host exposes it. */
4
+ export function reportActivity(pi: PiCtx, active: { running: number; pending: number }): void {
5
+ // SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
6
+ const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
7
+ if (typeof ui?.setActivity !== "function") return;
8
+ const count = active.running + active.pending;
9
+ ui.setActivity("pi-async", count === 0 ? undefined : { message: `${count} async tasks` });
10
+ // Activity uses the host API; never emit terminal OSC/printf escapes. In particular,
11
+ // leave PI_PROGRAM_STATUS untouched for hosts which own that status channel.
12
+ }
13
+
14
+ /** Raise task-scoped activity when the host supports Pi's optional activity API. */
15
+ export function raiseTaskActivity(pi: PiCtx, taskId: string, message = "Async task running"): void {
16
+ // SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
17
+ const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
18
+ if (typeof ui?.setActivity !== "function") return;
19
+ ui.setActivity(`pi-async/${taskId}`, { message });
20
+ }
21
+
22
+ /** Clear task-scoped activity when the host supports Pi's optional activity API. */
23
+ export function clearTaskActivity(pi: PiCtx, taskId: string): void {
24
+ // SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
25
+ const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
26
+ if (typeof ui?.setActivity !== "function") return;
27
+ ui.setActivity(`pi-async/${taskId}`, undefined);
28
+ }
@@ -0,0 +1,41 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { TaskRegistry } from "./registry.ts";
3
+ import type { TaskStatus } from "./task.ts";
4
+
5
+ export type PiCtx = ExtensionAPI;
6
+ export interface NotifyPayload { taskId: string; name: string; status: TaskStatus; exitCode?: number; outputFile: string; summary?: string; guidance?: string; }
7
+ const escapeXml = (value: unknown): string => String(value).replace(/[<&>\"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&apos;" })[char]!);
8
+
9
+ export function buildNotificationXml(p: NotifyPayload): string {
10
+ const fields: Array<[string, unknown]> = [["task-id", p.taskId], ["name", p.name], ["status", p.status]];
11
+ if (p.exitCode !== undefined) fields.push(["exit-code", p.exitCode]);
12
+ fields.push(["output-file", p.outputFile]);
13
+ if (p.summary) fields.push(["summary", p.summary]);
14
+ if (p.status === "failed" && p.guidance) fields.push(["guidance", p.guidance]);
15
+ return `<async-task-notification>${fields.map(([tag, value]) => `<${tag}>${escapeXml(value)}</${tag}>`).join("")}</async-task-notification>`;
16
+ }
17
+
18
+ export async function sendCompletionNotification(
19
+ pi: PiCtx,
20
+ registry: TaskRegistry,
21
+ p: NotifyPayload,
22
+ options: { triggerTurn?: boolean } = {},
23
+ ): Promise<boolean> {
24
+ if (registry.get(p.taskId)?.outputConsumed || !registry.claimNotification(p.taskId)) return false;
25
+ // SAFETY: The pi extension API provides sendMessage in supported hosts; detect it at runtime for other contexts.
26
+ const target = pi as unknown as { sendMessage?: (message: unknown, options: unknown) => unknown };
27
+ if (!target.sendMessage) { registry.rollbackNotification(p.taskId); return false; }
28
+ for (let attempt = 0; attempt < 3; attempt++) {
29
+ try {
30
+ await target.sendMessage(
31
+ { customType: "async-task-notification", content: buildNotificationXml(p) },
32
+ { deliverAs: "followUp", ...(options.triggerTurn === false ? {} : { triggerTurn: true }) },
33
+ );
34
+ return true;
35
+ } catch {
36
+ if (attempt < 2) await Promise.resolve();
37
+ }
38
+ }
39
+ registry.rollbackNotification(p.taskId);
40
+ return false;
41
+ }
@@ -0,0 +1,6 @@
1
+ const ANSI_ESCAPE = /\x1B(?:\][\s\S]*?(?:\x07|\x1B\\|\x9C)|\[[0-?]*[ -/]*[@-~]|[PX^_][\s\S]*?\x1B\\|[ -/]*[0-~])|\x9B[0-?]*[ -/]*[@-~]|\x9D[\s\S]*?(?:\x07|\x1B\\|\x9C)/g;
2
+
3
+ /** Remove ANSI escape, CSI, OSC, and string-control sequences from text. */
4
+ export function stripAnsi(text: string): string {
5
+ return text.replace(ANSI_ESCAPE, "");
6
+ }