@zhushanwen/pi-base-tool-enhance 0.5.2 → 0.5.4

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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @zhushanwen/pi-base-tool-enhance
2
2
 
3
- 同名 override pi 内置 bash 工具的增强层:前台行为 100% 委托 pi 官方工厂(`createBashToolDefinition`),增量提供 background 模式、强制后台白名单与双模式可配置超时。承接已废弃 unified-hooks 的核心能力(测试类拦截式 guard → force-test 白名单命中自动转后台,匹配语义较原 guard 有调整、登记于 force-patterns.ts;网络类挂死保护 → 可配置前台默认超时,弱承接;工具报错审计 → tool_error 审计 hook,entry customType 保持 `unified-hooks:tool-error` 历史连续)。
3
+ 同名 override pi 内置 bash 工具的增强层:前台行为 100% 委托 pi 官方工厂(`createBashToolDefinition`),增量提供 background 模式、强制后台白名单与双模式可配置超时。能力面:测试类命令 force-test 白名单命中自动转后台(模式登记于 force-patterns.ts)、网络类挂死保护(可配置前台默认超时)、工具报错审计(tool_error 审计 hook,entry customType 为 `unified-hooks:tool-error`)。
4
4
 
5
- 设计文档(SSOT):`docs/design/base-tool-enhance.md`。
5
+ 设计决策沉淀在包内源码注释与本文档。
6
6
 
7
7
  ## 配置
