@zhushanwen/pi-base-tool-enhance 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.
Files changed (35) hide show
  1. package/README.md +31 -0
  2. package/index.ts +1 -0
  3. package/package.json +54 -0
  4. package/skills/base-tool-enhance-ext-config/SKILL.md +76 -0
  5. package/src/__tests__/background-lifecycle.test.ts +634 -0
  6. package/src/__tests__/bash-tool.test.ts +573 -0
  7. package/src/__tests__/config.test.ts +193 -0
  8. package/src/__tests__/force-patterns.test.ts +230 -0
  9. package/src/__tests__/index.test.ts +133 -0
  10. package/src/__tests__/kill-tree.test.ts +76 -0
  11. package/src/__tests__/notify.test.ts +335 -0
  12. package/src/__tests__/pending-reconcile.test.ts +237 -0
  13. package/src/__tests__/reaper.test.ts +373 -0
  14. package/src/__tests__/registry.test.ts +149 -0
  15. package/src/__tests__/task-store.test.ts +156 -0
  16. package/src/__tests__/tool-error-audit.test.ts +92 -0
  17. package/src/background/notify.ts +218 -0
  18. package/src/background/output-tail.ts +84 -0
  19. package/src/background/pending-reconcile.ts +169 -0
  20. package/src/background/poller.ts +91 -0
  21. package/src/background/process-exit-guard.ts +106 -0
  22. package/src/background/registry.ts +203 -0
  23. package/src/background/spawn-background.ts +275 -0
  24. package/src/background/subagent-guard.ts +21 -0
  25. package/src/background/task-store.ts +125 -0
  26. package/src/background/types.ts +103 -0
  27. package/src/bash-kill-tool.ts +144 -0
  28. package/src/bash-output-tool.ts +131 -0
  29. package/src/bash-tool.ts +226 -0
  30. package/src/config.ts +167 -0
  31. package/src/force-patterns.ts +236 -0
  32. package/src/index.ts +90 -0
  33. package/src/kill-tree.ts +100 -0
  34. package/src/reaper.ts +313 -0
  35. package/src/tool-error-audit.ts +78 -0
