@zhushanwen/extension-protocol 0.11.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/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @zhushanwen/extension-protocol
2
+
3
+ pi extension 跨层契约包:类型 + helper 函数 + 共享行为原语,零运行时依赖。覆盖双模式(TUI/GUI)渲染、session-manager / background-task / pending-entries 协议与跨端共享的行为原语(进程处置 / registry 文件 IO / output tail)。
4
+
5
+ ## 包结构
6
+
7
+ - `core/` —— 通用协议层(所有 extension 共用:`GuiComponent` + 布局原语 + 传输编码 + 双模 widget helper)
8
+ - `extensions/` —— 有运行时定制逻辑的 extension(marker + helper)
9
+ - `ask-user/` —— 富交互(select 通道 + marker)
10
+ - `session-manager/` —— agent-managed session 嵌套 `{action, params}` 契约(select 通道 + marker)
11
+ - `plugin-bridge/` —— plugin system bridge(插件工具/事件/拦截经 select 通道 + marker 桥接)
12
+ - `subagent-engine/` —— 引擎可发现性(`engines.json` 状态文件 + 引擎配置视图)
13
+ - `pending-entries` —— pending 事件流差集核心(register 去重 + unregister 抵消,纯算法)
14
+ - `background-task` —— base-tool-enhance 后台任务 `registry.json` 文件契约
15
+ - 子出口 `@zhushanwen/extension-protocol/background-task` —— 后台任务行为原语(进程处置 / registry 文件 IO / output tail,含 node 内建依赖,不进 index 桶出口)
16
+
17
+ ## 设计原则
18
+
19
+ - **core 只保留结构性、中性的通用原语**(card / stats-line / progress-bar / list-tree / columns / tab-bar / ansi-text)。特定 extension 的领域数据结构不进协议层——extension 用通用原语组合表达,形状太特殊时走 custom 通道。
20
+ - **零运行时依赖**:纯类型 + 纯函数,两端(extension 与 renderer)共享同一契约。
21
+ - **marker 通道**:GUI 能力协商经 `GUI_WIDGET_MARKER` / 各 extension 专属 marker(如 `ASK_USER_MARKER`)走 pi 消息流。
22
+
23
+ ## 使用
24
+
25
+ ```ts
26
+ import { guiComponent, guiResult, extractGui, isGuiCapable } from '@zhushanwen/extension-protocol'
27
+
28
+ // extension 侧:构造 GUI 组件渲染结果(guiResult 收单个 component,非数组)
29
+ const result = guiResult(guiComponent('stats-line', { items: [{ label: 'turns', value: '12' }] }))
30
+
31
+ // 宿主侧:从 tool result 的 details.__gui__ 字段提取 GUI 渲染结果
32
+ const extracted = extractGui(toolResultDetails)
33
+ ```
34
+
35
+ session-manager 嵌套 `{action, params}` 契约的类型(各 action 的 params/result 类型)同样从本包导出。
36
+
37
+ ## 开发
38
+
39
+ ```bash
40
+ cd packages/extension-protocol
41
+ npx vitest run # 跑测试
42
+ npx tsc --noEmit # 类型检查
43
+ ```
@@ -0,0 +1,106 @@
1
+ /**
2
+ * base-tool-enhance 后台任务 registry.json 契约(跨层 SSOT)。
3
+ *
4
+ * 契约两端:
5
+ * - 写侧:@zhushanwen/pi-base-tool-enhance extension(pi 进程内,registry 权威)——
6
+ * spawn 登记 running、bash_kill / 后台 timeout 标 killing、轮询器与 process-exit
7
+ * guard 写 exited 终态。写入协议:原子写 temp+rename + 统一文件锁内 RMW;
8
+ * 解析失败/形状非法 → 重命名 .corrupt 保留现场 + 按空表重建。
9
+ * - 读侧:taiji runtime 后台任务收殓器——按属主判定处置孤儿(见 ownerPiPid
10
+ * 字段注释),orphaned 终态由 runtime 写入(收殓下沉,设计 docs/design/
11
+ * file-lock-unification-and-reaper-sink.md D2)。
12
+ *
13
+ * 目录布局(per-session 隔离):
14
+ * <agentDir>/base-tool-enhance/<sessionId>/registry.json
15
+ * (agentDir = pi getAgentDir() 同源 dataDir,由调用方解析传入,不硬编码)
16
+ *
17
+ * 字段名/语义/枚举值与写侧现状逐字段一致(基线:extensions/universal/
18
+ * base-tool-enhance/src/background/types.ts + registry.ts),禁止在此单侧改名。
19
+ */
20
+ /** registry 文件名(<agentDir>/base-tool-enhance/<sessionId>/ 下)。 */
21
+ declare const BACKGROUND_TASK_REGISTRY_FILENAME: "registry.json";
22
+ /** 后台任务根目录名(<agentDir>/ 下,内含 per-sessionId 子目录)。 */
23
+ declare const BASE_TOOL_ENHANCE_DIRNAME: "base-tool-enhance";
24
+ /**
25
+ * 任务 id 前缀(taskId 契约注释已约定:bt- 前缀,对账差集只认该前缀——区别于
26
+ * subagent-workflow 的 bg-/run-)。升级为导出常量:对账/守卫的前缀过滤从此引用
27
+ * 同一常量,禁止各侧字面量重写。
28
+ */
29
+ declare const BACKGROUND_TASK_ID_PREFIX: "bt-";
30
+ /** registry 文件格式版本(读侧 version 不匹配按损坏处理;未来结构变更时迁移判据)。 */
31
+ declare const BACKGROUND_TASK_REGISTRY_VERSION: 1;
32
+ /** 终态条目 LRU 上限(写侧合并时按 endedAt ?? startedAt 淘汰最老终态条目)。 */
33
+ declare const MAX_TERMINAL_REGISTRY_ENTRIES = 50;
34
+ /**
35
+ * 任务状态机:running → killing(intent 瞬态)→ exited;orphaned 由收殓侧写入。
36
+ * - running 任务运行中(spawn 后登记的初始态)
37
+ * - killing 终止已发令(bash_kill / 后台 timeout)、exit 边沿未确认的瞬态
38
+ * - exited 终态:进程已退出(reason 说明成因)
39
+ * - orphaned 终态:属主 pi 已死,任务被收殓(补杀或终态收尾)
40
+ */
41
+ type BackgroundTaskState = 'running' | 'killing' | 'exited' | 'orphaned';
42
+ /**
43
+ * 终态 reason 枚举(仅 exited 语义):
44
+ * - natural 进程自然退出(含外力终止——观测上不可区分)
45
+ * - timeout 后台显式 timeout 定时器触发
46
+ * - killed bash_kill 发令
47
+ * - process-exit pi 进程退出收殓
48
+ * orphaned 终态不写 reason(成因「属主强杀遗留」不在本枚举内,保持字段缺省)。
49
+ */
50
+ type BackgroundTaskEndReason = 'natural' | 'timeout' | 'killed' | 'process-exit';
51
+ /** registry.json 文件形状(version 锁定 + 条目数组;序列化 JSON indent 2 + 尾部换行)。 */
52
+ interface BackgroundTaskRegistryFile {
53
+ version: typeof BACKGROUND_TASK_REGISTRY_VERSION;
54
+ entries: BackgroundTaskRegistryEntry[];
55
+ }
56
+ /** registry.json 持久化条目(任务运行时对象剥离非持久字段后的形状)。 */
57
+ interface BackgroundTaskRegistryEntry {
58
+ /** 任务 id(表内唯一键;`bt-` 前缀,对账差集只认该前缀)。 */
59
+ taskId: string;
60
+ /** 任务进程 pid(收殓补杀目标;判活用 kill(pid,0),ESRCH = 死)。 */
61
+ pid: number;
62
+ /** 原始命令全文。 */
63
+ command: string;
64
+ /** stdout/stderr 重定向输出文件路径。 */
65
+ outputFile: string;
66
+ /** 任务登记时刻(epoch 毫秒)。 */
67
+ startedAt: number;
68
+ /** 状态机(见 BackgroundTaskState)。 */
69
+ state: BackgroundTaskState;
70
+ /**
71
+ * 属主判定依据:发起任务的 pi 进程 pid。pi 进程死亡(kill(ownerPiPid,0) 命中
72
+ * ESRCH)→ 其 detached 后台任务孤儿化,收殓器按属主判定处置(属主活 → 跳过,
73
+ * 宁漏杀勿误杀——桌面端并行 session 的合法任务靠此防线豁免)。
74
+ */
75
+ ownerPiPid: number;
76
+ /** 发起 session(registry 目录归属)。 */
77
+ sessionId: string;
78
+ /** 进程退出码(被 signal 终止时为 null;条目未终态时字段缺省)。 */
79
+ exitCode?: number | null;
80
+ /** 终态成因(仅 exited 语义;orphaned 保持缺省)。 */
81
+ reason?: BackgroundTaskEndReason;
82
+ /** 终态时刻(epoch 毫秒)。 */
83
+ endedAt?: number;
84
+ /** 存活时长 = endedAt - startedAt(毫秒)。 */
85
+ durationMs?: number;
86
+ /** exit 边沿组装的输出尾部摘要。 */
87
+ tailSummary?: string;
88
+ /**
89
+ * 任务进程 start time(epoch 秒,ps -o lstart= 解析值——勿与 startedAt 的毫秒
90
+ * 混用):收殓前精确比较防 pid 复用误杀;字段缺省(旧条目 / spawn 时读取失败)
91
+ * 走 startedAt 秒级降级校验。
92
+ */
93
+ pidStartTime?: number;
94
+ }
95
+ /** 任务是否处于活跃态(收殓扫描的候选集;exited/orphaned 直接跳过)。 */
96
+ declare function isActiveBackgroundTaskState(state: BackgroundTaskState): boolean;
97
+ /** 任务是否处于终态(orphaned 同属终态——收殓二次扫描幂等 no-op 的构造性来源)。 */
98
+ declare function isTerminalBackgroundTaskState(state: BackgroundTaskState): boolean;
99
+ /**
100
+ * 条目形状运行时 guard(读侧防御,对齐写侧 isValidRegistryEntry 语义):核心
101
+ * 标识字段(8 个必填字段)类型合法即放行,缺失/脏类型单条丢弃——不因单条脏数据
102
+ * 报废全表。可选字段不做深度校验(与写侧现状一致)。
103
+ */
104
+ declare function isBackgroundTaskRegistryEntry(item: unknown): item is BackgroundTaskRegistryEntry;
105
+
106
+ export { type BackgroundTaskState as B, MAX_TERMINAL_REGISTRY_ENTRIES as M, type BackgroundTaskRegistryEntry as a, BACKGROUND_TASK_ID_PREFIX as b, BACKGROUND_TASK_REGISTRY_FILENAME as c, BACKGROUND_TASK_REGISTRY_VERSION as d, BASE_TOOL_ENHANCE_DIRNAME as e, type BackgroundTaskEndReason as f, type BackgroundTaskRegistryFile as g, isBackgroundTaskRegistryEntry as h, isActiveBackgroundTaskState as i, isTerminalBackgroundTaskState as j };
@@ -0,0 +1,159 @@
1
+ import { B as BackgroundTaskState, a as BackgroundTaskRegistryEntry } from './background-task-Cu0p02O-.js';
2
+
3
+ /**
4
+ * 后台任务进程原语(pid 判活 / 进程树处置 / start time 身份判据)——跨端单一实现。
5
+ *
6
+ * 契约两端(此前各持一份逐字同构实现,对齐手段是注释,已归一到本模块):
7
+ * - extension 侧:@zhushanwen/pi-base-tool-enhance(bash_kill 前校验 / 后台 timeout
8
+ * 到点校验 / 对账判死),原实现在其 kill-tree.ts
9
+ * - runtime 侧:taiji background-task-reaper(孤儿收殓三分支判定),原实现在
10
+ * background-task-reaper.ts 原语区
11
+ *
12
+ * 分支语义(与 pi 实装对齐,勿单侧擅改):Windows `taskkill /F /T`;POSIX 杀进程组
13
+ * `kill -- -<pgid>`(后台任务 detached spawn 自成进程组,pgid = pid);进程组杀不到时
14
+ * 回退单 pid + `pgrep -P` 递归杀残留子进程。
15
+ *
16
+ * 日志通道:本模块零日志依赖(protocol 零运行时依赖纪律)——回退路径的诊断经可选
17
+ * 回调 `onFallback(step, err)` 上报,extension 侧注入 logger.debug 适配、runtime 侧
18
+ * 注入 console.debug 适配。step 为稳定短标识符:
19
+ * - 'process-group-kill-missed' POSIX 进程组 kill 失败(组长已死),走回退
20
+ * - 'single-pid-kill-missed' 单 pid kill 失败(目标已死,幂等语义)
21
+ * - 'taskkill-failed' Windows taskkill 失败
22
+ * - 'descendant-enumeration-failed' pgrep 不可用/失败,放弃子孙枚举
23
+ * - 'descendant-kill-missed' 子孙 kill 失败(已死,幂等语义)
24
+ */
25
+ /** killProcessTree 回退路径诊断回调(落盘/console 通道由调用方适配注入)。 */
26
+ type ProcessFallbackLogger = (step: string, err: unknown) => void;
27
+ /**
28
+ * pid 判活:kill(pid, 0) 不发信号只做存在性/权限校验。
29
+ * ESRCH = 已死(含被 reap 后);EPERM = 进程存在但属其他用户,仍视为活。
30
+ */
31
+ declare function isPidAlive(pid: number): boolean;
32
+ /**
33
+ * 杀整棵进程树(同步;收殓路径在 process.on("exit") 里跑,必须同步)。
34
+ * POSIX 杀进程组(pgid = pid)一次性覆盖全部子孙;进程组杀不到(组长已死)回退
35
+ * 单 pid + pgrep -P 递归清理残留子进程(先杀孙辈再杀子辈,防孙辈在父死后被
36
+ * reparent 逃逸枚举)。Windows taskkill /F /T。幂等:目标已死时静默成功
37
+ * (回退路径的诊断经 onFallback 上报,无回调则静默)。
38
+ */
39
+ declare function killProcessTree(pid: number, onFallback?: ProcessFallbackLogger): void;
40
+ /**
41
+ * 取进程 start time(epoch 秒)。ps -o lstart= 跨 macOS/Linux(= 号去表头;Linux
42
+ * /proc/<pid>/stat 精度更高但 macOS 无 /proc,统一 ps 保跨平台一致)。
43
+ * 返回 undefined:进程不存在 / ps 不可用(Windows)/ 输出不可解析——调用方一律按
44
+ * 「无法校验 → 保守跳过」处理。
45
+ */
46
+ declare function getProcessStartTimeSec(pid: number): number | undefined;
47
+ /**
48
+ * pid 身份判据(「宁不杀勿误杀」的唯一判定点)。
49
+ * true = 当前占用该 pid 的进程 start time 与登记值匹配,可安全 kill。
50
+ * - 有登记 start time(spawn 时 ps 读取成功)→ 精确比较(同单位 epoch 秒)
51
+ * - 缺登记 start time(旧条目 / ps 不可用平台登记)→ startedAtMs 秒级降级:
52
+ * 登记发生在 spawn 之后(进程先启动、条目后登记),原进程 start time 必然
53
+ * ≤ floor(startedAtMs/1000)(floor 单调性,零误跳)
54
+ */
55
+ declare function pidStartMatchesRegistered(actualStartSec: number, registeredStartSec: number | undefined, startedAtMs: number): boolean;
56
+
57
+ /**
58
+ * registry.json 文件行为原语(解析防御 / corrupt 隔离 / 原子写 / 终态 LRU 裁剪 /
59
+ * 序列化)——跨端单一实现。
60
+ *
61
+ * 契约两端(此前各持一份同构实现,对齐手段是注释):
62
+ * - extension 侧:@zhushanwen/pi-base-tool-enhance background/registry.ts(bte 的
63
+ * readRegistry 返回 Map、锁壳用 pi-file-lock)
64
+ * - runtime 侧:taiji background-task-reaper / services/background-task/
65
+ * registry-write.ts(数组形态、锁壳用统一锁)
66
+ *
67
+ * 本模块只提供无锁原语与纯函数;锁壳(withFileLockSync RMW 编排)留在各侧——
68
+ * 两侧锁实现不同(pi-file-lock vs runtime 统一锁)是产品决策,不下沉。
69
+ *
70
+ * 日志通道:本模块零日志依赖——warn 级诊断(registry 读失败 / corrupt 隔离 /
71
+ * tmp 清理失败)经可选回调 `onLog('warn', event, detail)` 上报,extension 侧注入
72
+ * extension-logger 适配、runtime 侧注入 console 适配。corrupt 隔离 warn 是排障
73
+ * 生命线,两侧适配必接。锁壳层的「registry write failed」warn 属各侧锁壳职责
74
+ * (写失败时条目停留 running 的降级决策在锁壳),不在本模块。
75
+ */
76
+
77
+ /** registry 文件原语日志回调(落盘/console 通道由调用方适配注入)。 */
78
+ type RegistryFileLogFn = (level: 'warn' | 'debug', event: string, detail?: unknown) => void;
79
+ /** 终态裁剪入参的最小结构约束(泛型透传,保留调用方条目类型)。 */
80
+ interface TrimTerminalEntryLike {
81
+ taskId: string;
82
+ startedAt: number;
83
+ endedAt?: number;
84
+ state: BackgroundTaskState;
85
+ }
86
+ /**
87
+ * 校验并归一化 registry 文件内容;形状非法返回 undefined(走 corrupt 隔离路径)。
88
+ * version 不匹配 / entries 非数组 → 整体非法;单条脏数据丢弃、不报废全表
89
+ * (契约 guard isBackgroundTaskRegistryEntry 复用)。
90
+ */
91
+ declare function parseRegistryContent(raw: string): BackgroundTaskRegistryEntry[] | undefined;
92
+ /** .corrupt 落点:固定名优先;已存在则带时间戳,不覆盖前一份现场。 */
93
+ declare function corruptPathFor(registryPath: string): string;
94
+ /**
95
+ * 读取 registry 全量条目 + 损坏标记(runtime 现形态为超集;extension 侧 Map 形态
96
+ * 由各侧薄壳投影)。UI 读侧需要区分「真的空表」与「损坏被隔离的空表」。
97
+ *
98
+ * 文件不存在 / 读失败 → 空表 + corrupted:false(常态/降级,非损坏);解析失败 →
99
+ * 重命名 .corrupt 保留现场 + onLog warn + 空表 + corrupted:true(「空表重建」由
100
+ * 后续写入自然完成,不立即写空文件)。
101
+ */
102
+ declare function readRegistry(registryPath: string, onLog?: RegistryFileLogFn): {
103
+ entries: BackgroundTaskRegistryEntry[];
104
+ corrupted: boolean;
105
+ };
106
+ /**
107
+ * registry 文件序列化(字节契约单点:version 锁定 + JSON indent 2 + 尾部换行)。
108
+ * 两侧写路径共用本函数——文件字节形态归一是 V4/P2 验收的前提。
109
+ */
110
+ declare function serializeRegistryFile(entries: BackgroundTaskRegistryEntry[]): string;
111
+ /**
112
+ * 原子写:tmp(pid+随机段唯一化防并发碰撞)+ rename(POSIX/Windows 均原子);
113
+ * 失败清理 tmp 后向上抛原错误(锁壳层决定降级语义)。tmp 清理失败不掩盖原错误,
114
+ * 仅经 onLog 上报诊断。
115
+ */
116
+ declare function atomicWriteRegistry(registryPath: string, content: string, onLog?: RegistryFileLogFn): void;
117
+ /**
118
+ * 终态条目 LRU 裁剪(纯函数):终态(exited/orphaned)按 `endedAt ?? startedAt`
119
+ * 升序、超上限淘汰最老;活跃条目(running/killing)永不淘汰;未超上限原样保留。
120
+ * 入参数组不被修改(sort 作用于 filter 产生的新数组),返回保留集新数组。
121
+ * extension task-store 与 registry 写侧、runtime registry-write 共用(max 由各侧
122
+ * 传参,上限值不变)。
123
+ */
124
+ declare function trimTerminalEntries<T extends TrimTerminalEntryLike>(entries: T[], max: number): T[];
125
+
126
+ /**
127
+ * 输出文件 tail 读取原语(末尾字节窗口 + 行/字节双上限)——跨端单一实现。
128
+ *
129
+ * 此前两侧各持一份逐行同构实现且签名已实际漂移(bte `(file, maxLines, maxBytes)`
130
+ * vs runtime `(file, maxBytes, maxLines)`;返回字段 `output` vs `text`)——本模块
131
+ * 定型单一签名:`(file, opts: { maxBytes, maxLines })` → `{ text, truncated }`
132
+ * (字段名取 runtime 的 `text`,比 `output` 中性且不与「输出文件」混淆)。
133
+ *
134
+ * 默认上限(bte 50KB / runtime 32KB)是各自产品决策,**留在各调用方**传参,
135
+ * 本模块不设默认值。
136
+ *
137
+ * 日志通道:零日志依赖——close 失败(已读完内容,不影响结果)的 debug 诊断经可选
138
+ * 回调 `onLog('debug', event, detail)` 上报,调用方不适配则静默。
139
+ */
140
+ /** output-tail 日志回调(落盘/console 通道由调用方适配注入;close debug 可选)。 */
141
+ type OutputTailLogFn = (level: 'warn' | 'debug', event: string, detail?: unknown) => void;
142
+ /** tail 读取上限(字节/行双上限,先到为准;由调用方按产品口径传参)。 */
143
+ interface OutputTailOptions {
144
+ maxBytes: number;
145
+ maxLines: number;
146
+ }
147
+ interface OutputTailResult {
148
+ text: string;
149
+ /** 读取窗口被截断(内容超任一上限)时 true。 */
150
+ truncated: boolean;
151
+ }
152
+ /**
153
+ * 读文件尾部(行/字节双上限)。文件不存在/不可读返回 undefined——调用方按
154
+ * 「输出丢失」降级,不崩溃。实现从文件末尾按字节窗口读(不整读大文件,
155
+ * O(maxBytes) 而非 O(fileSize))。
156
+ */
157
+ declare function readOutputTail(outputFile: string, opts: OutputTailOptions, onLog?: OutputTailLogFn): OutputTailResult | undefined;
158
+
159
+ export { type OutputTailLogFn, type OutputTailOptions, type OutputTailResult, type ProcessFallbackLogger, type RegistryFileLogFn, type TrimTerminalEntryLike, atomicWriteRegistry, corruptPathFor, getProcessStartTimeSec, isPidAlive, killProcessTree, parseRegistryContent, pidStartMatchesRegistered, readOutputTail, readRegistry, serializeRegistryFile, trimTerminalEntries };
@@ -0,0 +1,217 @@
1
+ import {
2
+ BACKGROUND_TASK_REGISTRY_VERSION,
3
+ isBackgroundTaskRegistryEntry,
4
+ isTerminalBackgroundTaskState
5
+ } from "./chunk-NJ3E4ISR.js";
6
+
7
+ // src/background-task-process.ts
8
+ import { spawnSync } from "child_process";
9
+ var PS_TIMEOUT_MS = 5e3;
10
+ var MS_PER_SECOND = 1e3;
11
+ function isPidAlive(pid) {
12
+ if (!Number.isInteger(pid) || pid <= 0) return false;
13
+ try {
14
+ process.kill(pid, 0);
15
+ return true;
16
+ } catch (err) {
17
+ return err.code === "EPERM";
18
+ }
19
+ }
20
+ function killProcessTree(pid, onFallback) {
21
+ if (!Number.isInteger(pid) || pid <= 0) return;
22
+ if (process.platform === "win32") {
23
+ killProcessTreeWindows(pid, onFallback);
24
+ return;
25
+ }
26
+ try {
27
+ process.kill(-pid, "SIGKILL");
28
+ return;
29
+ } catch (err) {
30
+ onFallback?.("process-group-kill-missed", err);
31
+ }
32
+ try {
33
+ process.kill(pid, "SIGKILL");
34
+ } catch (err) {
35
+ onFallback?.("single-pid-kill-missed", err);
36
+ }
37
+ killDescendantsRecursive(pid, onFallback);
38
+ }
39
+ function killProcessTreeWindows(pid, onFallback) {
40
+ try {
41
+ const result = spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], {
42
+ stdio: "ignore",
43
+ windowsHide: true
44
+ });
45
+ if (result.error) throw result.error;
46
+ } catch (err) {
47
+ onFallback?.("taskkill-failed", err);
48
+ }
49
+ }
50
+ function killDescendantsRecursive(pid, onFallback) {
51
+ let stdout;
52
+ try {
53
+ const result = spawnSync("pgrep", ["-P", String(pid)], { encoding: "utf8" });
54
+ if (result.error || result.status !== 0 || !result.stdout) return;
55
+ stdout = result.stdout;
56
+ } catch (err) {
57
+ onFallback?.("descendant-enumeration-failed", err);
58
+ return;
59
+ }
60
+ for (const line of stdout.split("\n")) {
61
+ const childPid = Number.parseInt(line.trim(), 10);
62
+ if (Number.isInteger(childPid) && childPid > 0) {
63
+ killDescendantsRecursive(childPid, onFallback);
64
+ try {
65
+ process.kill(childPid, "SIGKILL");
66
+ } catch (err) {
67
+ onFallback?.("descendant-kill-missed", err);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ function getProcessStartTimeSec(pid) {
73
+ let result;
74
+ try {
75
+ result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
76
+ encoding: "utf8",
77
+ timeout: PS_TIMEOUT_MS
78
+ });
79
+ } catch {
80
+ return void 0;
81
+ }
82
+ if (result.error || result.status !== 0 || !result.stdout) return void 0;
83
+ const ms = Date.parse(result.stdout.trim());
84
+ return Number.isNaN(ms) ? void 0 : Math.floor(ms / MS_PER_SECOND);
85
+ }
86
+ function pidStartMatchesRegistered(actualStartSec, registeredStartSec, startedAtMs) {
87
+ return registeredStartSec !== void 0 ? actualStartSec === registeredStartSec : actualStartSec <= Math.floor(startedAtMs / MS_PER_SECOND);
88
+ }
89
+
90
+ // src/background-task-registry-file.ts
91
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "fs";
92
+ import { dirname } from "path";
93
+ var JSON_INDENT = 2;
94
+ var TMP_RADIX = 36;
95
+ var TMP_SLICE_START = 2;
96
+ var TMP_SLICE_END = 10;
97
+ function parseRegistryContent(raw) {
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(raw);
101
+ } catch {
102
+ return void 0;
103
+ }
104
+ if (typeof parsed !== "object" || parsed === null) return void 0;
105
+ const { version, entries } = parsed;
106
+ if (version !== BACKGROUND_TASK_REGISTRY_VERSION || !Array.isArray(entries)) return void 0;
107
+ return entries.filter(isBackgroundTaskRegistryEntry);
108
+ }
109
+ function corruptPathFor(registryPath) {
110
+ const base = `${registryPath}.corrupt`;
111
+ return existsSync(base) ? `${base}-${Date.now()}` : base;
112
+ }
113
+ function readRegistry(registryPath, onLog) {
114
+ if (!existsSync(registryPath)) return { entries: [], corrupted: false };
115
+ let raw;
116
+ try {
117
+ raw = readFileSync(registryPath, "utf8");
118
+ } catch (err) {
119
+ onLog?.("warn", "registry read failed, treating as empty", { path: registryPath, err });
120
+ return { entries: [], corrupted: false };
121
+ }
122
+ const parsed = parseRegistryContent(raw);
123
+ if (parsed === void 0) {
124
+ const corruptPath = corruptPathFor(registryPath);
125
+ try {
126
+ renameSync(registryPath, corruptPath);
127
+ onLog?.("warn", "registry corrupted, quarantined and continuing with empty table", {
128
+ path: registryPath,
129
+ corruptPath
130
+ });
131
+ } catch (err) {
132
+ onLog?.("warn", "registry corrupted and quarantine rename failed, continuing with empty table in place", {
133
+ path: registryPath,
134
+ err
135
+ });
136
+ }
137
+ return { entries: [], corrupted: true };
138
+ }
139
+ return { entries: parsed, corrupted: false };
140
+ }
141
+ function serializeRegistryFile(entries) {
142
+ const shape = { version: BACKGROUND_TASK_REGISTRY_VERSION, entries };
143
+ return `${JSON.stringify(shape, null, JSON_INDENT)}
144
+ `;
145
+ }
146
+ function atomicWriteRegistry(registryPath, content, onLog) {
147
+ mkdirSync(dirname(registryPath), { recursive: true });
148
+ const tmpPath = `${registryPath}.tmp_${process.pid}_${Math.random().toString(TMP_RADIX).slice(TMP_SLICE_START, TMP_SLICE_END)}`;
149
+ try {
150
+ writeFileSync(tmpPath, content, "utf8");
151
+ renameSync(tmpPath, registryPath);
152
+ } catch (err) {
153
+ try {
154
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
155
+ } catch (cleanupErr) {
156
+ onLog?.("warn", "registry tmp cleanup failed", { tmpPath, err: cleanupErr });
157
+ }
158
+ throw err;
159
+ }
160
+ }
161
+ function trimTerminalEntries(entries, max) {
162
+ const terminal = entries.filter((e) => isTerminalBackgroundTaskState(e.state)).sort((a, b) => (a.endedAt ?? a.startedAt) - (b.endedAt ?? b.startedAt));
163
+ const excess = terminal.length - max;
164
+ if (excess <= 0) return [...entries];
165
+ const evicted = new Set(terminal.slice(0, excess).map((e) => e.taskId));
166
+ return entries.filter((e) => !evicted.has(e.taskId));
167
+ }
168
+
169
+ // src/output-tail.ts
170
+ import { openSync, readSync, closeSync, statSync } from "fs";
171
+ var TAIL_WINDOW_MARGIN_BYTES = 64;
172
+ function readOutputTail(outputFile, opts, onLog) {
173
+ const { maxBytes, maxLines } = opts;
174
+ let size;
175
+ try {
176
+ size = statSync(outputFile).size;
177
+ } catch {
178
+ return void 0;
179
+ }
180
+ const windowSize = Math.min(size, maxBytes + TAIL_WINDOW_MARGIN_BYTES);
181
+ const buffer = Buffer.alloc(windowSize);
182
+ let fd;
183
+ try {
184
+ fd = openSync(outputFile, "r");
185
+ readSync(fd, buffer, 0, windowSize, size - windowSize);
186
+ } catch {
187
+ return void 0;
188
+ } finally {
189
+ if (fd !== void 0) {
190
+ try {
191
+ closeSync(fd);
192
+ } catch (err) {
193
+ onLog?.("debug", "output tail close failed", { outputFile, err });
194
+ }
195
+ }
196
+ }
197
+ const text = buffer.toString("utf8");
198
+ const lines = text.split("\n");
199
+ const firstLineIsPartial = windowSize < size && lines.length > 0;
200
+ const effectiveLines = firstLineIsPartial ? lines.slice(1) : lines;
201
+ const byteTruncated = size > maxBytes;
202
+ const shown = effectiveLines.slice(-maxLines).join("\n");
203
+ return { text: shown, truncated: byteTruncated || effectiveLines.length > maxLines };
204
+ }
205
+ export {
206
+ atomicWriteRegistry,
207
+ corruptPathFor,
208
+ getProcessStartTimeSec,
209
+ isPidAlive,
210
+ killProcessTree,
211
+ parseRegistryContent,
212
+ pidStartMatchesRegistered,
213
+ readOutputTail,
214
+ readRegistry,
215
+ serializeRegistryFile,
216
+ trimTerminalEntries
217
+ };
@@ -0,0 +1,28 @@
1
+ // src/background-task.ts
2
+ var BACKGROUND_TASK_REGISTRY_FILENAME = "registry.json";
3
+ var BASE_TOOL_ENHANCE_DIRNAME = "base-tool-enhance";
4
+ var BACKGROUND_TASK_ID_PREFIX = "bt-";
5
+ var BACKGROUND_TASK_REGISTRY_VERSION = 1;
6
+ var MAX_TERMINAL_REGISTRY_ENTRIES = 50;
7
+ function isActiveBackgroundTaskState(state) {
8
+ return state === "running" || state === "killing";
9
+ }
10
+ function isTerminalBackgroundTaskState(state) {
11
+ return state === "exited" || state === "orphaned";
12
+ }
13
+ function isBackgroundTaskRegistryEntry(item) {
14
+ if (typeof item !== "object" || item === null) return false;
15
+ const e = item;
16
+ return typeof e.taskId === "string" && typeof e.pid === "number" && typeof e.command === "string" && typeof e.outputFile === "string" && typeof e.startedAt === "number" && typeof e.state === "string" && typeof e.ownerPiPid === "number" && typeof e.sessionId === "string";
17
+ }
18
+
19
+ export {
20
+ BACKGROUND_TASK_REGISTRY_FILENAME,
21
+ BASE_TOOL_ENHANCE_DIRNAME,
22
+ BACKGROUND_TASK_ID_PREFIX,
23
+ BACKGROUND_TASK_REGISTRY_VERSION,
24
+ MAX_TERMINAL_REGISTRY_ENTRIES,
25
+ isActiveBackgroundTaskState,
26
+ isTerminalBackgroundTaskState,
27
+ isBackgroundTaskRegistryEntry
28
+ };