8
8
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-base-tool-enhance",
3
- "version": "0.5.2",
4
- "description": "Base tool enhancement - override builtin bash tool: foreground delegates to pi official factory, incremental background mode (design: docs/design/base-tool-enhance.md)",
3
+ "version": "0.5.4",
4
+ "description": "Base tool enhancement - override builtin bash tool: foreground delegates to pi official factory, incremental background mode (design decisions documented in source comments)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
- "xyz-agent": {
7
+ "taiji": {
8
8
  "role": "universal"
9
9
  },
10
10
  "pi": {
@@ -28,10 +28,11 @@
28
28
  "skills/"
29
29
  ],
30
30
  "dependencies": {
31
- "@xyz-agent/extension-protocol": "0.9.0",
31
+ "@taiji/extension-protocol": "0.11.0",
32
+ "@zhushanwen/pi-ext-guards": "0.4.0",
32
33
  "@zhushanwen/pi-extension-logger": "0.6.0",
33
34
  "@zhushanwen/pi-file-lock": "0.4.0",
34
- "@zhushanwen/pi-llm-shared": "0.7.0"
35
+ "@zhushanwen/pi-llm-shared": "0.8.0"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@vitest/coverage-v8": "^4.1.9",
@@ -40,7 +41,7 @@
40
41
  "peerDependencies": {
41
42
  "@earendil-works/pi-coding-agent": "^0.84.4",
42
43
  "typebox": "*",
43
- "@zhushanwen/pi-pending-notifications": "0.7.0"
44
+ "@zhushanwen/pi-pending-notifications": "0.7.3"
44
45
  },
45
46
  "peerDependenciesMeta": {
46
47
  "@zhushanwen/pi-pending-notifications": {
@@ -16,10 +16,10 @@ description: "使用或排查 @zhushanwen/pi-base-tool-enhance 的 bash 后台/
16
16
  | 环境 | 路径 |
17
17
  |------|------|
18
18
  | 独立 pi CLI | `~/.pi/agent/config/base-tool-enhance-ext-config.json` |
19
- | xyz-agent dev | `~/.xyz-agent-dev/pi/agent/config/base-tool-enhance-ext-config.json` |
20
- | xyz-agent prod | `~/.xyz-agent/pi/agent/config/base-tool-enhance-ext-config.json` |
19
+ | taiji dev | `~/.taiji-dev/agent/config/base-tool-enhance-ext-config.json` |
20
+ | taiji prod | `~/.taiji/agent/config/base-tool-enhance-ext-config.json` |
21
21
 
22
- **动态推导(推荐)**:agentDir 由 pi 核心 `getAgentDir()` 决定(读 `PI_CODING_AGENT_DIR`,默认 `~/.pi/agent`);xyz-agent 通过 `XYZ_AGENT_DATA_DIR` 隔离数据目录。排查时先查这两个 env 组合出实际路径,不要假设单一环境——写错环境的配置文件改了也不生效。
22
+ **动态推导(推荐)**:agentDir 由 pi 核心 `getAgentDir()` 决定(读 `PI_CODING_AGENT_DIR`,默认 `~/.pi/agent`);taiji 通过 `TAIJI_AGENT_DATA_DIR` 隔离数据目录。排查时先查这两个 env 组合出实际路径,不要假设单一环境——写错环境的配置文件改了也不生效。
23
23
 
24
24
  文件不存在 / JSON 解析失败 → 全默认值继续工作(工具不报错,warn 落扩展日志)。单键类型错/非法值 → **仅该键**回退默认 + warn,不整体拒载;未知键忽略(前向兼容)。
25
25
 
@@ -17,19 +17,24 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
17
17
  getAgentDir: () => dataDirRef.dir,
18
18
  }));
19
19
 
20
- // kill-tree 转发真实杀 + 记录调用(timeout 分支断言 killProcessTree 被调)
20
+ // 进程原语(ext-simplify-13 后单点在 protocol 子出口)转发真实杀 + 记录调用
21
+ //(timeout 分支断言 killProcessTree 被调)
21
22
  const { killTreeCalls } = vi.hoisted(() => ({ killTreeCalls: [] as number[] }));
22
- vi.mock("../kill-tree.ts", async (importOriginal) => {
23
- const orig = await importOriginal<typeof import("../kill-tree.ts")>();
23
+ vi.mock("@taiji/extension-protocol/background-task", async (importOriginal) => {
24
+ const orig = await importOriginal<
25
+ typeof import("@taiji/extension-protocol/background-task")
26
+ >();
24
27
  return {
25
28
  ...orig,
26
- killProcessTree: (pid: number): void => {
29
+ killProcessTree: (pid: number, onFallback?: (step: string, err: unknown) => void): void => {
27
30
  killTreeCalls.push(pid);
28
- orig.killProcessTree(pid);
31
+ orig.killProcessTree(pid, onFallback);
29
32
  },
30
33
  };
31
34
  });
32
35
 
36
+ import { isPidAlive } from "@taiji/extension-protocol/background-task";
37
+
33
38
  import { createBashKillToolDefinition } from "../bash-kill-tool.ts";
34
39
  import { createBashOutputToolDefinition } from "../bash-output-tool.ts";
35
40
  import { handleTaskExit, refreshPiReference, resetNotifyForTest } from "../background/notify.ts";
@@ -39,7 +44,6 @@ import type { RegistryEntry } from "../background/types.ts";
39
44
  import { DEFAULT_MAX_CONCURRENT_BACKGROUND, spawnBackgroundTask } from "../background/spawn-background.ts";
40
45
  import { clearTaskStoreForTest, getActiveTasks, getTask, markKillingIntent } from "../background/task-store.ts";
41
46
  import { reapBackgroundTasksNow, resetProcessExitGuardForTest } from "../background/process-exit-guard.ts";
42
- import { isPidAlive } from "../kill-tree.ts";
43
47
 
44
48
  const DATA_DIR = mkdtempSync(join(tmpdir(), "bte-data-"));
45
49
  dataDirRef.dir = DATA_DIR;
@@ -402,7 +406,7 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
402
406
  expect(result.killed).toBe(false);
403
407
  expect(result.reason).toBe("cross-process running task owned by another pi process");
404
408
  // 收殓已下沉 runtime(u-bte-remove):hint 指向 runtime 收殓而非 reaper
405
- expect(result.hint).toContain("xyz-agent runtime");
409
+ expect(result.hint).toContain("taiji runtime");
406
410
  expect(killTreeCalls).not.toContain(foreignPid); // 未发 kill 信号
407
411
  });
408
412
 
@@ -121,11 +121,9 @@ describe("session_start chain: reconcile (M3, reap sunk into runtime by u-bte-re
121
121
  status: "cancelled",
122
122
  }),
123
123
  );
