@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
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 模块级单例任务表(运行时权威,D17 的根基)。
3
+ *
4
+ * 为什么模块级而不是 extension 实例级:pi 同进程 session 替换(/fork、选择器切换、
5
+ * RPC session.*)会重新 load extension 实例,实例级状态随 dispose 消失;模块级 Map
6
+ * 跨替换存活,任务表无需恢复(pending-notifications 的 unsubscribers 列表同范式)。
7
+ *
8
+ * 条目唯一来源 = 本进程 execute 的 spawn(registerSpawnedTask 全仓唯一调用点 =
9
+ * spawn-background.ts)。他进程 / 历史 session 的 running 条目**永不进表**——
10
+ * 零恢复零接管,处置权统一归 M5 reaper 属主裁决;终态条目可从 registry 读,
11
+ * 仅供 bash_output 查历史。
12
+ *
13
+ * 终态条目 LRU 上限 MAX_TERMINAL_ENTRIES(与 registry 对称),淘汰后 bash_output
14
+ * 回落 registry 查询。
15
+ */
16
+
17
+ import {
18
+ isActiveState,
19
+ isTerminalState,
20
+ type BackgroundTask,
21
+ type BackgroundTaskEndReason,
22
+ type KillingIntent,
23
+ } from "./types.ts";
24
+
25
+ /** 终态条目 LRU 上限(§3.5 两层存储分工:单例表与 registry 对称采用 LRU 50)。 */
26
+ export const MAX_TERMINAL_TASKS = 50;
27
+
28
+ const taskTable = new Map<string, BackgroundTask>();
29
+
30
+ /**
31
+ * 登记新任务(条目唯一入口,仅 spawn-background 调用)。
32
+ * 同 taskId 已存在 = 编码错误(task_id 含 ts+rand 保证全局唯一),防御性覆盖并保留
33
+ * 日志语义由调用方保证不发生。
34
+ */
35
+ export function registerSpawnedTask(task: BackgroundTask): void {
36
+ taskTable.set(task.taskId, task);
37
+ }
38
+
39
+ export function getTask(taskId: string): BackgroundTask | undefined {
40
+ return taskTable.get(taskId);
41
+ }
42
+
43
+ /** 全部条目(running/killing/exited/orphaned),bash_output list 用。 */
44
+ export function getAllTasks(): BackgroundTask[] {
45
+ return [...taskTable.values()];
46
+ }
47
+
48
+ /** 活跃条目(running | killing),轮询器监护对象。 */
49
+ export function getActiveTasks(): BackgroundTask[] {
50
+ return getAllTasks().filter((t) => isActiveState(t.state));
51
+ }
52
+
53
+ export function countActiveTasks(): number {
54
+ return getActiveTasks().length;
55
+ }
56
+
57
+ /** 最老活跃任务(并发上限满时列入错误文案)。 */
58
+ export function oldestActiveTask(): BackgroundTask | undefined {
59
+ return getActiveTasks().sort((a, b) => a.startedAt - b.startedAt)[0];
60
+ }
61
+
62
+ /**
63
+ * 标 killing intent(瞬态 running→killing)。bash_kill 与后台 timeout 定时器共用;
64
+ * 调用方负责同步写 registry 侧(两侧一致是查询面可见性前提)。
65
+ */
66
+ export function markKillingIntent(
67
+ taskId: string,
68
+ reason: KillingIntent["reason"],
69
+ ): BackgroundTask | undefined {
70
+ const task = taskTable.get(taskId);
71
+ if (task === undefined || !isActiveState(task.state)) return undefined;
72
+ task.state = "killing";
73
+ task.intent = { reason, at: Date.now() };
74
+ return task;
75
+ }
76
+
77
+ export interface FinalizeOutcome {
78
+ exitCode: number | null;
79
+ reason: BackgroundTaskEndReason;
80
+ endedAt: number;
81
+ tailSummary?: string;
82
+ }
83
+
84
+ /**
85
+ * 终态化(exited):唯一终态写入口。轮询器 exit 边沿、进程退出收殓两条路径收敛
86
+ * 到这里(bash_kill 不直接写终态——单一终态归属,§3.5「bash_kill 终态收尾的
87
+ * 单点归属」)。消费 intent、清 timeout 定时器、算 durationMs、LRU 淘汰溢出终态。
88
+ */
89
+ export function finalizeTask(taskId: string, outcome: FinalizeOutcome): BackgroundTask | undefined {
90
+ const task = taskTable.get(taskId);
91
+ if (task === undefined || isTerminalState(task.state)) return task;
92
+ if (task.timeoutTimer !== undefined) {
93
+ clearTimeout(task.timeoutTimer);
94
+ task.timeoutTimer = undefined;
95
+ }
96
+ task.state = "exited";
97
+ task.exitCode = outcome.exitCode;
98
+ task.reason = outcome.reason;
99
+ task.endedAt = outcome.endedAt;
100
+ task.durationMs = outcome.endedAt - task.startedAt;
101
+ task.tailSummary = outcome.tailSummary;
102
+ task.intent = undefined;
103
+ task.child = undefined; // 终态后不再需要 exitCode,释放 ChildProcess 引用
104
+ evictTerminalOverflow();
105
+ return task;
106
+ }
107
+
108
+ /** 终态条目超上限时按 endedAt 升序淘汰最老(LRU;endedAt 缺失退 startedAt)。 */
109
+ function evictTerminalOverflow(): void {
110
+ const terminal = getAllTasks()
111
+ .filter((t) => isTerminalState(t.state))
112
+ .sort((a, b) => (a.endedAt ?? a.startedAt) - (b.endedAt ?? b.startedAt));
113
+ const excess = terminal.length - MAX_TERMINAL_TASKS;
114
+ for (let i = 0; i < excess; i++) {
115
+ taskTable.delete(terminal[i].taskId);
116
+ }
117
+ }
118
+
119
+ /** 测试专用:清空单例表(模块级状态跨测试文件存活,必须显式复位)。 */
120
+ export function clearTaskStoreForTest(): void {
121
+ for (const task of taskTable.values()) {
122
+ if (task.timeoutTimer !== undefined) clearTimeout(task.timeoutTimer);
123
+ }
124
+ taskTable.clear();
125
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * background 任务核心数据模型(设计文档 docs/design/base-tool-enhance.md §3.5)。
3
+ *
4
+ * 两层存储分工:
5
+ * - 单例任务表(task-store.ts,模块级 Map)= 运行时权威;条目唯一来源 = 本进程
6
+ * execute 的 spawn——他进程 / 历史 session 的 running 条目永不进表(零恢复零接管)
7
+ * - registry.json(registry.ts,per-sessionId 目录)= 持久化权威;M5 reaper 的
8
+ * 孤儿发现源,条目记 ownerPiPid(M2 只负责写入)
9
+ */
10
+
11
+ import type { ChildProcess } from "node:child_process";
12
+
13
+ /** 任务状态机:running → killing(intent 瞬态)→ exited;orphaned 由 M5 reaper 写入。 */
14
+ export type BackgroundTaskState = "running" | "killing" | "exited" | "orphaned";
15
+
16
+ /**
17
+ * 终态 reason 枚举(§3.5 bash_output 规格):
18
+ * - natural 进程自然退出(含外力终止——观测上不可区分,§3.5 两层存储分工节)
19
+ * - timeout 后台显式 timeout 定时器触发
20
+ * - killed bash_kill 发令
21
+ * - process-exit pi 进程退出收殓(D12)
22
+ */
23
+ export type BackgroundTaskEndReason = "natural" | "timeout" | "killed" | "process-exit";
24
+
25
+ /**
26
+ * killing intent:bash_kill / 后台 timeout 已发令、轮询器 exit 边沿未确认的瞬态标记。
27
+ * 标记写入单例表与 registry 两侧(查询面立即可见,无「已 kill 仍 running」倒挂);
28
+ * 轮询边沿据此决定终态 reason,消费后清除。
29
+ */
30
+ export interface KillingIntent {
31
+ reason: Extract<BackgroundTaskEndReason, "killed" | "timeout">;
32
+ at: number;
33
+ }
34
+
35
+ /**
36
+ * 单例任务表条目(运行时权威,D17 根基)。
37
+ *
38
+ * child 引用**只用于读 exitCode/signalCode,禁止挂事件监听**——exit 感知统一走
39
+ * 轮询器 kill(pid,0) 边沿;闭包式 exit 监听在同进程 session 替换后指向 stale bus
40
+ * (D17)。child.on("error") 是唯一例外(spawn-background 内的 no-op,防进程崩溃,
41
+ * 不做任何状态推进)。
42
+ */
43
+ export interface BackgroundTask {
44
+ taskId: string;
45
+ pid: number;
46
+ /** 原始命令全文(bash_output list 展示时截前 80 字符) */
47
+ command: string;
48
+ outputFile: string;
49
+ /** 本条目 registry.json 路径(收殓同步写终态用;运行时字段,不序列化) */
50
+ registryPath: string;
51
+ startedAt: number;
52
+ state: BackgroundTaskState;
53
+ /** 发起任务的 pi 进程 pid(M5 reaper 属主判定依据;M2 只写入) */
54
+ ownerPiPid: number;
55
+ /** 发起 session(registry 目录归属) */
56
+ sessionId: string;
57
+ exitCode?: number | null;
58
+ reason?: BackgroundTaskEndReason;
59
+ endedAt?: number;
60
+ durationMs?: number;
61
+ /** exit 边沿组装的输出尾部摘要(M3 通知用;M2 存条目不消费) */
62
+ tailSummary?: string;
63
+ /** killing intent(瞬态,终态化时消费);终态后为 undefined */
64
+ intent?: KillingIntent;
65
+ /** 后台显式 timeout 定时器(到点 kill-tree + 标 intent timeout);终态化时清除 */
66
+ timeoutTimer?: ReturnType<typeof setTimeout>;
67
+ /**
68
+ * 子进程 start time(epoch 秒,M3 补写 M5 预告字段):spawn 后立即读取,供 reaper
69
+ * 精确比较防 pid 复用误杀;读取失败省略(reaper 降级走 startedAt 秒级校验兜底)。
70
+ */
71
+ pidStartTime?: number;
72
+ /** spawn 返回的 ChildProcess 引用:仅读 exitCode/signalCode(D17,见接口注释) */
73
+ child?: ChildProcess;
74
+ }
75
+
76
+ /** registry.json 持久化条目(BackgroundTask 剥离运行时字段后的形状)。 */
77
+ export interface RegistryEntry {
78
+ taskId: string;
79
+ pid: number;
80
+ command: string;
81
+ outputFile: string;
82
+ startedAt: number;
83
+ state: BackgroundTaskState;
84
+ ownerPiPid: number;
85
+ sessionId: string;
86
+ exitCode?: number | null;
87
+ reason?: BackgroundTaskEndReason;
88
+ endedAt?: number;
89
+ durationMs?: number;
90
+ tailSummary?: string;
91
+ /** 子进程 start time(epoch 秒,reaper pid 复用防御精确比较用;缺失走降级校验)。 */
92
+ pidStartTime?: number;
93
+ }
94
+
95
+ /** 任务是否处于活跃态(轮询器监护对象)。 */
96
+ export function isActiveState(state: BackgroundTaskState): boolean {
97
+ return state === "running" || state === "killing";
98
+ }
99
+
100
+ /** 任务是否处于终态(LRU 淘汰对象;orphaned 由 M5 写入,同属终态)。 */
101
+ export function isTerminalState(state: BackgroundTaskState): boolean {
102
+ return state === "exited" || state === "orphaned";
103
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * bash_kill 工具(D9,§3.5「bash_kill 终态收尾的单点归属」+ 跨进程边界段)。
3
+ *
4
+ * 职责边界:只负责杀进程树 + kill 前把单例表与 registry **两侧**标 killing intent
5
+ * (查询面立即可见——kill 返回后 bash_output 即显示 killing,无「已 kill 仍 running」
6
+ * 倒挂窗口)。实际终态(exited, reason:"killed")由轮询器 exit 边沿收尾写——
7
+ * bash_kill 不直接写终态(单一终态归属),也不 sendMessage(kill 调用方就在当前
8
+ * turn 内等结果,再发 steer 通知是双发噪音——那是 M3 的规则,此处先不实现通知)。
9
+ *
10
+ * kill 目标归属(§3.5):**限定本进程单例表条目**;registry 回落限定终态条目
11
+ * (exited/orphaned → already exited)——registry 中他进程的 running/killing 条目
12
+ * **不可 kill**(跨进程边界:处置权归发起进程,孤儿由 reaper 属主判定收殓)。
13
+ * kill 前校验 pid 判活 + start time 匹配(防陈旧条目遇 pid 复用误杀无关进程,
14
+ * 宁不杀勿误杀,同 reaper 原则 §3.6)。
15
+ */
16
+
17
+ import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
19
+ import { Type } from "typebox";
20
+
21
+ import { isPidAlive, killProcessTree } from "./kill-tree.ts";
22
+ import { getProcessStartTimeSec } from "./reaper.ts";
23
+ import { ensurePollerRunning } from "./background/poller.ts";
24
+ import { getRegistryPath, readRegistry, taskToRegistryEntry, writeRegistryEntry } from "./background/registry.ts";
25
+ import { getAllTasks, markKillingIntent } from "./background/task-store.ts";
26
+ import { isActiveState, isTerminalState, type RegistryEntry } from "./background/types.ts";
27
+
28
+ const bashKillSchema = Type.Object({
29
+ task_id: Type.String({ description: "Task id returned by the background bash tool." }),
30
+ });
31
+
32
+ const BASH_KILL_DESCRIPTION = [
33
+ "Terminate a background bash task by killing its process tree (the task's whole process group).",
34
+ "Returns immediately; poll bash_output {task_id} for the final exited state.",
35
+ ].join("\n");
36
+
37
+ /** JSON 输出缩进(registry.ts 同款)。 */
38
+ const JSON_INDENT = 2;
39
+
40
+ function textResult(text: string): AgentToolResult<unknown> {
41
+ return { content: [{ type: "text", text }], details: undefined };
42
+ }
43
+
44
+ function killedFalse(reason: string, hint?: string): AgentToolResult<unknown> {
45
+ return textResult(JSON.stringify({ killed: false, reason, ...(hint !== undefined ? { hint } : {}) }, null, JSON_INDENT));
46
+ }
47
+
48
+ export function createBashKillToolDefinition() {
49
+ return {
50
+ name: "bash_kill",
51
+ label: "bash_kill",
52
+ description: BASH_KILL_DESCRIPTION,
53
+ parameters: bashKillSchema,
54
+ async execute(
55
+ _toolCallId: string,
56
+ args: { task_id: string },
57
+ _signal: AbortSignal | undefined,
58
+ _onUpdate: unknown,
59
+ ctx: ExtensionContext,
60
+ ): Promise<AgentToolResult<unknown>> {
61
+ const sessionId = ctx.sessionManager.getSessionId();
62
+ // 查找顺序(§3.5 kill 目标归属):单例表(本进程,kill 权威目标)→ registry
63
+ // 回落(限定终态——他进程 running/killing 条目不可 kill)
64
+ const fromStore = getAllTasks().find((t) => t.taskId === args.task_id);
65
+ if (fromStore === undefined) {
66
+ const entry: RegistryEntry | undefined = readRegistry(getRegistryPath(getAgentDir(), sessionId)).get(
67
+ args.task_id,
68
+ );
69
+ if (entry === undefined) {
70
+ return killedFalse("no such task", "use bash_output to list");
71
+ }
72
+ if (isTerminalState(entry.state)) {
73
+ return killedFalse(
74
+ `already exited${entry.exitCode !== undefined && entry.exitCode !== null ? ` (code ${entry.exitCode})` : ""}`,
75
+ );
76
+ }
77
+ // registry-only 活跃条目 = 他进程任务(本进程活跃任务必在单例表)——跨进程
78
+ // 不可 kill:处置权归发起进程;属主若已死,孤儿由 reaper 在下次 session
79
+ // 启动时收殓(属主判定)
80
+ return killedFalse(
81
+ "cross-process running task owned by another pi process",
82
+ "the task is managed by the pi process that started it (bash_kill from that session); " +
83
+ "if that process is gone, the reaper will collect the orphan at the next session start",
84
+ );
85
+ }
86
+ if (isTerminalState(fromStore.state)) {
87
+ return killedFalse(
88
+ `already exited${fromStore.exitCode !== undefined && fromStore.exitCode !== null ? ` (code ${fromStore.exitCode})` : ""}`,
89
+ );
90
+ }
91
+
92
+ // pid 复用防御(§3.6 同 reaper 原则,宁不杀勿误杀):
93
+ // - pid 已死 → already exited 风格返回,不发 kill(终态由轮询边沿收尾)
94
+ // - 有 pidStartTime 字段 → 校验实际进程 start time 匹配;不匹配/读不到 =
95
+ // 复用嫌疑,拒绝 kill 并说明
96
+ // - 无字段(spawn 时 ps 不可用平台)→ 放行:本进程轮询器 ≤2s 前判过活,
97
+ // 复用窗口毫秒级;registry 终态条目不会走到这里(上面 already exited)
98
+ if (!isPidAlive(fromStore.pid)) {
99
+ return killedFalse("already exited (process no longer alive; final state pending poll)");
100
+ }
101
+ if (fromStore.pidStartTime !== undefined) {
102
+ const actualStartSec = getProcessStartTimeSec(fromStore.pid);
103
+ if (actualStartSec === undefined) {
104
+ return killedFalse(
105
+ "cannot verify process start time; refusing to kill (better safe than sorry)",
106
+ `pid ${fromStore.pid} is alive but its start time is unreadable, so pid reuse cannot be ruled out; ` +
107
+ "poll bash_output for the poll edge, or kill the process group manually if certain",
108
+ );
109
+ }
110
+ if (actualStartSec !== fromStore.pidStartTime) {
111
+ return killedFalse(
112
+ `pid reuse suspected: recorded start time ${fromStore.pidStartTime} but actual ${actualStartSec}; refusing to kill`,
113
+ `pid ${fromStore.pid} likely belongs to an unrelated recycled process now; ` +
114
+ "if you are certain, kill the process group manually (kill -- -<pgid>)",
115
+ );
116
+ }
117
+ }
118
+
119
+ // 两侧标 killing intent(单例表内存标记 + registry 同步写盘)→ 再杀进程树。
120
+ // intent 落盘在 kill 信号之前,查询面无倒挂窗口。markKillingIntent 只查
121
+ // 单例表——可 kill 目标此时必在单例表(registry-only 活跃条目上面已拒绝、
122
+ // registry 终态条目 already exited 返回),无另一侧需同步
123
+ const marked = markKillingIntent(fromStore.taskId, "killed");
124
+ if (marked !== undefined) {
125
+ writeRegistryEntry(marked.registryPath, taskToRegistryEntry(marked));
126
+ }
127
+ killProcessTree(fromStore.pid);
128
+ // 轮询器确保在跑:边沿收尾(写终态)依赖它
129
+ ensurePollerRunning();
130
+ return textResult(
131
+ JSON.stringify(
132
+ {
133
+ killed: true,
134
+ reason: isActiveState(fromStore.state)
135
+ ? "kill signal sent; poll bash_output for the final state"
136
+ : "kill signal re-sent; poll bash_output for the final state",
137
+ },
138
+ null,
139
+ JSON_INDENT,
140
+ ),
141
+ );
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * bash_output 工具(D9:独立小工具,查询与 kill 权限语义分离)。
3
+ *
4
+ * 规格(§3.5):{task_id?}。
5
+ * - 省略 = list:单例表与 registry 终态条目合并(同 task_id 以单例表为准——它的
6
+ * 状态更新),返回 {tasks:[...]},按 startedAt 升序
7
+ * - 指定 = 详情:{state, exitCode?, reason?, durationMs?, output(tail 2000 行/50KB
8
+ * 截断,同内置规则), outputFile, truncated};输出文件被删后返回
9
+ * {output:"<lost>", state} 不崩溃(§3.6)
10
+ *
11
+ * 查询归属边界(§3.5 跨进程边界):单例表 = 本进程全部任务(含同进程 session 替换
12
+ * 前发起的);registry 侧只读当前 sessionId 目录。他进程任务查不到(回发起 session)。
13
+ */
14
+
15
+ import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
+ import { Type } from "typebox";
18
+
19
+ import { readOutputTail } from "./background/output-tail.ts";
20
+ import { getRegistryPath, readRegistry } from "./background/registry.ts";
21
+ import { truncateCommand } from "./background/spawn-background.ts";
22
+ import { getAllTasks } from "./background/task-store.ts";
23
+ import { isTerminalState, type BackgroundTask, type RegistryEntry } from "./background/types.ts";
24
+
25
+ const bashOutputSchema = Type.Object({
26
+ task_id: Type.Optional(Type.String({ description: "Task id returned by the background bash tool. Omit to list all background tasks." })),
27
+ });
28
+
29
+ /** JSON 输出缩进(registry.ts 同款)。 */
30
+ const JSON_INDENT = 2;
31
+
32
+ const BASH_OUTPUT_DESCRIPTION = [
33
+ "Fetch output and status of a background bash task started with bash {background:true}.",
34
+ "Provide task_id to get task detail: state (running|killing|exited|orphaned), exitCode, reason, duration and tail output (last 2000 lines / 50KB).",
35
+ "Omit task_id to list all background tasks of this session.",
36
+ ].join("\n");
37
+
38
+ /** list 视图条目(§3.5:command 前 80 字符)。 */
39
+ function toListRow(source: BackgroundTask | RegistryEntry) {
40
+ return {
41
+ task_id: source.taskId,
42
+ command: truncateCommand(source.command),
43
+ state: source.state,
44
+ ...(source.exitCode !== undefined ? { exitCode: source.exitCode } : {}),
45
+ ...(source.reason !== undefined ? { reason: source.reason } : {}),
46
+ startedAt: source.startedAt,
47
+ ...(source.durationMs !== undefined ? { durationMs: source.durationMs } : {}),
48
+ };
49
+ }
50
+
51
+ function textResult(text: string): AgentToolResult<unknown> {
52
+ return { content: [{ type: "text", text }], details: undefined };
53
+ }
54
+
55
+ /**
56
+ * list 视图:单例表优先,registry 终态条目补差(同 id 已在单例表则跳过)。
57
+ * registry 侧只并入终态(exited/orphaned)——running/killing 条目属他进程
58
+ * 任务(本进程活跃任务必在单例表),并入会显示幻影 running 行(§3.5
59
+ * 「单例表与 registry 终态条目合并」)。
60
+ */
61
+ function listTasksText(registry: Map<string, RegistryEntry>): string {
62
+ const rows = new Map<string, ReturnType<typeof toListRow>>();
63
+ for (const task of getAllTasks()) rows.set(task.taskId, toListRow(task));
64
+ for (const entry of registry.values()) {
65
+ if (!isTerminalState(entry.state)) continue;
66
+ if (!rows.has(entry.taskId)) rows.set(entry.taskId, toListRow(entry));
67
+ }
68
+ const tasks = [...rows.values()].sort((a, b) => a.startedAt - b.startedAt);
69
+ return JSON.stringify({ tasks }, null, JSON_INDENT);
70
+ }
71
+
72
+ /**
73
+ * 按 taskId 定位任务:单例表 → 当前 session registry(回落限定终态条目——
74
+ * 他进程 running 条目不可查,§3.5 跨进程边界)。
75
+ */
76
+ function findTask(
77
+ taskId: string,
78
+ registry: Map<string, RegistryEntry>,
79
+ ): BackgroundTask | RegistryEntry | undefined {
80
+ const registryEntry = registry.get(taskId);
81
+ return (
82
+ getAllTasks().find((t) => t.taskId === taskId) ??
83
+ (registryEntry !== undefined && isTerminalState(registryEntry.state) ? registryEntry : undefined)
84
+ );
85
+ }
86
+
87
+ /** 详情视图:tail 输出(丢失时 "<lost>",§3.6)+ 终态字段按存在性展开。 */
88
+ function taskDetailText(task: BackgroundTask | RegistryEntry): string {
89
+ const tail = readOutputTail(task.outputFile);
90
+ const detail = {
91
+ task_id: task.taskId,
92
+ state: task.state,
93
+ ...(task.exitCode !== undefined ? { exitCode: task.exitCode } : {}),
94
+ ...(task.reason !== undefined ? { reason: task.reason } : {}),
95
+ startedAt: task.startedAt,
96
+ ...(task.durationMs !== undefined ? { durationMs: task.durationMs } : {}),
97
+ output: tail?.output ?? "<lost>",
98
+ truncated: tail?.truncated ?? false,
99
+ outputFile: task.outputFile,
100
+ };
101
+ return JSON.stringify(detail, null, JSON_INDENT);
102
+ }
103
+
104
+ export function createBashOutputToolDefinition() {
105
+ return {
106
+ name: "bash_output",
107
+ label: "bash_output",
108
+ description: BASH_OUTPUT_DESCRIPTION,
109
+ parameters: bashOutputSchema,
110
+ async execute(
111
+ _toolCallId: string,
112
+ args: { task_id?: string },
113
+ _signal: AbortSignal | undefined,
114
+ _onUpdate: unknown,
115
+ ctx: ExtensionContext,
116
+ ): Promise<AgentToolResult<unknown>> {
117
+ const sessionId = ctx.sessionManager.getSessionId();
118
+ const registry = readRegistry(getRegistryPath(getAgentDir(), sessionId));
119
+
120
+ if (args.task_id === undefined) return textResult(listTasksText(registry));
121
+
122
+ const task = findTask(args.task_id, registry);
123
+ if (task === undefined) {
124
+ throw new Error(
125
+ `No such task: ${args.task_id}. Use bash_output without task_id to list all background tasks.`,
126
+ );
127
+ }
128
+ return textResult(taskDetailText(task));
129
+ },
130
+ };
131
+ }