package/src/index.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @zhushanwen/pi-base-tool-enhance 入口。
3
+ *
4
+ * M1:同名 override pi 内置 bash 工具 + 前台委托官方工厂 + 工具报错审计 hook(D11)。
5
+ * M2:background 任务核心生命周期——bash background 分支(spawn 后台 + registry +
6
+ * 轮询器单例任务表)、bash_output / bash_kill 工具、进程退出收殓、subagent 降级。
7
+ * M3:pending-notifications 通知接入——load 时刷新轮询器通知通路的 pi 引用(D17
8
+ * session 替换接管)+ 挂 exit 边沿通知回调(unregister emit + sendMessage steer)+
9
+ * session_start 对账(reaper 先、对账后,appendEntry 权威路径兜底 pending 收尾)。
10
+ * 白名单与配置体系(M4 已交付)经 bash-tool execute 读时加载接入。
11
+ */
12
+
13
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
15
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
16
+
17
+ import { createBashOutputToolDefinition } from "./bash-output-tool.ts";
18
+ import { createBashKillToolDefinition } from "./bash-kill-tool.ts";
19
+ import { createBashOverrideToolDefinition } from "./bash-tool.ts";
20
+ import { handleTaskExit, refreshPiReference } from "./background/notify.ts";
21
+ import { installProcessExitGuard } from "./background/process-exit-guard.ts";
22
+ import { reconcilePendingEntries } from "./background/pending-reconcile.ts";
23
+ import { setOnTaskExit } from "./background/poller.ts";
24
+ import { reapOrphanedTasks } from "./reaper.ts";
25
+ import { setupToolErrorAudit } from "./tool-error-audit.ts";
26
+
27
+ const logger = getLogger("base-tool-enhance");
28
+
29
+ export default function baseToolEnhanceExtension(pi: ExtensionAPI): void {
30
+ // D17 pi 引用刷新:同进程 session 替换(/fork、选择器切换、RPC session.*)→ 新
31
+ // ResourceLoader/eventBus + extension 重新 load → 本调用把通知通路(notify.ts
32
+ // 模块级引用)切到新 pi——任务发起于旧 session 而完成通知投递新 session。
33
+ refreshPiReference(pi);
34
+ // ⑧⑨ 轮询器 exit 边沿 → pending:unregister emit + sendMessage steer(kill 路径
35
+ // 不 sendMessage,见 notify.ts 单点归属规则)。重复 load 幂等(覆盖同一回调)
36
+ setOnTaskExit(handleTaskExit);
37
+ // 同名 "bash" 覆盖内置工具(pi agent-session _refreshToolRegistry:custom 定义后注册者胜)
38
+ pi.registerTool(createBashOverrideToolDefinition());
39
+ // 查询 / 终止工具(D9:独立小工具,kill 与查询权限语义分离)
40
+ pi.registerTool(createBashOutputToolDefinition());
41
+ pi.registerTool(createBashKillToolDefinition());
42
+ // 进程级收殓(D12):只认 process 信号/退出,绝不在 session_shutdown / dispose 路径
43
+ installProcessExitGuard();
44
+ // unified-hooks 退役承接:工具报错审计(D11 落点)
45
+ setupToolErrorAudit(pi);
46
+ // 孤儿收殓(M5)+ pending 对账(M3):任意 session 启动触发(startup/reload/new/resume/fork 全 reason)
47
+ pi.on("session_start", (_event, ctx: ExtensionContext) => {
48
+ void runSessionStartMaintenance(pi, ctx);
49
+ });
50
+ }
51
+
52
+ /**
53
+ * session_start 维护链(M5 reaper + M3 对账,reaper 先、对账后)。
54
+ *
55
+ * 执行形态:fire-and-forget 而非 await——pi extension runner 对 session_start
56
+ * handler 是顺序 await(runner.js emit:逐 handler await),若本 handler await
57
+ * reaper,多进程锁竞争(reaper.lock 等待 + registry 写 busy-wait)会把秒级延迟
58
+ * 累计进 session 启动链。reaper 是兜底机制,不要求启动时序内完成,扔后台跑、
59
+ * 错误吞掉记 warn(孤儿保持原状,下一 session_start 幂等重试)。
60
+ *
61
+ * 对账同步毫秒级(readRegistry + kill(pid,0) + appendEntry),排在 reaper await
62
+ * 之后同一 async 函数体内顺序执行——先处置孤儿/补写 registry 终态,对账随后读到
63
+ * 正确终态;顺序颠倒也无静默错误(对账先见 running+pid 活则不动作,下一
64
+ * session_start 兜底),按设计约定维持 reaper 先行。
65
+ *
66
+ * 不节流(同进程多次 session_start——CLI /fork /switch 均触发,每次都扫):
67
+ * 幂等性由三分支判定构造性保证(终态条目跳过、属主活跳过——二次扫描对已处置
68
+ * 孤儿天然 no-op);无孤儿时扫描成本 = 目录枚举 + 每目录一次 JSON 读 + 每
69
+ * running 条目一次 kill(pid,0),毫秒级且零子进程开销(ps 只在「属主死 + pid 活」
70
+ * 的孤儿判定时才调用)。节流反而引入「最近扫描后的新孤儿延迟收殓」窗口,不抵。
71
+ */
72
+ async function runSessionStartMaintenance(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
73
+ const dataDir = getAgentDir();
74
+ try {
75
+ await reapOrphanedTasks(dataDir);
76
+ } catch (err) {
77
+ logger.warn("session_start reaper failed; orphans stay until next session start", {
78
+ detail: { err: err instanceof Error ? err.message : String(err) },
79
+ });
80
+ }
81
+ try {
82
+ const sessionId = ctx.sessionManager.getSessionId();
83
+ reconcilePendingEntries(pi, dataDir, sessionId, ctx.sessionManager.getEntries());
84
+ } catch (err) {
85
+ // 对账失败无害:僵尸 register 停留差集,下一 session_start 幂等重查
86
+ logger.warn("session_start pending reconcile failed; zombies retried next session start", {
87
+ detail: { err: err instanceof Error ? err.message : String(err) },
88
+ });
89
+ }
90
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 自实现进程树 kill 与 pid 判活(设计文档 §3.5 bash_kill)。
3
+ *
4
+ * 为什么不用 pi 的 killProcessTree:它未从主入口导出(pi package.json exports 仅
5
+ * `.`/`./rpc-entry`/`./client`,定义在 dist/utils/shell.d.ts 但子路径不暴露),不可
6
+ * import。分支语义与 pi 实装对齐:Windows `taskkill /F /T`、POSIX 杀进程组
7
+ * `kill -- -<pgid>`(本包后台任务 detached spawn 自成进程组,pgid = pid);进程组
8
+ * 杀不到时回退单 pid + `pgrep -P` 递归杀残留子进程。
9
+ */
10
+
11
+ import { spawnSync } from "node:child_process";
12
+
13
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
14
+
15
+ const logger = getLogger("base-tool-enhance");
16
+
17
+ /**
18
+ * pid 判活:kill(pid, 0) 不发信号只做权限校验。
19
+ * ESRCH = 已死(含 libuv 自动 reap 后);EPERM = 进程存在但属其他用户,仍视为活。
20
+ */
21
+ export function isPidAlive(pid: number): boolean {
22
+ if (!Number.isInteger(pid) || pid <= 0) return false;
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ } catch (err) {
27
+ return (err as NodeJS.ErrnoException).code === "EPERM";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * 杀整棵进程树(同步;收殓路径在 process.on("exit") 里跑,必须同步)。
33
+ * 幂等:目标已死时静默成功。
34
+ */
35
+ export function killProcessTree(pid: number): void {
36
+ if (!Number.isInteger(pid) || pid <= 0) return;
37
+ if (process.platform === "win32") {
38
+ killProcessTreeWindows(pid);
39
+ return;
40
+ }
41
+ // POSIX:detached spawn 自成进程组(pgid = pid),杀进程组一次性覆盖全部子孙
42
+ try {
43
+ process.kill(-pid, "SIGKILL");
44
+ return;
45
+ } catch (err) {
46
+ logger.debug("process group kill missed, falling back to single pid + descendants", {
47
+ detail: { pid, err: err instanceof Error ? err.message : String(err) },
48
+ });
49
+ }
50
+ // 回退:组长已死(进程组不复存在)时单杀 pid + pgrep -P 递归清理残留子进程
51
+ try {
52
+ process.kill(pid, "SIGKILL");
53
+ } catch (err) {
54
+ // 目标已死:kill 幂等语义,仅留诊断
55
+ logger.debug("single pid kill missed (already dead?)", {
56
+ detail: { pid, err: err instanceof Error ? err.message : String(err) },
57
+ });
58
+ }
59
+ killDescendantsRecursive(pid);
60
+ }
61
+
62
+ function killProcessTreeWindows(pid: number): void {
63
+ try {
64
+ const result = spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], {
65
+ stdio: "ignore",
66
+ windowsHide: true,
67
+ });
68
+ if (result.error) throw result.error;
69
+ } catch (err) {
70
+ logger.debug("taskkill failed", {
71
+ detail: { pid, err: err instanceof Error ? err.message : String(err) },
72
+ });
73
+ }
74
+ }
75
+
76
+ /** pgrep -P 递归:先杀孙辈再杀子辈(防孙辈在父死后被 reparent 逃逸枚举)。 */
77
+ function killDescendantsRecursive(pid: number): void {
78
+ let stdout: string;
79
+ try {
80
+ const result = spawnSync("pgrep", ["-P", String(pid)], { encoding: "utf8" });
81
+ if (result.error || result.status !== 0 || !result.stdout) return;
82
+ stdout = result.stdout;
83
+ } catch {
84
+ return;
85
+ }
86
+ for (const line of stdout.split("\n")) {
87
+ const childPid = Number.parseInt(line.trim(), 10);
88
+ if (Number.isInteger(childPid) && childPid > 0) {
89
+ killDescendantsRecursive(childPid);
90
+ try {
91
+ process.kill(childPid, "SIGKILL");
92
+ } catch (err) {
93
+ // 已死:kill 幂等语义,仅留诊断
94
+ logger.debug("descendant kill missed (already dead?)", {
95
+ detail: { pid: childPid, err: err instanceof Error ? err.message : String(err) },
96
+ });
97
+ }
98
+ }
99
+ }
100
+ }
package/src/reaper.ts ADDED
@@ -0,0 +1,313 @@
1
+ /**
2
+ * reaper 孤儿收殓(M5,设计文档 docs/design/base-tool-enhance.md §3.5 数据流末段 /
3
+ * §2.3 术语 ownerPiPid·reaper / §3.3 D8·D12 / §3.6「reaper 误判防御」/ §4 S5·S8-B)。
4
+ *
5
+ * 职责:任意 session 启动时扫描 <dataDir>/base-tool-enhance/ 下全部 sessionId
6
+ * 目录的 registry.json,按**属主判定**处置孤儿——pi 被强杀(SIGKILL)/崩溃后
7
+ * detached 后台任务被 init 收养永久存活的兜底防线。R4 轮教训:「一律收殓」会
8
+ * 误杀桌面端并行 session(每 session 独立 pi 进程)的合法任务——**属主判定是
9
+ * 这道防线的全部依据,宁可漏杀不可误杀**。
10
+ *
11
+ * 三分支判定(§3.5 原文逐字落实):
12
+ * ①属主判定——条目 ownerPiPid 进程仍活 → 跳过(活进程的合法任务,桌面端并行
13
+ * session 的常态;kill(pid,0) 判活,ESRCH=死)
14
+ * ②孤儿补杀——属主已死 && 任务 pid 存活 → kill-tree 补杀(复用 kill-tree.ts)
15
+ * + registry 标 state:"orphaned"
16
+ * ③终态收尾——属主已死 && 任务 pid 已死 && 条目仍 running(graceful 收殓写盘
17
+ * 没完成的遗留)→ 不补杀,仅转终态 state:"orphaned"(registry 终态闭环,
18
+ * M3 对账以此判据)
19
+ *
20
+ * killing 条目:属主死 → 一并按孤儿处理(bash_kill 已发令但属主死前没等到轮询
21
+ * 边沿确认,补杀幂等无害);属主活 → 跳过(属主自己的轮询器正在收尾瞬态)。
22
+ * exited/orphaned 已终态跳过——这是二次扫描幂等 no-op 的构造性来源。
23
+ *
24
+ * pid 复用防御(§3.6):判「任务 pid 存活」时校验进程 start time,与条目登记值
25
+ * 不匹配视为已死(防系统复用 pid 后误杀无辜进程);无法取 start time 的平台
26
+ * 保守跳过整个处置(宁延迟勿误杀,worktree-manager 同原则)。终态收尾分支③
27
+ * 无需校验——kill(pid,0) ESRCH 无歧义,校验只服务「判活防复用」。
28
+ *
29
+ * 已知缺口→已闭合(M3 补写):spawn 侧现已写入 pidStartTime(spawn-background.ts
30
+ * spawn 后读 ps start time,读取失败省略),新条目走精确比较(同单位 epoch 秒)。
31
+ * 存量旧条目(M3 之前登记、缺该字段)仍降级用 startedAt 秒级校验兜底:
32
+ * actualStartSec <= floor(startedAt/1000) 视为原进程——登记发生在 spawn 之后(进程
33
+ * 先启动、条目后登记),原进程必然满足降级判据(floor 单调性,零误跳)。误杀窗口
34
+ * 如实描述:原进程可在 spawn 与登记之间的毫秒窗口内死亡,pid 又被复用——复用进程
35
+ * 的 start time 只需晚于原进程死亡(不必然晚于登记时刻),故降级判据的实际误杀窗口
36
+ * 是「spawn 所在秒内原进程死亡且 pid 被复用」(含登记前死亡+复用与登记后同秒复用
37
+ * 两种形态),不止「登记后同秒」(概率趋零,方向已登记)。
38
+ *
39
+ * 多进程并发串行化:扫描/补杀/写 registry 全程持跨进程文件锁(固定名
40
+ * reaper.lock)——防两个 pi 进程同时 reap 同一批条目(kill 幂等无害,但 RMW
41
+ * 写会交错覆盖终态)。误杀防御不依赖锁(属主判定承担),锁只消灭扫描/写入
42
+ * 交错。fn 内全同步(readdir/read/kill/write 毫秒级),满足 file-lock
43
+ * 「fn 内禁止任何 await」契约;registry 条目写在 reaper 锁内再取 registry.json
44
+ * 自身的锁(writeRegistryEntry 内部)——锁序恒为 reaper.lock → registry 锁,
45
+ * spawn 侧只取 registry 锁,无环无死锁。
46
+ */
47
+
48
+ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
49
+ import { readdirSync, type Dirent } from "node:fs";
50
+ import { join } from "node:path";
51
+
52
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
53
+ import { withFileLock } from "@zhushanwen/pi-file-lock";
54
+
55
+ import { getBaseToolEnhanceDir, readRegistry, writeRegistryEntry } from "./background/registry.ts";
56
+ import { isActiveState, type RegistryEntry } from "./background/types.ts";
57
+ import { isPidAlive, killProcessTree } from "./kill-tree.ts";
58
+
59
+ const logger = getLogger("base-tool-enhance");
60
+
61
+ /** ps 调用超时:卡死的 ps 不拖垮 reaper(超时按取不到处理 → 保守跳过)。 */
62
+ const PS_TIMEOUT_MS = 5000;
63
+ /** 毫秒 → 秒(epoch 秒换算,spawn-background.ts 同名常量先例)。 */
64
+ const MS_PER_SECOND = 1000;
65
+
66
+ /**
67
+ * 锁目标名(proper-lockfile 落 <目标>.lock;4.x 实测锁文件是 **mkdir 目录**形态,
68
+ * 不是普通文件——扫描时须按名排除,否则每轮对锁目录做一次无谓 readRegistry
69
+ * 且计入 scannedDirs)。
70
+ */
71
+ const REAPER_LOCK_TARGET = "reaper";
72
+
73
+ /**
74
+ * registry 条目扩展字段(M5 reaper 引入;M3 起 spawn 侧已补写——types.ts
75
+ * RegistryEntry 直接携带 pidStartTime,本接口保留为 reaper 视角的显式声明与
76
+ * 存量条目(M3 前登记)的读取形状)。单位 epoch 秒(ps -o lstart= 解析值),
77
+ * 勿混用 /proc tick 毫秒值。
78
+ */
79
+ export interface RegistryEntryStartTime {
80
+ pidStartTime?: number;
81
+ }
82
+
83
+ /** reaper 视角的 registry 条目(M2 RegistryEntry + start time 扩展字段)。 */
84
+ export type ReaperRegistryEntry = RegistryEntry & RegistryEntryStartTime;
85
+
86
+ /** 读条目扩展字段(运行时 guard:in + typeof + 有限性,防脏数据混入比较)。 */
87
+ function readPidStartTimeSec(entry: RegistryEntry): number | undefined {
88
+ if ("pidStartTime" in entry) {
89
+ const registered = (entry as ReaperRegistryEntry).pidStartTime;
90
+ if (typeof registered === "number" && Number.isFinite(registered)) {
91
+ return registered;
92
+ }
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ /**
98
+ * 取进程 start time(epoch 秒)。ps -o lstart= 跨 macOS/Linux(Linux /proc/
99
+ * <pid>/stat 精度更高但 macOS 无 /proc,统一 ps 保跨平台一致);= 号去表头。
100
+ * 返回 undefined:进程不存在 / ps 不可用 / 输出不可解析——调用方一律按
101
+ * 「无法校验 → 保守跳过」处理。
102
+ */
103
+ export function getProcessStartTimeSec(pid: number): number | undefined {
104
+ let result: SpawnSyncReturns<string>;
105
+ try {
106
+ result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
107
+ encoding: "utf8",
108
+ timeout: PS_TIMEOUT_MS,
109
+ });
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ if (result.error || result.status !== 0 || !result.stdout) return undefined;
114
+ // lstart 形如 "Mon Aug 25 14:23:45 2026"(本地时区),Date.parse 按本地时区解释
115
+ const ms = Date.parse(result.stdout.trim());
116
+ return Number.isNaN(ms) ? undefined : Math.floor(ms / MS_PER_SECOND);
117
+ }
118
+
119
+ /**
120
+ * pid 身份判据(§3.6「宁不杀勿误杀」的唯一定义点;reaper 孤儿补杀与 background
121
+ * timeout 到点 kill 共用)。true = 当前占用该 pid 的进程 start time 与登记值匹配,
122
+ * 可安全 kill。
123
+ * - 有登记 start time(spawn 时 ps 读取成功)→ 精确比较(同单位 epoch 秒)
124
+ * - 缺登记 start time(M3 前登记的存量条目 / ps 不可用平台)→ startedAtMs 秒级
125
+ * 降级:登记发生在 spawn 之后(进程先启动、条目后登记),原进程 start time
126
+ * 必然 ≤ floor(startedAtMs/1000)——见文件头「已知缺口→已闭合」的误杀窗口描述
127
+ */
128
+ export function pidStartMatchesRegistered(
129
+ actualStartSec: number,
130
+ registeredStartSec: number | undefined,
131
+ startedAtMs: number,
132
+ ): boolean {
133
+ return registeredStartSec !== undefined
134
+ ? actualStartSec === registeredStartSec
135
+ : actualStartSec <= Math.floor(startedAtMs / MS_PER_SECOND);
136
+ }
137
+
138
+ /** 单轮扫描统计(日志 + 测试断言面;写失败单独计数保持守恒)。 */
139
+ export interface ReapResult {
140
+ /** 扫描的 sessionId 目录数(含无 registry / 无活跃条目的目录)。 */
141
+ scannedDirs: number;
142
+ /** 分支①跳过:属主活(含 ownerPiPid === 本进程的防御性跳过)。 */
143
+ ownerAliveSkipped: number;
144
+ /** 分支②补杀成功:kill-tree 已发令 + orphaned 终态写入。 */
145
+ killedOrphans: number;
146
+ /** 分支③终态收尾成功:未补杀,仅转 orphaned。 */
147
+ finalizedOrphans: number;
148
+ /** 保守跳过:start time 无法获取 / 复用嫌疑不匹配(含锁内写失败条目停留 running)。 */
149
+ conservativelySkipped: number;
150
+ }
151
+
152
+ export interface ReapOptions {
153
+ /** 测试接缝:进程 start time 获取(epoch 秒)。默认真实 ps。 */
154
+ getProcessStartTimeSec?: (pid: number) => number | undefined;
155
+ }
156
+
157
+ /**
158
+ * 扫描并处置孤儿(跨进程文件锁内执行)。幂等:终态条目跳过 + 属主活跳过,
159
+ * 二次扫描对已处置孤儿天然 no-op。锁获取失败(重试耗尽)抛 ELOCKED 给调用方
160
+ * ——孤儿保持原状,下一 session_start 重试,无害。
161
+ */
162
+ export async function reapOrphanedTasks(dataDir: string, opts: ReapOptions = {}): Promise<ReapResult> {
163
+ const getStartSec = opts.getProcessStartTimeSec ?? getProcessStartTimeSec;
164
+ const baseDir = getBaseToolEnhanceDir(dataDir);
165
+ // 锁目标传 reaper(proper-lockfile 落 <目标>.lock = reaper.lock,固定名)
166
+ return withFileLock(join(baseDir, REAPER_LOCK_TARGET), () =>
167
+ Promise.resolve(scanAndReapSync(baseDir, getStartSec)),
168
+ );
169
+ }
170
+
171
+ function emptyResult(): ReapResult {
172
+ return { scannedDirs: 0, ownerAliveSkipped: 0, killedOrphans: 0, finalizedOrphans: 0, conservativelySkipped: 0 };
173
+ }
174
+
175
+ function scanAndReapSync(
176
+ baseDir: string,
177
+ getStartSec: (pid: number) => number | undefined,
178
+ ): ReapResult {
179
+ const result = emptyResult();
180
+ let dirents: Dirent[];
181
+ try {
182
+ dirents = readdirSync(baseDir, { withFileTypes: true });
183
+ } catch (err) {
184
+ // baseDir 不存在(从未有过后台任务)是常态,不告警;读失败(权限等)warn 后放弃本轮
185
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
186
+ logger.warn("reaper: base dir unreadable, skipping this scan", {
187
+ detail: { dir: baseDir, err: err instanceof Error ? err.message : String(err) },
188
+ });
189
+ }
190
+ return result;
191
+ }
192
+ for (const dirent of dirents) {
193
+ // reaper.lock(proper-lockfile mkdir 目录形态)与 .DS_Store 等非 session 目录跳过
194
+ if (!dirent.isDirectory() || dirent.name === `${REAPER_LOCK_TARGET}.lock`) continue;
195
+ result.scannedDirs++;
196
+ try {
197
+ reapSessionDirSync(join(baseDir, dirent.name), getStartSec, result);
198
+ } catch (err) {
199
+ // 错误容忍:单目录失败跳过 + warn,不中断整体扫描(readRegistry 已内建
200
+ // 损坏防御 rename .corrupt + 空表重建,此处 catch 是目录级双保险)
201
+ logger.warn("reaper: session dir scan failed, skipping dir", {
202
+ detail: { dir: dirent.name, err: err instanceof Error ? err.message : String(err) },
203
+ });
204
+ }
205
+ }
206
+ return result;
207
+ }
208
+
209
+ function reapSessionDirSync(
210
+ sessionDir: string,
211
+ getStartSec: (pid: number) => number | undefined,
212
+ result: ReapResult,
213
+ ): void {
214
+ const registryPath = join(sessionDir, "registry.json");
215
+ // 不存在 → 空表;损坏 → .corrupt 保留现场 + 空表重建(M2 内建防御,双保险)
216
+ const entries = readRegistry(registryPath);
217
+ for (const entry of entries.values()) {
218
+ if (!isActiveState(entry.state)) continue; // exited/orphaned 终态跳过
219
+ reapEntrySync(entry, registryPath, getStartSec, result);
220
+ }
221
+ }
222
+
223
+ /** 三分支判定主体(§3.5 原文;注释里的 ①②③ 与文件头逐条对应)。 */
224
+ function reapEntrySync(
225
+ entry: RegistryEntry,
226
+ registryPath: string,
227
+ getStartSec: (pid: number) => number | undefined,
228
+ result: ReapResult,
229
+ ): void {
230
+ // ①属主判定:ownerPiPid === 当前进程 pid = 自己进程的条目出现在别的 session
231
+ // 目录(理论不该发生——单例表条目唯一来源是本进程 spawn;防御性视为属主活)。
232
+ // ephemeral 短命附着进程触发 reaper 时,其他进程的任务属主活 → 此处天然
233
+ // 跳过,无需特判。reaper 永不介入属主存活的挂死任务(bash_kill / 用户职责)。
234
+ if (entry.ownerPiPid === process.pid || isPidAlive(entry.ownerPiPid)) {
235
+ result.ownerAliveSkipped++;
236
+ return;
237
+ }
238
+
239
+ // 属主已死 → 孤儿身份成立,按任务 pid 死活分流
240
+ if (!isPidAlive(entry.pid)) {
241
+ // ③终态收尾:任务 pid 已死但条目仍 running/killing(graceful 收殓的
242
+ // registry 写入没写完/写不进的遗留)→ 不补杀,仅转终态 orphaned——
243
+ // 保证 registry 终态闭环,对账判据才有依据。ESRCH 无歧义,无需
244
+ // start-time 校验(校验只服务「判活防复用」)
245
+ writeOrphanedTerminal(entry, registryPath, "finalized", result);
246
+ return;
247
+ }
248
+
249
+ // 任务 pid 存活 → ②孤儿补杀前先过 pid 复用防御(§3.6)
250
+ const actualStartSec = getStartSec(entry.pid);
251
+ if (actualStartSec === undefined) {
252
+ // 无法取 start time(平台无 ps / ps 失败 / 输出不可解析)→ 保守跳过
253
+ // 整个处置:不补杀(可能误杀复用 pid 上的无辜进程)也不转终态(条目
254
+ // 停留 running,下一 session_start 重试)。宁延迟勿误杀
255
+ result.conservativelySkipped++;
256
+ logger.warn("reaper: cannot read pid start time, conservatively skipping entry", {
257
+ detail: { taskId: entry.taskId, pid: entry.pid, ownerPiPid: entry.ownerPiPid },
258
+ });
259
+ return;
260
+ }
261
+ const registeredStartSec = readPidStartTimeSec(entry);
262
+ if (!pidStartMatchesRegistered(actualStartSec, registeredStartSec, entry.startedAt)) {
263
+ // start time 与登记值不匹配 = pid 已被系统复用,当前占用者是无关新进程
264
+ // → 视为已死:不误杀,也不转终态(任务真实死活未知,交下一周期)
265
+ result.conservativelySkipped++;
266
+ logger.warn("reaper: pid start time mismatch (likely pid reuse), skipping entry", {
267
+ detail: {
268
+ taskId: entry.taskId,
269
+ pid: entry.pid,
270
+ ownerPiPid: entry.ownerPiPid,
271
+ actualStartSec,
272
+ registeredStartSec,
273
+ },
274
+ });
275
+ return;
276
+ }
277
+
278
+ // ②孤儿补杀:属主已死 + 原进程身份成立(pid 活 + start time 匹配)
279
+ killProcessTree(entry.pid);
280
+ logger.warn("reaper: orphan task killed", {
281
+ detail: { taskId: entry.taskId, pid: entry.pid, ownerPiPid: entry.ownerPiPid, command: entry.command },
282
+ });
283
+ writeOrphanedTerminal(entry, registryPath, "killed", result);
284
+ }
285
+
286
+ /**
287
+ * 写 orphaned 终态(分支②③共用)。reason 不写:reason 枚举(natural/timeout/
288
+ * killed/process-exit)属 exited 语义,orphaned 的成因(属主强杀遗留)不在
289
+ * 枚举内,保持 undefined 而非造词。
290
+ */
291
+ function writeOrphanedTerminal(
292
+ entry: RegistryEntry,
293
+ registryPath: string,
294
+ kind: "killed" | "finalized",
295
+ result: ReapResult,
296
+ ): void {
297
+ const endedAt = Date.now();
298
+ const orphaned: ReaperRegistryEntry = {
299
+ ...entry,
300
+ state: "orphaned",
301
+ endedAt,
302
+ durationMs: endedAt - entry.startedAt,
303
+ };
304
+ const written = writeRegistryEntry(registryPath, orphaned);
305
+ if (!written.success) {
306
+ // 写失败:条目停留 running。补杀分支进程已死,下一轮 reap 走③收尾;
307
+ // 终态收尾分支下一轮重试——幂等闭环,无静默丢失
308
+ result.conservativelySkipped++;
309
+ return;
310
+ }
311
+ if (kind === "killed") result.killedOrphans++;
312
+ else result.finalizedOrphans++;
313
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 工具报错审计 hook —— 自 unified-hooks tool-error-handler 等价迁移(设计文档 D11 落点)。
3
+ *
4
+ * 迁移约定(与原实现逐字段一致,M1 验收点):
5
+ * - 事件名 = "tool_execution_end"(pi 0.84.1 实装无 "tool_error" 事件,工具报错以
6
+ * ToolExecutionEndEvent.isError=true 表达——以 dist types.d.ts 为准)
7
+ * - customType = "unified-hooks:tool-error"(保持原值,等价迁移不断链;unified-hooks
8
+ * 整包废弃后该 entry 由本包继续产出,M6 摘除旧包时消费方无感)
9
+ * - entry 形态 = { timestamp, toolName, toolCallId, errorText },errorText 取不到时 null
10
+ *
11
+ * 原实现的设计决策一并继承:不调 ctx.ui.notify——tool error 已在对话流里
12
+ * (pi 原生 tool result isError → error content 回灌 LLM),notify 会重复显示
13
+ * 且措辞误导;仅 appendEntry 留审计痕迹。
14
+ */
15
+
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+
18
+ /**
19
+ * Subset of `ToolExecutionEndEvent` fields used by this hook.
20
+ * 局部最小接口沿自 unified-hooks 原实现:SDK 的完整事件类型经 CI ambient stub
21
+ * 不一定可达,宽松声明避免类型环境差异。
22
+ */
23
+ interface ToolExecutionEndLikeEvent {
24
+ isError: boolean;
25
+ toolName: string;
26
+ toolCallId: string;
27
+ result?: unknown;
28
+ }
29
+
30
+ /**
31
+ * 从 tool 执行结果里提取错误文本。
32
+ *
33
+ * pi 在 tool execute 抛错时构造 `{ content: [{ type: "text", text }] }` 塞进
34
+ * result.content[0].text;事件结构无独立 errorMessage 字段。防御性取多种结构,
35
+ * 取不到返回 undefined(调用方降级为 null,不阻断)。
36
+ */
37
+ function extractErrorText(result: unknown): string | undefined {
38
+ const contentArr = getContentArray(result);
39
+ if (contentArr) {
40
+ for (const item of contentArr) {
41
+ const text = getStringProperty(item, "text");
42
+ if (text) return text;
43
+ }
44
+ }
45
+ // 兜底:某些工具直接塞 { error: "..." }
46
+ return getStringProperty(result, "error");
47
+ }
48
+
49
+ /** 若 result.content 是数组则返回它,否则 undefined。 */
50
+ function getContentArray(result: unknown): unknown[] | undefined {
51
+ if (typeof result !== "object" || result === null) return undefined;
52
+ const content = (result as Record<string, unknown>).content;
53
+ return Array.isArray(content) ? content : undefined;
54
+ }
55
+
56
+ /** 类型守卫:返回 obj[key] 当它是非空 string,否则 undefined。 */
57
+ function getStringProperty(obj: unknown, key: string): string | undefined {
58
+ if (typeof obj !== "object" || obj === null) return undefined;
59
+ const val = (obj as Record<string, unknown>)[key];
60
+ return typeof val === "string" && val.length > 0 ? val : undefined;
61
+ }
62
+
63
+ export function setupToolErrorAudit(pi: ExtensionAPI): void {
64
+ pi.on("tool_execution_end", async (event: unknown) => {
65
+ const e = event as ToolExecutionEndLikeEvent;
66
+ if (!e.isError) return;
67
+
68
+ const errorText = extractErrorText(e.result);
69
+
70
+ const entry = {
71
+ timestamp: Date.now(),
72
+ toolName: e.toolName,
73
+ toolCallId: e.toolCallId,
74
+ errorText: errorText ?? null,
75
+ };
76
+ pi.appendEntry("unified-hooks:tool-error", entry);
77
+ });
78
+ }