124
- // 对账之外尽力补一次 emit(幂等兜底,纯日志性质;失败无害)
125
- expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
126
- id: "bt-1700000000-idx001",
127
- reason: "cancelled",
128
- });
124
+ // 尽力补 emit 已随 ext-simplify-13 删除(恒 no-op 死路径)——对账后不得有任何
125
+ // bus emit,appendEntry 即唯一权威路径
126
+ expect(pi.events.emit).not.toHaveBeenCalled();
129
127
  } finally {
130
128
  rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
131
129
  dataDirRef.dir = "/tmp/bte-fake-agent-dir";
@@ -1,6 +1,6 @@
1
1
  // src/__tests__/maintenance-once.test.ts —— 收殓下沉后的 session_start 维护链
2
- // 语义(实施单元 u-bte-remove,设计 docs/design/file-lock-unification-and-reaper-
3
- // sink.md §3.2 D2「extension 删 session_start reaper」/ §3.3 D3 粒度段 / §4 S6
2
+ // 语义(实施单元 u-bte-remove,设计 docs/architecture/file-lock-unification-and-
3
+ // reaper-sink.md §3.2 D2「extension 删 session_start reaper」/ §3.3 D3 粒度段 / §4 S6
4
4
  // 「批 2 后 reap 类操作不再执行」):
5
5
  // - reconcilePendingEntries 是 session 级豁免类,每 session_start 派发都执行
6
6
  // (startup/resume/new 多派发 ×N——含 factory 二调 handler 累积的真实形态)
@@ -8,7 +8,7 @@ import { join } from "node:path";
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
9
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
10
 
11
- import { killProcessTree } from "../kill-tree.ts";
11
+ import { killProcessTree } from "@taiji/extension-protocol/background-task";
12
12
  import {
13
13
  BACKGROUND_BASH_CUSTOM_TYPE,
14
14
  buildNotificationContent,
@@ -1,14 +1,19 @@
1
1
  // src/__tests__/pending-reconcile.test.ts —— M3 session_start 对账单元(§3.5 接入细则 4):
2
- // 差集收集 / 三类僵尸场景 appendEntry 权威路径 + 尽力 emit / 活任务与缺条目保守跳过
2
+ // bt- 差集(protocol collectActivePendingIds 单点消费)/ 三类僵尸场景 appendEntry
3
+ // 权威路径(唯一写路径,无 emit)/ 活任务与缺条目保守跳过
3
4
  import { spawnSync } from "node:child_process";
4
5
  import { mkdtempSync, rmSync } from "node:fs";
5
6
  import { tmpdir } from "node:os";
6
7
  import { join } from "node:path";
7
8
 
9
+ import {
10
+ BACKGROUND_TASK_ID_PREFIX,
11
+ collectActivePendingIds,
12
+ mapReasonToStatus,
13
+ } from "@taiji/extension-protocol";
8
14
  import { afterEach, describe, expect, it, vi } from "vitest";
9
15
 
10
16
  import {
11
- collectUnsettledTaskIds,
12
17
  reconcilePendingEntries,
13
18
  type ReconcilePi,
14
19
  } from "../background/pending-reconcile.ts";
@@ -18,9 +23,13 @@ import type { RegistryEntry } from "../background/types.ts";
18
23
  const DATA_DIR = mkdtempSync(join(tmpdir(), "bte-reconcile-"));
19
24
  const SESSION_ID = "sess-reconcile";
20
25
 
21
- function createMockPi(overrides: Partial<Pick<ReconcilePi, "appendEntry">> = {}): ReconcilePi {
26
+ function createMockPi(overrides: Partial<Pick<ReconcilePi, "appendEntry">> = {}): ReconcilePi & {
27
+ events: { emit: ReturnType<typeof vi.fn> };
28
+ } {
22
29
  return {
23
30
  appendEntry: vi.fn(),
31
+ // emit 路径已随 ext-simplify-13 删除(恒 no-op 死路径):events spy 仅用于
32
+ // 断言实现不再触达 bus emit
24
33
  events: { emit: vi.fn() },
25
34
  ...overrides,
26
35
  };
@@ -56,41 +65,60 @@ afterEach(() => {
56
65
  vi.restoreAllMocks();
57
66
  });
58
67
 
59
- describe("collectUnsettledTaskIds (bt- prefixed register/unregister diff)", () => {
68
+ describe("bt- diff via protocol collectActivePendingIds (single-source diff core)", () => {
69
+ // 差集本体单点在 protocol pending-entries(ext-simplify-13 D3,与 pending 守卫
70
+ // 判据同源);此处锁定 bte 对账消费面的语义(idPrefix = BACKGROUND_TASK_ID_PREFIX)
60
71
  it("collects bt- registers without a matching unregister", () => {
61
- const ids = collectUnsettledTaskIds([
62
- registerEntry("bt-a"),
63
- registerEntry("bt-b"),
64
- { customType: "pending:unregister", data: { id: "bt-b", reason: "completed" } },
65
- ]);
72
+ const ids = collectActivePendingIds(
73
+ [
74
+ registerEntry("bt-a"),
75
+ registerEntry("bt-b"),
76
+ { customType: "pending:unregister", data: { id: "bt-b", reason: "completed" } },
77
+ ],
78
+ { idPrefix: BACKGROUND_TASK_ID_PREFIX },
79
+ );
66
80
  expect([...ids]).toEqual(["bt-a"]);
67
81
  });
68
82
 
69
83
  it("ignores non-bt ids (subagent bg-/run- namespace not ours)", () => {
70
- const ids = collectUnsettledTaskIds([
71
- registerEntry("bg-1"),
72
- registerEntry("run-x-1"),
73
- registerEntry("bt-a"),
74
- ]);
84
+ const ids = collectActivePendingIds(
85
+ [registerEntry("bg-1"), registerEntry("run-x-1"), registerEntry("bt-a")],
86
+ { idPrefix: BACKGROUND_TASK_ID_PREFIX },
87
+ );
75
88
  expect([...ids]).toEqual(["bt-a"]);
76
89
  });
77
90
 
78
91
  it("dedupes repeated registers and tolerates malformed entries", () => {
79
- const ids = collectUnsettledTaskIds([
80
- null,
81
- undefined,
82
- { customType: "pending:register" }, // data 缺失
83
- { customType: "pending:register", data: { id: 42 } }, // id 非字符串
84
- registerEntry("bt-a"),
85
- registerEntry("bt-a"),
86
- { customType: "other" },
87
- ]);
92
+ const ids = collectActivePendingIds(
93
+ [
94
+ null,
95
+ undefined,
96
+ { customType: "pending:register" }, // data 缺失
97
+ { customType: "pending:register", data: { id: 42 } }, // id 非字符串
98
+ registerEntry("bt-a"),
99
+ registerEntry("bt-a"),
100
+ { customType: "other" },
101
+ ],
102
+ { idPrefix: BACKGROUND_TASK_ID_PREFIX },
103
+ );
88
104
  expect([...ids]).toEqual(["bt-a"]);
89
105
  });
106
+
107
+ it("register→unregister→register same id stays settled (global cancellation, §5.4①)", () => {
108
+ const ids = collectActivePendingIds(
109
+ [
110
+ registerEntry("bt-a"),
111
+ { customType: "pending:unregister", data: { id: "bt-a", reason: "completed" } },
112
+ registerEntry("bt-a"),
113
+ ],
114
+ { idPrefix: BACKGROUND_TASK_ID_PREFIX },
115
+ );
116
+ expect(ids.size).toBe(0);
117
+ });
90
118
  });
91
119
 
92
120
  describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry never written)", () => {
93
- it("appends pending:unregister {id, reason, status} matching pending-notifications entry shape + best-effort emit", () => {
121
+ it("appends pending:unregister {id, reason, status} matching pending-notifications entry shape", () => {
94
122
  const entry = makeRegistryEntry({
95
123
  taskId: "bt-1700000000-zomb01",
96
124
  state: "exited",
@@ -109,11 +137,6 @@ describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry
109
137
  reason: "completed",
110
138
  status: "completed",
111
139
  });
112
- // emit 形态 {id, reason}(status 由 listener mapReasonToStatus 计算)
113
- expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
114
- id: entry.taskId,
115
- reason: "completed",
116
- });
117
140
  });
118
141
 
119
142
  it("maps exited reason/exitCode through the same mapping as the exit edge", () => {
@@ -127,14 +150,43 @@ describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry
127
150
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
128
151
  const pi = createMockPi();
129
152
  reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
130
- expect(pi.appendEntry).toHaveBeenCalledWith(
131
- "pending:unregister",
132
- { id: entry.taskId, reason: expectedReason, status: expectedReason },
133
- );
153
+ // status 断言按「权威映射(reason)」而非 reason 本身表述(D10:identity 假设消除)
154
+ expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
155
+ id: entry.taskId,
156
+ reason: expectedReason,
157
+ status: mapReasonToStatus(expectedReason),
158
+ });
134
159
  }
135
160
  });
136
161
  });
