pi-async 0.1.0 → 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 +54 -0
- package/README.md +20 -0
- package/docs/CONTRACT.md +68 -17
- package/extensions/index.ts +33 -2
- package/package.json +3 -2
- package/src/core/bus.ts +11 -3
- package/src/core/duration.ts +16 -0
- package/src/core/env.ts +23 -0
- package/src/core/notify.ts +19 -3
- package/src/core/output.ts +48 -2
- package/src/core/registry.ts +197 -70
- package/src/core/spawn.ts +54 -14
- package/src/core/task.ts +25 -8
- package/src/core/watch.ts +49 -13
- package/src/guard/settle-guard.ts +3 -6
- package/src/tools/index.ts +50 -11
- package/src/tui/panel.ts +99 -38
- package/src/tui/rail.ts +73 -20
- package/src/tui/status.ts +23 -2
- package/src/tui/width.ts +53 -0
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/README.md
CHANGED
|
@@ -53,6 +53,9 @@ The panel and rail are only enabled in the main Pi TUI session; RPC, print, and
|
|
|
53
53
|
| `PI_ASYNC_MAX_TASKS` | `32` | Maximum concurrent running and pending tasks. |
|
|
54
54
|
| `PI_ASYNC_HEARTBEAT_MS` | `60000` | Settle-guard heartbeat interval in milliseconds. |
|
|
55
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. |
|
|
56
59
|
|
|
57
60
|
## Legion child sessions
|
|
58
61
|
|
|
@@ -72,6 +75,23 @@ npm run typecheck
|
|
|
72
75
|
npm test
|
|
73
76
|
```
|
|
74
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
|
+
|
|
75
95
|
## Known upstream issue (print mode)
|
|
76
96
|
|
|
77
97
|
`pi --print` + background task completing **after** the agent settles: the wake happens but the woken
|
package/docs/CONTRACT.md
CHANGED
|
@@ -28,7 +28,7 @@ pi-async/
|
|
|
28
28
|
│ │ ├── spawn.ts # detached spawn + 进程组 kill 升级链
|
|
29
29
|
│ │ ├── output.ts # 输出捕获、tail 读取、ANSI 剥离
|
|
30
30
|
│ │ ├── watch.ts # 轮询 + 条件匹配引擎
|
|
31
|
-
│ │ ├── notify.ts #
|
|
31
|
+
│ │ ├── notify.ts # 完成通知/唤醒、抑制闩(每任务一条,不合并)
|
|
32
32
|
│ │ └── bus.ts # 预留 eventbus + OSC activity 上报
|
|
33
33
|
│ ├── tools/
|
|
34
34
|
│ │ └── index.ts # 5 个工具的注册与 schema
|
|
@@ -54,8 +54,13 @@ pending --(delay 到期/spawn 成功)--> running --┬-- exit 0 --> completed
|
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
- 状态词汇:`pending | running | completed | failed | killed`;terminal = 后三者。
|
|
57
|
-
- KillKind:`user | timeout | output_cap | shutdown | watch_failure
|
|
58
|
-
|
|
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` 枚举把两者揉扁——通知与投影字段各自透传已有字段即可。
|
|
59
64
|
- delayed 任务在 pending 期可 cancel(→ killed, kind=user)。
|
|
60
65
|
- `notified` 持久闩(写进 metadata,reload 不重置),单次投递;发送失败回滚重试 ≤3 次。
|
|
61
66
|
|
|
@@ -83,7 +88,10 @@ async_run({
|
|
|
83
88
|
```
|
|
84
89
|
|
|
85
90
|
**返回(receipt)**:`{ taskId, name, status, mode, outputHint }`;sync 模式返回
|
|
86
|
-
`{ ...receipt, exitCode, outputTail }
|
|
91
|
+
`{ ...receipt, exitCode, outputTail }`。所有任务**同步启动**:spawn 成功即返回 `running`;
|
|
92
|
+
spawn 失败立即抛错,无重试等待。admission 检查为 closed / maxTasks / 空命令,均同步完成。
|
|
93
|
+
shutdown 先 drain 再退出。
|
|
94
|
+
(**REV-6 裁决**:原「≤30s admission 预检」从未实现且与同步 spawn 设计冲突,改为描述现状;见 #29。)
|
|
87
95
|
|
|
88
96
|
**工具集(5 个)**:`async_run` / `async_wait` / `async_logs` / `async_status` / `async_kill`。
|
|
89
97
|
命令面:`/async`(开面板)、`/async-clear`(清历史徽章)。
|
|
@@ -96,40 +104,54 @@ interface WatchSpec {
|
|
|
96
104
|
timeoutMs: number; // watch 必填:watch 期限(schema enforce)
|
|
97
105
|
success_when: Condition[]; // 任一命中 → 任务完成语义(不干预进程,见下)
|
|
98
106
|
failure_when: Condition[]; // 任一命中 → kill(kind=watch_failure)
|
|
99
|
-
blind_checks?: number; // 默认 3:连续 N
|
|
107
|
+
blind_checks?: number; // 默认 3:连续 N 次「检查基础设施失败」(readOutput 抛错等)
|
|
108
|
+
// → failed;「尚未可判定」为 pending,不累计(见下方 REV-2)
|
|
100
109
|
}
|
|
101
110
|
type Condition =
|
|
102
111
|
| { type: "exit_code"; equals: number }
|
|
103
112
|
| { type: "stdout_contains"; value: string }
|
|
104
113
|
| { type: "stderr_contains"; value: string }
|
|
105
|
-
| { type: "json_path_exists"; path: string } // $.a.b[0]
|
|
106
|
-
| { type: "output_matches"; regex: string } //
|
|
114
|
+
| { type: "json_path_exists"; path: string } // 必须以 $ 开头,如 $.a.b[0];启动前校验;tool schema 含 pattern/description(#12)
|
|
115
|
+
| { type: "output_matches"; regex: string } // 合并输出流匹配(设备码等);编译带 m(^/$ 按行)
|
|
107
116
|
```
|
|
108
117
|
|
|
109
118
|
- `success_when` 命中 → **不杀进程**:任务标 completed,通知带命中详情;进程按 taskTimeoutMs/kill 自然管理。
|
|
110
119
|
(区别于 better-bg 的 check 命令模式:我们 watch 的是**已存在任务**的输出/退出。)
|
|
111
120
|
- 条件在 async_run 时**启动前校验**,非法 regex/JSON path 直接拒绝(不产生 pending 任务)。
|
|
112
|
-
- **blind_checks 语义(REV-2 澄清)**:仅计「检查基础设施失败」(readOutput
|
|
121
|
+
- **blind_checks 语义(REV-2 澄清)**:仅计「检查基础设施失败」(readOutput 抛错等);
|
|
113
122
|
「尚未可判定」(exit_code 未退出 / json_path 输出尚非 JSON)为 **pending 态,不累计 blind、照常
|
|
114
|
-
|
|
123
|
+
继续轮询**。两者是不同语义,禁止合并。条件持续 pending 时**不**触发 blind 兜底,仅由
|
|
124
|
+
`timeoutMs` 结束 watch(WatchSpec 字段注释已与本条对齐,见 #27)。
|
|
115
125
|
- promptGuidelines(写进工具 description):check 不要 `|| true` 自吞错误;未知态映射失败;
|
|
116
|
-
结构化输出优先 jq -e;`output_matches` 的 regex
|
|
126
|
+
结构化输出优先 jq -e;`output_matches` 的 regex 须加锚定防止过早匹配(编译固定带 `m`,
|
|
127
|
+
`^`/`$` 按行边界,故 `^READY$` 可命中多行输出中的某一行)。
|
|
117
128
|
- **watch 失败性终态的进程处置(REV-4 裁决)**:`failure_when` 命中、blind 耗尽、watch timeout
|
|
118
129
|
三条 watch 失败路径在 finalize 任务的同时**必须 kill 进程组**(与 `failure_when → killed(watch_failure)`
|
|
119
130
|
同语义);仅 `success_when`(D4)不干预进程。杀失败时响亮上报(kill 链已有 catch),
|
|
120
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` 等价写法。
|
|
121
138
|
|
|
122
139
|
## 5. async_wait(唤醒原语,冻结)
|
|
123
140
|
|
|
124
141
|
```ts
|
|
125
142
|
async_wait({ taskIds: string[], timeoutMs: number }) // timeoutMs 必填(schema enforce)
|
|
126
|
-
//
|
|
127
|
-
// →
|
|
143
|
+
// 返回三态(REV-7):
|
|
144
|
+
// → 全终态:{ results: Array<{taskId, status, exitCode?, outputTail}> }
|
|
145
|
+
// → 超时且无已完成:{ notReady: Array<{taskId, status: "running"|"pending", elapsedMs}> }(不杀任务)
|
|
146
|
+
// → 混合超时:{ results, notReady }(已完成走 waitResult/consume,抑制通知 chatter;未就绪不杀)
|
|
128
147
|
```
|
|
129
148
|
|
|
130
149
|
- 事件驱动:await 内部 waiter(任务 finalize 时 resolve),**禁止 sleep 轮询**(patty 教训)。
|
|
131
150
|
- 支持 AbortSignal(Esc);中断等待不中断任务。
|
|
132
151
|
- 消费即抑制:async_wait 取回结果的任务,完成通知被 `outputConsumed` 闩抑制(防 chatter)。
|
|
152
|
+
- **REV-7 裁决(#43)**:原契约只写「全终态 → results」与「超时 → notReady」两极,导致混合超时
|
|
153
|
+
路径丢弃已完成结果、且无法对已完成侧置 `outputConsumed`(通知抑制失效)。正式登记超集三态:
|
|
154
|
+
判据仍是「有 notReady ⇒ 超时分支」;`results` 可选并存。实现见 #56,本 REV 只改契约。
|
|
133
155
|
|
|
134
156
|
## 6. 唤醒链路与 legion 兼容(冻结)
|
|
135
157
|
|
|
@@ -140,8 +162,24 @@ async_wait({ taskIds: string[], timeoutMs: number }) // timeoutMs 必填(sch
|
|
|
140
162
|
| legion child,跨 turn | **hold 模式**:settle 前守卫上报 busy(见 §7);上游 #156 busy-hold 落地前,通知可能无法送达——**契约要求 agent 在 legion 环境用 async_wait 收割后再报 verdict**(守卫强制提醒,最多 3 次) |
|
|
141
163
|
|
|
142
164
|
- 环境检测:`PI_LEGION_NODE_ID` 存在 → legion child 环境,settle 守卫默认开启。
|
|
143
|
-
- 通知 XML:`<async-task-notification>` + task-id/name/status/exit-code
|
|
165
|
+
- 通知 XML:`<async-task-notification>` + task-id/name/status/exit-code/**kill-kind**/**failure-kind**/output-file/summary/**summary-truncated**/guidance
|
|
144
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)。
|
|
145
183
|
- 心跳:有 running/pending 任务期间,每 60s `pi.appendEntry` 一条 heartbeat(触发 legion
|
|
146
184
|
`collectArtifactGrowing` 宽限续期);session jsonl 增长由守卫的提醒消息自然产生,心跳为兜底。
|
|
147
185
|
|
|
@@ -153,6 +191,9 @@ async_wait({ taskIds: string[], timeoutMs: number }) // timeoutMs 必填(sch
|
|
|
153
191
|
- OSC busy 上报(feature-detect,无 API 则跳过):
|
|
154
192
|
- `ui.setActivity("pi-async", { message: "N async tasks" })`,全清时 `undefined`
|
|
155
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 静默无效。
|
|
156
197
|
- **禁止 printf OSC**;尊重 `PI_PROGRAM_STATUS`
|
|
157
198
|
- 上报时机:任务启动、终态、以及 `agent_before_settle`(闭合 retire 竞态窗口的关键,见 #156 补充评论)
|
|
158
199
|
- RPC(legion child)内:setActivity 依赖 legion `extension_activity` 转发(#156 落地后生效),
|
|
@@ -180,7 +221,7 @@ async_wait({ taskIds: string[], timeoutMs: number }) // timeoutMs 必填(sch
|
|
|
180
221
|
## 9. 输出与预算(冻结)
|
|
181
222
|
|
|
182
223
|
- 写盘 cap:默认 20 MiB(`PI_ASYNC_MAX_OUTPUT_BYTES`),超限 → kill(kind=output_cap) → failed。
|
|
183
|
-
- 模型读取:`async_logs { taskId, maxBytes≤50KiB, tail=true }`,truncated
|
|
224
|
+
- 模型读取:`async_logs { taskId, maxBytes≤50KiB, tail=true }`,truncated 带全量路径提示(`fullPath` 有效至被 §10 retention prune)。
|
|
184
225
|
- ANSI/CSI/OSC 剥离:`async_logs` 返回与 TUI 显示前必须剥离(安全项,防输出注入转义序列)。
|
|
185
226
|
- sync 返回 outputTail ≤ 8 KiB / 200 行(whichever first)。
|
|
186
227
|
|
|
@@ -196,6 +237,14 @@ async_wait({ taskIds: string[], timeoutMs: number }) // timeoutMs 必填(sch
|
|
|
196
237
|
`<session-id>-<pid>`);跨 pid 崩溃遗留目录只读留档、不恢复,崩溃后遗留的 pending 延迟任务
|
|
197
238
|
不会自动续跑。理由:跨 pid 收养需处理写入归属与幂等重建,与 v1 不承诺 crash-durability 的
|
|
198
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>`)的整目录回收不在本条范围(仍只读留档)。
|
|
199
248
|
|
|
200
249
|
## 11. 配置与环境变量
|
|
201
250
|
|
|
@@ -205,6 +254,8 @@ async_wait({ taskIds: string[], timeoutMs: number }) // timeoutMs 必填(sch
|
|
|
205
254
|
| `PI_ASYNC_MAX_TASKS` | 32 | 注册表上限,超了拒绝新任务 |
|
|
206
255
|
| `PI_ASYNC_HEARTBEAT_MS` | 60_000 | settle 守卫心跳 |
|
|
207
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 取并集 |
|
|
208
259
|
|
|
209
260
|
## 12. 工程约束
|
|
210
261
|
|
|
@@ -236,9 +287,9 @@ async_wait({ taskIds: string[], timeoutMs: number }) // timeoutMs 必填(sch
|
|
|
236
287
|
`<async-task-notification>` 并继续 turn)
|
|
237
288
|
3. sync:短命令阻塞返回 exitCode;长命令 timeoutMs 到点 promote 转 background,任务不死
|
|
238
289
|
4. delayed:delayMs 后启动;pending 期 cancel;reload 后恢复
|
|
239
|
-
5. watch:stdout_contains/output_matches 命中 completed
|
|
240
|
-
|
|
241
|
-
6. async_wait:多任务等待全部终态;timeout 返回 notReady
|
|
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 中断不杀任务
|
|
242
293
|
7. 输出安全:注入 ANSI 转义的任务输出,async_logs 返回已剥离
|
|
243
294
|
8. kill:进程组全灭(子进程不残留);20MiB 超限 kill(output_cap)
|
|
244
295
|
9. settle 守卫:有未决任务时 agent_before_settle 注入提醒并 continue;3 次后放行
|
package/extensions/index.ts
CHANGED
|
@@ -33,9 +33,29 @@ function registryDataDir(pi: PiCtx): string {
|
|
|
33
33
|
return join(cwd, ".pi", "async", `${safeSessionId}-${pid}`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Previous init generation; /reload re-runs init without session_shutdown (#44). */
|
|
37
|
+
let activeRegistry: TaskRegistry | undefined;
|
|
38
|
+
let activeDisposeTui: (() => void) | undefined;
|
|
39
|
+
|
|
36
40
|
/** Wire the task runtime to the host API, degrading optional Pi features gracefully. */
|
|
37
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
|
+
}
|
|
38
57
|
const registry = new TaskRegistry({ dataDir: registryDataDir(pi) });
|
|
58
|
+
activeRegistry = registry;
|
|
39
59
|
// NOTE: pi's factory-time ExtensionAPI exposes no `mode`/`ui` — those live on
|
|
40
60
|
// event-handler contexts (pi.on("session_start", (event, ctx) => ...)). TUI
|
|
41
61
|
// registration must therefore bind lazily from the first event context.
|
|
@@ -55,8 +75,10 @@ export async function init(pi: PiCtx): Promise<void> {
|
|
|
55
75
|
if (tui) initStatus(pi, registry);
|
|
56
76
|
if (snapshot.metadata.notifyOnCompletion === false) return;
|
|
57
77
|
const outputFile = String(snapshot.metadata.outputFile ?? "");
|
|
58
|
-
const
|
|
78
|
+
const logSlice = registry.logs(snapshot.taskId, { maxBytes: 8192, tail: true });
|
|
79
|
+
const outputTail = logSlice.text;
|
|
59
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;
|
|
60
82
|
void sendCompletionNotification(pi, registry, {
|
|
61
83
|
taskId: snapshot.taskId,
|
|
62
84
|
name: snapshot.name,
|
|
@@ -65,6 +87,9 @@ export async function init(pi: PiCtx): Promise<void> {
|
|
|
65
87
|
outputFile,
|
|
66
88
|
...(outputTail ? { summary: outputTail } : {}),
|
|
67
89
|
...(guidance ? { guidance } : {}),
|
|
90
|
+
...(snapshot.killKind ? { killKind: snapshot.killKind } : {}),
|
|
91
|
+
...(snapshot.failureKind ? { failureKind: snapshot.failureKind } : {}),
|
|
92
|
+
...(summaryTruncated ? { summaryTruncated: true } : {}),
|
|
68
93
|
}, { triggerTurn: snapshot.metadata.triggerOnCompletion !== false })
|
|
69
94
|
.catch((error: unknown) => console.error(`pi-async: completion notification failed for ${snapshot.taskId}`, error));
|
|
70
95
|
},
|
|
@@ -92,11 +117,17 @@ export async function init(pi: PiCtx): Promise<void> {
|
|
|
92
117
|
}
|
|
93
118
|
if (typeof context.on === "function") new SettleGuard(pi, registry).register();
|
|
94
119
|
|
|
120
|
+
activeDisposeTui = () => {
|
|
121
|
+
if (tui) disposeStatus();
|
|
122
|
+
};
|
|
123
|
+
|
|
95
124
|
let shutdownPromise: Promise<void> | undefined;
|
|
96
125
|
if (typeof context.on === "function") {
|
|
97
126
|
context.on("session_shutdown", async () => {
|
|
98
127
|
if (!shutdownPromise) shutdownPromise = (async () => {
|
|
99
|
-
|
|
128
|
+
activeDisposeTui?.();
|
|
129
|
+
activeDisposeTui = undefined;
|
|
130
|
+
if (activeRegistry === registry) activeRegistry = undefined;
|
|
100
131
|
await registry.shutdown();
|
|
101
132
|
})();
|
|
102
133
|
await shutdownPromise;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-async",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"src/",
|
|
40
40
|
"README.md",
|
|
41
41
|
"LICENSE",
|
|
42
|
-
"docs/CONTRACT.md"
|
|
42
|
+
"docs/CONTRACT.md",
|
|
43
|
+
"CHANGELOG.md"
|
|
43
44
|
],
|
|
44
45
|
"scripts": {
|
|
45
46
|
"typecheck": "tsc --noEmit",
|
package/src/core/bus.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import type { PiCtx } from "./notify.ts";
|
|
2
2
|
|
|
3
|
+
/** Aggregate activity source id (frozen §7). */
|
|
4
|
+
const AGGREGATE_ACTIVITY_ID = "pi-async";
|
|
5
|
+
|
|
6
|
+
/** Per-task activity id — legion #156 / osc-busy-plugin: `plugin/<name>/…`. */
|
|
7
|
+
export function taskActivityId(taskId: string): string {
|
|
8
|
+
return `plugin/pi-async/${taskId}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
/** Report aggregate async work through Pi's activity API when the host exposes it. */
|
|
4
12
|
export function reportActivity(pi: PiCtx, active: { running: number; pending: number }): void {
|
|
5
13
|
// SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
|
|
6
14
|
const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
|
|
7
15
|
if (typeof ui?.setActivity !== "function") return;
|
|
8
16
|
const count = active.running + active.pending;
|
|
9
|
-
ui.setActivity(
|
|
17
|
+
ui.setActivity(AGGREGATE_ACTIVITY_ID, count === 0 ? undefined : { message: `${count} async tasks` });
|
|
10
18
|
// Activity uses the host API; never emit terminal OSC/printf escapes. In particular,
|
|
11
19
|
// leave PI_PROGRAM_STATUS untouched for hosts which own that status channel.
|
|
12
20
|
}
|
|
@@ -16,7 +24,7 @@ export function raiseTaskActivity(pi: PiCtx, taskId: string, message = "Async ta
|
|
|
16
24
|
// SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
|
|
17
25
|
const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
|
|
18
26
|
if (typeof ui?.setActivity !== "function") return;
|
|
19
|
-
ui.setActivity(
|
|
27
|
+
ui.setActivity(taskActivityId(taskId), { message });
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/** Clear task-scoped activity when the host supports Pi's optional activity API. */
|
|
@@ -24,5 +32,5 @@ export function clearTaskActivity(pi: PiCtx, taskId: string): void {
|
|
|
24
32
|
// SAFETY: PiCtx is the installed extension API; ui.setActivity is feature-detected for versions without it.
|
|
25
33
|
const ui = (pi as unknown as { ui?: { setActivity?: (id: string, activity: { message: string } | undefined) => void } }).ui;
|
|
26
34
|
if (typeof ui?.setActivity !== "function") return;
|
|
27
|
-
ui.setActivity(
|
|
35
|
+
ui.setActivity(taskActivityId(taskId), undefined);
|
|
28
36
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TaskSnapshot } from "./task.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format a task's elapsed duration for TUI surfaces.
|
|
5
|
+
* - pending → em dash (not started)
|
|
6
|
+
* - end base = finishedAt ?? now (terminal states freeze; live tasks tick)
|
|
7
|
+
* - three tiers: Ns / Mm Ss / Hh Mm
|
|
8
|
+
*/
|
|
9
|
+
export function formatDuration(task: TaskSnapshot, now: number): string {
|
|
10
|
+
if (task.status === "pending") return "—";
|
|
11
|
+
const end = task.finishedAt ?? now;
|
|
12
|
+
const seconds = Math.floor(Math.max(0, end - (task.startedAt ?? task.createdAt)) / 1000);
|
|
13
|
+
if (seconds < 60) return `${seconds}s`;
|
|
14
|
+
const minutes = Math.floor(seconds / 60);
|
|
15
|
+
return minutes < 60 ? `${minutes}m ${seconds % 60}s` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
16
|
+
}
|
package/src/core/env.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface EnvNumberOpts {
|
|
2
|
+
min?: number;
|
|
3
|
+
max?: number;
|
|
4
|
+
/** When true, non-integers fall back (avoids Number("")===0 and 2.7→3 surprises). */
|
|
5
|
+
integer?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a numeric env var with empty-string → fallback (not Number("")===0).
|
|
10
|
+
* Non-finite / out-of-range / non-integer (when requested) also fall back.
|
|
11
|
+
*/
|
|
12
|
+
export function envNumber(name: string, fallback: number, opts?: EnvNumberOpts): number {
|
|
13
|
+
const raw = process.env[name];
|
|
14
|
+
if (raw === undefined) return fallback;
|
|
15
|
+
const trimmed = raw.trim();
|
|
16
|
+
if (trimmed === "") return fallback;
|
|
17
|
+
const parsed = Number(trimmed);
|
|
18
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
19
|
+
if (opts?.integer && !Number.isInteger(parsed)) return fallback;
|
|
20
|
+
if (opts?.min !== undefined && parsed < opts.min) return fallback;
|
|
21
|
+
if (opts?.max !== undefined && parsed > opts.max) return fallback;
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
package/src/core/notify.ts
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { TaskRegistry } from "./registry.ts";
|
|
3
|
-
import type { TaskStatus } from "./task.ts";
|
|
3
|
+
import type { FailureKind, KillKind, TaskStatus } from "./task.ts";
|
|
4
4
|
|
|
5
5
|
export type PiCtx = ExtensionAPI;
|
|
6
|
-
export interface NotifyPayload {
|
|
7
|
-
|
|
6
|
+
export interface NotifyPayload {
|
|
7
|
+
taskId: string; name: string; status: TaskStatus; exitCode?: number;
|
|
8
|
+
outputFile: string; summary?: string; guidance?: string;
|
|
9
|
+
killKind?: KillKind;
|
|
10
|
+
failureKind?: FailureKind;
|
|
11
|
+
summaryTruncated?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** XML 1.0 Char excludes C0 (except TAB/LF/CR), lone surrogates, and U+FFFE/FFFF (#19). */
|
|
15
|
+
const ILLEGAL_XML = /[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]/g;
|
|
16
|
+
|
|
17
|
+
const escapeXml = (value: unknown): string =>
|
|
18
|
+
String(value)
|
|
19
|
+
.replace(ILLEGAL_XML, "")
|
|
20
|
+
.replace(/[<&>\"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" })[char]!);
|
|
8
21
|
|
|
9
22
|
export function buildNotificationXml(p: NotifyPayload): string {
|
|
10
23
|
const fields: Array<[string, unknown]> = [["task-id", p.taskId], ["name", p.name], ["status", p.status]];
|
|
11
24
|
if (p.exitCode !== undefined) fields.push(["exit-code", p.exitCode]);
|
|
25
|
+
if (p.killKind) fields.push(["kill-kind", p.killKind]);
|
|
26
|
+
if (p.failureKind) fields.push(["failure-kind", p.failureKind]);
|
|
12
27
|
fields.push(["output-file", p.outputFile]);
|
|
13
28
|
if (p.summary) fields.push(["summary", p.summary]);
|
|
29
|
+
if (p.summaryTruncated) fields.push(["summary-truncated", "true"]);
|
|
14
30
|
if (p.status === "failed" && p.guidance) fields.push(["guidance", p.guidance]);
|
|
15
31
|
return `<async-task-notification>${fields.map(([tag, value]) => `<${tag}>${escapeXml(value)}</${tag}>`).join("")}</async-task-notification>`;
|
|
16
32
|
}
|
package/src/core/output.ts
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
|
-
const ANSI_ESCAPE =
|
|
1
|
+
const ANSI_ESCAPE =
|
|
2
|
+
/\x1B(?:\][\s\S]*?(?:\x07|\x1B\\|\x9C)|\][\s\S]*$|\[[0-?]*[ -/]*[@-~]|\[[0-?]*[ -/]*$|[PX^_][\s\S]*?\x1B\\|[ -/]*[0-~])|\x9B[0-?]*[ -/]*[@-~]|\x9D[\s\S]*?(?:\x07|\x1B\\|\x9C)/g;
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
+
/** Keep tab/LF/CR; drop other C0 controls that must not reach model/TUI/XML. */
|
|
5
|
+
const C0_CONTROLS = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
|
|
6
|
+
|
|
7
|
+
/** Remove ANSI escape, CSI, OSC, and string-control sequences from text.
|
|
8
|
+
* Unterminated OSC/CSI are dropped through end-of-string so truncation mid-sequence
|
|
9
|
+
* does not leave the payload as visible garbage (#32). */
|
|
4
10
|
export function stripAnsi(text: string): string {
|
|
5
11
|
return text.replace(ANSI_ESCAPE, "");
|
|
6
12
|
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Strip bare C0 control bytes (except TAB/LF/CR which are folded to spaces)
|
|
16
|
+
* and collapse runs of whitespace. Use after stripAnsi for TUI-visible text.
|
|
17
|
+
* Keeps panel and rail sanitization aligned (CONTRACT §9 / issue #3).
|
|
18
|
+
*/
|
|
19
|
+
export function stripControl(text: string): string {
|
|
20
|
+
return text
|
|
21
|
+
.replace(C0_CONTROLS, "")
|
|
22
|
+
.replace(/[\r\n\t]+/g, " ");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** ANSI + C0 sanitizer for TUI labels (task names, panel rows). */
|
|
26
|
+
export function visibleText(value: unknown): string {
|
|
27
|
+
return stripControl(stripAnsi(String(value ?? "")));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Model/logs path: strip ANSI then bare C0, preserve TAB/LF/CR (#18). */
|
|
31
|
+
export function stripLogControls(text: string): string {
|
|
32
|
+
return stripAnsi(text).replace(C0_CONTROLS, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Truncate a UTF-8 buffer to at most `maxBytes`, cutting on a leading-byte boundary
|
|
37
|
+
* so multi-byte characters are never split into U+FFFD (#17).
|
|
38
|
+
* When `tail` is true, keep the end of the buffer (advance past continuation bytes).
|
|
39
|
+
*/
|
|
40
|
+
export function truncateUtf8Bytes(bytes: Buffer, maxBytes: number, tail = true): string {
|
|
41
|
+
if (maxBytes <= 0) return "";
|
|
42
|
+
if (bytes.byteLength <= maxBytes) return bytes.toString("utf8");
|
|
43
|
+
if (!tail) {
|
|
44
|
+
let end = maxBytes;
|
|
45
|
+
// Walk back while on a UTF-8 continuation byte so we don't cut mid-sequence.
|
|
46
|
+
while (end > 0 && (bytes[end]! & 0xc0) === 0x80) end--;
|
|
47
|
+
return bytes.subarray(0, end).toString("utf8");
|
|
48
|
+
}
|
|
49
|
+
let start = bytes.byteLength - maxBytes;
|
|
50
|
+
while (start < bytes.byteLength && (bytes[start]! & 0xc0) === 0x80) start++;
|
|
51
|
+
return bytes.subarray(start).toString("utf8");
|
|
52
|
+
}
|