137
162
 
163
+ describe("entry status maps via protocol mapReasonToStatus (D10 single-source, identity assumption removed)", () => {
164
+ // bte 写侧 status 与 pending-notifications unregister listener 同引 protocol 单点
165
+ // (ext-simplify-17 D10)——原 status: reason 的 identity 假设在非 identity reason
166
+ // (budget_limited→failed 等)上会静默漂移,映射表演化时两写侧不再可能分叉。
167
+ // settle 产域四值当前恰为 identity 映射,但断言面按「status === mapReasonToStatus
168
+ // (reason)」表述:映射表日后改任何一行的口径,本组用例随实现同源自洽。
169
+ it("appended status equals the authoritative mapping for every reason the settle path can produce", () => {
170
+ // settledPendingReason 全产域:exited 三分支(natural/timeout/killed|process-exit
171
+ // 经 toPendingReason)+ orphaned/判死兜底 cancelled + reason 缺失防御分支
172
+ const settleReasons = ["completed", "failed", "time_limited", "cancelled"] as const;
173
+ for (const reason of settleReasons) {
174
+ expect(mapReasonToStatus(reason)).toBe(reason); // 当前口径下四值均为 identity 映射
175
+ }
176
+ });
177
+
178
+ it("non-identity reasons exist in the authoritative mapping — identity assumption is provably gone", () => {
179
+ // 这些 reason 上 status ≠ reason 本身:若写侧仍持 identity 假设(status: reason),
180
+ // settle 产域将来扩展到这些值时落盘 entry 会静默漂移;现实现单点映射自动正确
181
+ expect(mapReasonToStatus("budget_limited")).toBe("failed");
182
+ expect(mapReasonToStatus("interrupted")).toBe("aborted");
183
+ expect(mapReasonToStatus("interrupted-by-restart")).toBe("aborted");
184
+ expect(mapReasonToStatus("interrupted-by-parent")).toBe("aborted");
185
+ expect(mapReasonToStatus("reopened")).toBe("completed");
186
+ expect(mapReasonToStatus("some-future-reason")).toBe("completed");
187
+ });
188
+ });
189
+
138
190
  describe("reconcile scenario ②: collector-only orphan (registry orphaned, session file untouched)", () => {
139
191
  it("appends unregister with cancelled for orphaned entries", () => {
140
192
  const entry = makeRegistryEntry({ state: "orphaned" });
@@ -214,6 +266,19 @@ describe("conservative no-op paths", () => {
214
266
  });
215
267
  });
216
268
 
269
+ describe("best-effort emit removed (ext-simplify-13 D5: appendEntry is the sole authority)", () => {
270
+ it("reconcile never touches pi.events (the emit path is deleted, not just disabled)", () => {
271
+ const entry = makeRegistryEntry({ state: "orphaned" });
272
+ writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
273
+ const pi = createMockPi();
274
+
275
+ reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
276
+
277
+ expect(pi.appendEntry).toHaveBeenCalledTimes(1);
278
+ expect(pi.events.emit).not.toHaveBeenCalled();
279
+ });
280
+ });
281
+
217
282
  describe("appendEntry failure tolerance", () => {
218
283
  it("one appendEntry throw does not block the remaining zombie (count only successful)", () => {
219
284
  const first = makeRegistryEntry({ taskId: "bt-1700000000-zomb01", state: "orphaned" });
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
 
6
6
  import { afterEach, describe, expect, it } from "vitest";
7
7
 
8
- import { MAX_TERMINAL_REGISTRY_ENTRIES } from "@xyz-agent/extension-protocol";
8
+ import { MAX_TERMINAL_REGISTRY_ENTRIES } from "@taiji/extension-protocol";
9
9
 
10
10
  import {
11
11
  getRegistryPath,
@@ -0,0 +1,29 @@
1
+ // src/__tests__/subagent-guard.test.ts
2
+ //
3
+ // subagent-guard 判据钉值(ext-simplify-17 D4 重锚定后):
4
+ // - TAIJI_AGENT_SUBAGENT === "1" 命中(引擎 spawn 链恒注入的现行标记)
5
+ // - 其他值 / 缺失不命中(顶层会话不误降级,background 正常后台化)
6
+ //
7
+ // [HISTORICAL] 对照(D4 重锚前的旧行为):旧判据查 PI_SUBAGENT_* 身份键族两枚、
8
+ // 任一存在即命中——该键族已无写入方,旧判据实际恒 false(降级从未生效)。旧键
9
+ // 不命中的钉值在 ext-guards predicates 测试(谓词实现方领地),此处只钉 bte
10
+ // 消费面的现行语义。
11
+
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import { isSubagentProcess } from "../background/subagent-guard.ts";
15
+
16
+ describe("isSubagentProcess(subagent-guard,D4 重锚后)", () => {
17
+ it("TAIJI_AGENT_SUBAGENT=1 命中(引擎链 subagent → D14 background 降级前提恢复)", () => {
18
+ expect(isSubagentProcess({ TAIJI_AGENT_SUBAGENT: "1" })).toBe(true);
19
+ });
20
+
21
+ it("值非 \"1\" 不命中", () => {
22
+ expect(isSubagentProcess({ TAIJI_AGENT_SUBAGENT: "0" })).toBe(false);
23
+ expect(isSubagentProcess({ TAIJI_AGENT_SUBAGENT: "" })).toBe(false);
24
+ });
25
+
26
+ it("缺失不命中(顶层会话 → background 正常后台化,guard 不误命中)", () => {
27
+ expect(isSubagentProcess({})).toBe(false);
28
+ });
29
+ });
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
30
31
  import { getLogger } from "@zhushanwen/pi-extension-logger";
31
32
 
32
33
  import type { BackgroundTask, BackgroundTaskEndReason } from "./types.ts";
@@ -107,7 +108,7 @@ export function emitPendingRegister(task: BackgroundTask): void {
107
108
  pi.events.emit("pending:register", { id: task.taskId, type: "bash", name });
108
109
  } catch (err) {
109
110
  logger.warn("pending:register emit failed (stale bus?); task unaffected", {
110
- detail: { taskId: task.taskId, err: err instanceof Error ? err.message : String(err) },
111
+ detail: { taskId: task.taskId, err: toErrorMessage(err) },
111
112
  });
112
113
  }
113
114
  }
@@ -130,7 +131,7 @@ export function emitPendingUnregister(
130
131
  pi.events.emit("pending:unregister", { id: taskId, reason: toPendingReason(reason, exitCode) });
131
132
  } catch (err) {
132
133
  logger.warn("pending:unregister emit failed (stale bus?); reconcile covers on next session_start", {
133
- detail: { taskId, err: err instanceof Error ? err.message : String(err) },
134
+ detail: { taskId, err: toErrorMessage(err) },
134
135
  });
135
136
  }
136
137
  }
@@ -160,7 +161,7 @@ function sendTaskFinishedMessage(task: BackgroundTask): void {
160
161
  logger.warn("background task notify sendMessage failed; poll continues", {
161
162
  detail: {
162
163
  taskId: task.taskId,
163
- err: err instanceof Error ? err.message : String(err),
164
+ err: toErrorMessage(err),
164
165
  },
165
166
  });
166
167
  }
@@ -1,84 +1,28 @@
1
1
  /**
2
- * 输出文件 tail 读取(D7:输出落文件不占内存,查询时按需读尾部)。
2
+ * 输出文件 tail 读取薄壳(D7:输出落文件不占内存,查询时按需读尾部)。
3
3
  *
4
- * 截断规则与 pi 内置 bash 一致:末尾 2000 行 / 50KB 先到为准(bash.js truncate.ts
5
- * DEFAULT_MAX_LINES/DEFAULT_MAX_BYTES)。实现从文件末尾按字节窗口读(不整读大文件,
6
- * O(maxBytes) 而非 O(fileSize))。
4
+ * tail 算法单点在 extension-protocol(ext-simplify-13:与 runtime 桌面预览侧共用,
5
+ * 原两份同构实现签名已实际漂移)。本模块只保留 bte 私有需求:
6
+ * - 50KB / 2000 行截断上限常量(pi 内置 bash 同款,bash.js truncate.ts)——口径是
7
+ * 本包产品决策,由调用方(bash-output-tool)传参给 protocol 原语
8
+ * - readTailSummary:exit 边沿的 tail 摘要(存条目 / M3 通知用)
7
9
  */
8
10
 
9
- import { openSync, readSync, closeSync, statSync } from "node:fs";
10
-
11
- import { getLogger } from "@zhushanwen/pi-extension-logger";
12
-
13
- const logger = getLogger("base-tool-enhance");
11
+ import { readOutputTail as readOutputTailPrimitive } from "@taiji/extension-protocol/background-task";
14
12
 
15
13
  /** pi 内置 bash 同款截断上限(last 2000 lines / 50KB = 51200 bytes,先到为准)。 */
16
14
  export const TAIL_MAX_LINES = 2000;
17
15
  export const TAIL_MAX_BYTES = 51_200;
18
- /** 字节窗口余量:截窗口可能吞掉首行前半,余量降低残行概率。 */
19
- const TAIL_WINDOW_MARGIN_BYTES = 64;
20
16
  /** exit 边沿 tail 摘要参数(存条目/M3 通知用)。 */
21
17
  const SUMMARY_TAIL_LINES = 5;
22
18
  const SUMMARY_MAX_CHARS = 800;
23
19
 
24
- export interface TailResult {
25
- output: string;
26
- /** 读取窗口被截断(内容超上限)时 true。 */
27
- truncated: boolean;
28
- }
29
-
30
- /**
31
- * 读文件尾部(行/字节双上限)。文件不存在/不可读返回 undefined——bash_output 对
32
- * 此降级为 {output:"<lost>"} 不崩溃(§3.6)。
33
- */
34
- export function readOutputTail(
35
- outputFile: string,
36
- maxLines: number = TAIL_MAX_LINES,
37
- maxBytes: number = TAIL_MAX_BYTES,
38
- ): TailResult | undefined {
39
- let size: number;
40
- try {
41
- size = statSync(outputFile).size;
42
- } catch {
43
- return undefined;
44
- }
45
- // 字节窗口从末尾取 maxBytes + 余量(截窗口可能吞掉首行前半,余量降低概率)
46
- const windowSize = Math.min(size, maxBytes + TAIL_WINDOW_MARGIN_BYTES);
47
- const buffer = Buffer.alloc(windowSize);
48
- let fd: number | undefined;
49
- try {
50
- fd = openSync(outputFile, "r");
51
- readSync(fd, buffer, 0, windowSize, size - windowSize);
52
- } catch {
53
- return undefined;
54
- } finally {
55
- if (fd !== undefined) {
56
- try {
57
- closeSync(fd);
58
- } catch (err) {
59
- // 已读完内容,close 失败不影响结果,仅留诊断
60
- logger.debug("output tail close failed", {
61
- detail: { outputFile, err: err instanceof Error ? err.message : String(err) },
62
- });
63
- }
64
- }
65
- }
66
- const text = buffer.toString("utf8");
67
- const lines = text.split("\n");
68
- // 窗口起点可能落在行中间:首行是残行时丢弃(它必然不完整)
69
- const firstLineIsPartial = windowSize < size && lines.length > 0;
70
- const effectiveLines = firstLineIsPartial ? lines.slice(1) : lines;
71
- const byteTruncated = size > maxBytes;
72
- const shown = effectiveLines.slice(-maxLines).join("\n");
73
- return { output: shown, truncated: byteTruncated || effectiveLines.length > maxLines };
74
- }
75
-
76
20
  /**
77
21
  * 轮询器 exit 边沿的 tail 摘要(存进条目、M3 通知用):末尾几行的紧凑文本。
78
22
  */
79
23
  export function readTailSummary(outputFile: string, maxChars: number = SUMMARY_MAX_CHARS): string | undefined {
80
- const tail = readOutputTail(outputFile, SUMMARY_TAIL_LINES, maxChars);
24
+ const tail = readOutputTailPrimitive(outputFile, { maxLines: SUMMARY_TAIL_LINES, maxBytes: maxChars });
81
25
  if (tail === undefined) return undefined;
82
- const compact = tail.output.trim();
26
+ const compact = tail.text.trim();
83
27
  return compact.length > 0 ? compact.slice(-maxChars) : undefined;
84
28
  }