@zhushanwen/pi-unified-hooks 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-unified-hooks",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Unified hooks extension - collect scattered hooks in one place for easy maintenance",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -15,9 +15,13 @@
15
15
  "hooks"
16
16
  ],
17
17
  "license": "MIT",
18
+ "dependencies": {
19
+ "@zhushanwen/pi-extension-logger": "0.2.1"
20
+ },
18
21
  "peerDependencies": {
19
22
  "@earendil-works/pi-coding-agent": "*"
20
23
  },
24
+ "peerDependenciesMeta": {},
21
25
  "devDependencies": {
22
26
  "vitest": "^4.1.8"
23
27
  },
@@ -18,15 +18,10 @@ vi.mock("../hooks/test-timeout-guard.ts", () => ({
18
18
  setupTestTimeoutGuard: vi.fn(),
19
19
  }));
20
20
 
21
- vi.mock("../hooks/subagent-list-injector.ts", () => ({
22
- setupSubagentListInjector: vi.fn(),
23
- }));
24
-
25
21
  // Re-import after mocking so the mocked versions are used
26
22
  import { setupToolErrorHandler } from "../hooks/tool-error-handler.ts";
27
23
  import { setupNetworkTimeoutGuard } from "../hooks/network-timeout-guard.ts";
28
24
  import { setupTestTimeoutGuard } from "../hooks/test-timeout-guard.ts";
29
- import { setupSubagentListInjector } from "../hooks/subagent-list-injector.ts";
30
25
 
31
26
  import unifiedHooksExtension from "../index.ts";
32
27
 
@@ -55,7 +50,7 @@ function getSessionStartHandler(pi: ReturnType<typeof createMockPi>): (event: un
55
50
 
56
51
  // --- tests ---
57
52
  describe("session_start handler", () => {
58
- it("notifies 'info' when all hooks are enabled", () => {
53
+ it("does not notify when all hooks are enabled (only appendEntry)", () => {
59
54
  const pi = createMockPi();
60
55
  const { ctx, notify } = createMockCtx();
61
56
 
@@ -63,22 +58,21 @@ describe("session_start handler", () => {
63
58
  (setupToolErrorHandler as ReturnType<typeof vi.fn>).mockImplementation(() => {});
64
59
  (setupNetworkTimeoutGuard as ReturnType<typeof vi.fn>).mockImplementation(() => {});
65
60
  (setupTestTimeoutGuard as ReturnType<typeof vi.fn>).mockImplementation(() => {});
66
- (setupSubagentListInjector as ReturnType<typeof vi.fn>).mockImplementation(() => {});
67
61
 
68
62
  unifiedHooksExtension(pi as unknown as ExtensionAPI);
69
63
 
70
64
  const handler = getSessionStartHandler(pi);
71
65
  handler({}, ctx);
72
66
 
73
- expect(notify).toHaveBeenCalledTimes(1);
74
- expect(notify.mock.calls[0]![1]).toBe("info");
67
+ // 全成功时不 notify(避免刷屏),只 appendEntry
68
+ expect(notify).not.toHaveBeenCalled();
75
69
  expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", {
76
- enabled: ["tool-error-handler", "network-timeout-guard", "test-timeout-guard", "subagent-list-injector"],
70
+ enabled: ["tool-error-handler", "network-timeout-guard", "test-timeout-guard"],
77
71
  disabled: [],
78
72
  });
79
73
  });
80
74
 
81
- it("notifies 'warning' and lists disabled hooks when some hooks fail", () => {
75
+ it("notifies 'warning' listing only disabled hooks when some hooks fail", () => {
82
76
  const pi = createMockPi();
83
77
  const { ctx, notify } = createMockCtx();
84
78
 
@@ -90,7 +84,6 @@ describe("session_start handler", () => {
90
84
  (setupTestTimeoutGuard as ReturnType<typeof vi.fn>).mockImplementation(() => {
91
85
  throw new Error("timeout");
92
86
  });
93
- (setupSubagentListInjector as ReturnType<typeof vi.fn>).mockImplementation(() => {});
94
87
 
95
88
  unifiedHooksExtension(pi as unknown as ExtensionAPI);
96
89
 
@@ -101,14 +94,13 @@ describe("session_start handler", () => {
101
94
  const [msg, level] = notify.mock.calls[0]!;
102
95
  expect(level).toBe("warning");
103
96
  expect(msg).toContain("Failed: network-timeout-guard, test-timeout-guard");
104
- expect(msg).toContain("Loaded: tool-error-handler, subagent-list-injector");
105
97
  expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", {
106
- enabled: ["tool-error-handler", "subagent-list-injector"],
98
+ enabled: ["tool-error-handler"],
107
99
  disabled: ["network-timeout-guard", "test-timeout-guard"],
108
100
  });
109
101
  });
110
102
 
111
- it("notifies 'warning' when all hooks are disabled", () => {
103
+ it("notifies 'warning' listing all hooks when all are disabled", () => {
112
104
  const pi = createMockPi();
113
105
  const { ctx, notify } = createMockCtx();
114
106
 
@@ -122,9 +114,6 @@ describe("session_start handler", () => {
122
114
  (setupTestTimeoutGuard as ReturnType<typeof vi.fn>).mockImplementation(() => {
123
115
  throw new Error("c");
124
116
  });
125
- (setupSubagentListInjector as ReturnType<typeof vi.fn>).mockImplementation(() => {
126
- throw new Error("d");
127
- });
128
117
 
129
118
  unifiedHooksExtension(pi as unknown as ExtensionAPI);
130
119
 
@@ -133,10 +122,10 @@ describe("session_start handler", () => {
133
122
 
134
123
  expect(notify.mock.calls[0]![1]).toBe("warning");
135
124
  const msg = notify.mock.calls[0]![0] as string;
136
- expect(msg).toContain("(none)");
125
+ expect(msg).toContain("Failed: tool-error-handler, network-timeout-guard, test-timeout-guard");
137
126
  expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", {
138
127
  enabled: [],
139
- disabled: ["tool-error-handler", "network-timeout-guard", "test-timeout-guard", "subagent-list-injector"],
128
+ disabled: ["tool-error-handler", "network-timeout-guard", "test-timeout-guard"],
140
129
  });
141
130
  });
142
131
 
@@ -3,7 +3,19 @@ import { describe, expect, it, vi } from "vitest";
3
3
 
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
 
6
- import { setupToolErrorHandler, type HookContext } from "../hooks/tool-error-handler.ts";
6
+ // Mock 共享 logger,让 logger.warn 可被 spy
7
+ const { loggerMock } = vi.hoisted(() => ({
8
+ loggerMock: {
9
+ debug: vi.fn(),
10
+ warn: vi.fn(),
11
+ error: vi.fn(),
12
+ },
13
+ }));
14
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
15
+ getLogger: () => loggerMock,
16
+ }));
17
+
18
+ import { setupToolErrorHandler } from "../hooks/tool-error-handler.ts";
7
19
 
8
20
  // --- helper types ---
9
21
  interface MockPi {
@@ -19,12 +31,6 @@ function createMockPi(overrides?: Partial<MockPi>): MockPi {
19
31
  };
20
32
  }
21
33
 
22
- function createMockCtx(): { ctx: HookContext; notify: ReturnType<typeof vi.fn> } {
23
- const notify = vi.fn();
24
- const ctx = { ui: { notify } };
25
- return { ctx, notify };
26
- }
27
-
28
34
  describe("setupToolErrorHandler", () => {
29
35
  it("registers a handler on the tool_execution_end event", () => {
30
36
  const pi = createMockPi();
@@ -34,211 +40,129 @@ describe("setupToolErrorHandler", () => {
34
40
  expect(pi.on).toHaveBeenCalledWith("tool_execution_end", expect.any(Function));
35
41
  });
36
42
 
37
- it("notifies via ctx.ui.notify and persists via appendEntry on isError:true", async () => {
43
+ it("logs via logger.warn and appendEntry with dedicated customType on isError:true", async () => {
38
44
  const pi = createMockPi();
39
- const { ctx, notify } = createMockCtx();
45
+ loggerMock.warn.mockClear();
46
+ pi.appendEntry.mockClear();
40
47
 
41
48
  setupToolErrorHandler(pi as unknown as ExtensionAPI);
42
- const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
49
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown) => Promise<void>;
43
50
 
44
- await handler(
45
- { isError: true, toolName: "read", toolCallId: "call-42" },
46
- ctx,
47
- );
51
+ await handler({ isError: true, toolName: "read", toolCallId: "call-42" });
48
52
 
49
- expect(notify).toHaveBeenCalledTimes(1);
50
- expect(notify).toHaveBeenCalledWith(
53
+ // logger.warn 被调一次(内部走泛化 appendEntry customType)
54
+ expect(loggerMock.warn).toHaveBeenCalledTimes(1);
55
+ expect(loggerMock.warn).toHaveBeenCalledWith(
51
56
  "[unified-hooks] read error (callId=call-42)",
52
- "warning",
57
+ expect.objectContaining({
58
+ toolName: "read",
59
+ toolCallId: "call-42",
60
+ errorText: null,
61
+ }),
53
62
  );
63
+ // 额外 appendEntry 用专属 customType "unified-hooks:tool-error",
64
+ // 保留按 entry type 过滤 tool 错误的埋点契约
54
65
  expect(pi.appendEntry).toHaveBeenCalledTimes(1);
55
- expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:tool-error", {
56
- toolName: "read",
57
- toolCallId: "call-42",
58
- errorText: null,
59
- });
60
- });
61
-
62
- it("does nothing on isError:false (no notify, no appendEntry)", async () => {
63
- const pi = createMockPi();
64
- const { ctx, notify } = createMockCtx();
65
-
66
- setupToolErrorHandler(pi as unknown as ExtensionAPI);
67
- const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
68
-
69
- await handler(
70
- { isError: false, toolName: "bash", toolCallId: "call-99" },
71
- ctx,
66
+ expect(pi.appendEntry).toHaveBeenCalledWith(
67
+ "unified-hooks:tool-error",
68
+ expect.objectContaining({
69
+ toolName: "read",
70
+ toolCallId: "call-42",
71
+ errorText: null,
72
+ }),
72
73
  );
73
-
74
- expect(notify).not.toHaveBeenCalled();
75
- expect(pi.appendEntry).not.toHaveBeenCalled();
76
74
  });
77
75
 
78
- it("uses the warn notification type for errors", async () => {
76
+ it("does nothing on isError:false (no logger.warn)", async () => {
79
77
  const pi = createMockPi();
80
- const { ctx, notify } = createMockCtx();
78
+ loggerMock.warn.mockClear();
81
79
 
82
80
  setupToolErrorHandler(pi as unknown as ExtensionAPI);
83
- const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
81
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown) => Promise<void>;
84
82
 
85
- await handler(
86
- { isError: true, toolName: "edit", toolCallId: "c1" },
87
- ctx,
88
- );
83
+ await handler({ isError: false, toolName: "bash", toolCallId: "call-99" });
89
84
 
90
- // Second arg of notify is the type — must be "warning" (matches SDK literal union, not info/error).
91
- expect(notify.mock.calls[0]![1]).toBe("warning");
85
+ expect(loggerMock.warn).not.toHaveBeenCalled();
92
86
  });
93
87
 
94
88
  // --- edge cases ---
95
89
 
96
90
  it("propagates if pi.on throws during registration", () => {
97
91
  const pi = createMockPi({
98
- on: vi.fn(() => { throw new Error("registration failed"); }),
92
+ on: vi.fn(() => { throw new Error("registration failed"); }),
99
93
  });
100
94
 
101
95
  expect(() => setupToolErrorHandler(pi as unknown as ExtensionAPI)).toThrow("registration failed");
102
96
  });
103
97
 
104
- it("does not crash if handler callback throws (notify throws)", async () => {
105
- const pi = createMockPi();
106
- const { ctx, notify } = createMockCtx();
107
- notify.mockImplementation(() => { throw new Error("notify broke"); });
108
-
109
- setupToolErrorHandler(pi as unknown as ExtensionAPI);
110
- const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
111
-
112
- await expect(
113
- handler({ isError: true, toolName: "bash", toolCallId: "c2" }, ctx),
114
- ).rejects.toThrow("notify broke");
115
-
116
- // appendEntry should NOT have been called since notify threw first
117
- expect(pi.appendEntry).not.toHaveBeenCalled();
118
- });
119
-
120
- it("does not crash if handler callback throws (appendEntry throws)", async () => {
121
- const pi = createMockPi();
122
- const { ctx, notify } = createMockCtx();
123
- pi.appendEntry.mockImplementation(() => { throw new Error("append broke"); });
124
-
125
- setupToolErrorHandler(pi as unknown as ExtensionAPI);
126
- const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
127
-
128
- await expect(
129
- handler({ isError: true, toolName: "grep", toolCallId: "c3" }, ctx),
130
- ).rejects.toThrow("append broke");
131
-
132
- // notify was called before appendEntry threw
133
- expect(notify).toHaveBeenCalledTimes(1);
134
- });
135
-
136
98
  it("handles concurrent error events independently", async () => {
137
99
  const pi = createMockPi();
138
- const { ctx, notify } = createMockCtx();
100
+ loggerMock.warn.mockClear();
139
101
 
140
102
  setupToolErrorHandler(pi as unknown as ExtensionAPI);
141
- const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
103
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown) => Promise<void>;
142
104
 
143
105
  await Promise.all([
144
- handler({ isError: true, toolName: "read", toolCallId: "e1" }, ctx),
145
- handler({ isError: true, toolName: "bash", toolCallId: "e2" }, ctx),
146
- handler({ isError: false, toolName: "edit", toolCallId: "e3" }, ctx),
106
+ handler({ isError: true, toolName: "read", toolCallId: "e1" }),
107
+ handler({ isError: true, toolName: "bash", toolCallId: "e2" }),
108
+ handler({ isError: false, toolName: "edit", toolCallId: "e3" }),
147
109
  ]);
148
110
 
149
- expect(notify).toHaveBeenCalledTimes(2);
150
- expect(pi.appendEntry).toHaveBeenCalledTimes(2);
151
-
152
- // verify both calls persisted independently
153
- const calls = pi.appendEntry.mock.calls.map((c) => c[1]);
154
- expect(calls).toEqual(
155
- expect.arrayContaining([
156
- { toolName: "read", toolCallId: "e1", errorText: null },
157
- { toolName: "bash", toolCallId: "e2", errorText: null },
158
- ]),
111
+ expect(loggerMock.warn).toHaveBeenCalledTimes(2);
112
+ expect(loggerMock.warn).toHaveBeenCalledWith(
113
+ "[unified-hooks] read error (callId=e1)",
114
+ expect.objectContaining({ toolName: "read", toolCallId: "e1" }),
115
+ );
116
+ expect(loggerMock.warn).toHaveBeenCalledWith(
117
+ "[unified-hooks] bash error (callId=e2)",
118
+ expect.objectContaining({ toolName: "bash", toolCallId: "e2" }),
159
119
  );
160
120
  });
161
121
 
162
- it("falls back to console.warn when ctx.ui is undefined (headless session)", async () => {
163
- // [HISTORICAL] headless / RPC 会话 ctx.ui 为 undefined,旧实现直接 ctx.ui.notify 会 NPE。
122
+ // --- errorText 提取(核心能力)---
123
+
124
+ it("从 result.content[0].text 提取错误文本(如 'hub disposed')", async () => {
164
125
  const pi = createMockPi();
165
- const ctx = { ui: undefined } as unknown as HookContext;
166
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
126
+ loggerMock.warn.mockClear();
167
127
 
168
128
  setupToolErrorHandler(pi as unknown as ExtensionAPI);
169
- const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
129
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown) => Promise<void>;
170
130
 
171
- await handler({ isError: true, toolName: "bash", toolCallId: "h1" }, ctx);
172
-
173
- expect(warnSpy).toHaveBeenCalledTimes(1);
174
- expect(warnSpy.mock.calls[0]![0]).toContain("bash error");
175
- // appendEntry 仍持久化
176
- expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:tool-error", {
177
- toolName: "bash",
178
- toolCallId: "h1",
179
- errorText: null,
131
+ await handler({
132
+ isError: true,
133
+ toolName: "subagent",
134
+ toolCallId: "call-disposed",
135
+ result: { content: [{ type: "text", text: "hub disposed" }] },
180
136
  });
181
- warnSpy.mockRestore();
182
- });
183
-
184
- // --- errorText 提取(核心新增能力)---
185
137
 
186
- it("从 result.content[0].text 提取错误文本并拼到 warning(如 'hub disposed')", async () => {
187
- // [HISTORICAL] subagent execute throw 时 Pi 把 error.message 塞进 result.content[0].text。
188
- // 旧实现只打 "(callId=xxx)" 无详情,AI 看不到真实原因(如 hub disposed)只能盲猜。
189
- const pi = createMockPi();
190
- const { ctx, notify } = createMockCtx();
191
-
192
- setupToolErrorHandler(pi as unknown as ExtensionAPI);
193
- const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
194
-
195
- await handler(
196
- {
197
- isError: true,
138
+ expect(loggerMock.warn).toHaveBeenCalledWith(
139
+ "[unified-hooks] subagent error (callId=call-disposed)",
140
+ expect.objectContaining({
198
141
  toolName: "subagent",
199
142
  toolCallId: "call-disposed",
200
- result: { content: [{ type: "text", text: "hub disposed" }] },
201
- },
202
- ctx,
143
+ errorText: "hub disposed",
144
+ }),
203
145
  );
204
-
205
- expect(notify).toHaveBeenCalledWith(
206
- "[unified-hooks] subagent error (callId=call-disposed): hub disposed",
207
- "warning",
208
- );
209
- expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:tool-error", {
210
- toolName: "subagent",
211
- toolCallId: "call-disposed",
212
- errorText: "hub disposed",
213
- });
214
146
  });
215
147
 
216
148
  it("result 缺失或无 content 时降级到无详情(不崩)", async () => {
217
149
  const pi = createMockPi();
218
- const { ctx, notify } = createMockCtx();
150
+ loggerMock.warn.mockClear();
219
151
 
220
152
  setupToolErrorHandler(pi as unknown as ExtensionAPI);
221
- const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
153
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown) => Promise<void>;
222
154
 
223
155
  // result 为 undefined(某些 headless 路径)
224
- await handler({ isError: true, toolName: "bash", toolCallId: "x1" }, ctx);
156
+ await handler({ isError: true, toolName: "bash", toolCallId: "x1" });
225
157
  // result.content 为空数组
226
- await handler(
227
- { isError: true, toolName: "bash", toolCallId: "x2", result: { content: [] } },
228
- ctx,
229
- );
158
+ await handler({ isError: true, toolName: "bash", toolCallId: "x2", result: { content: [] } });
230
159
  // result 不是对象
231
- await handler(
232
- { isError: true, toolName: "bash", toolCallId: "x3", result: "oops" },
233
- ctx,
234
- );
160
+ await handler({ isError: true, toolName: "bash", toolCallId: "x3", result: "oops" });
235
161
 
236
- // 三次都降级为无详情后缀
237
- expect(notify.mock.calls[0]![0]).toBe("[unified-hooks] bash error (callId=x1)");
238
- expect(notify.mock.calls[1]![0]).toBe("[unified-hooks] bash error (callId=x2)");
239
- expect(notify.mock.calls[2]![0]).toBe("[unified-hooks] bash error (callId=x3)");
240
- pi.appendEntry.mock.calls.forEach((c) => {
241
- expect(c[1]).toHaveProperty("errorText", null);
242
- });
162
+ // 三次都降级为无详情
163
+ expect(loggerMock.warn.mock.calls[0]![0]).toBe("[unified-hooks] bash error (callId=x1)");
164
+ expect(loggerMock.warn.mock.calls[0]![1]).toHaveProperty("errorText", null);
165
+ expect(loggerMock.warn.mock.calls[1]![1]).toHaveProperty("errorText", null);
166
+ expect(loggerMock.warn.mock.calls[2]![1]).toHaveProperty("errorText", null);
243
167
  });
244
168
  });
@@ -1,11 +1,19 @@
1
1
  /**
2
2
  * Tool Error Handler Hook
3
3
  *
4
- * Logs tool execution errors for debugging. Can be extended to handle
5
- * specific error patterns with contextual recovery.
4
+ * Records tool execution errors for post-hoc debugging via appendEntry.
5
+ *
6
+ * Design: tool errors already surface in the conversation flow via pi's native
7
+ * tool result (isError → error content fed back to LLM). This hook does NOT
8
+ * call ctx.ui.notify — that would duplicate the error in the TUI notification
9
+ * area, and the "bash error" wording misleads (the error may be a hook's
10
+ * block reason, not a real crash). We only appendEntry for audit trail.
6
11
  */
7
12
 
8
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
15
+
16
+ const logger = getLogger("unified-hooks");
9
17
 
10
18
  /**
11
19
  * Subset of `ToolExecutionEndEvent` fields used by this hook.
@@ -68,30 +76,31 @@ function getStringProperty(obj: unknown, key: string): string | undefined {
68
76
  }
69
77
 
70
78
  export function setupToolErrorHandler(pi: ExtensionAPI): void {
71
- pi.on("tool_execution_end", async (event: unknown, ctx: HookContext) => {
79
+ pi.on("tool_execution_end", async (event: unknown) => {
72
80
  const e = event as ToolExecutionEndLikeEvent;
73
81
  if (!e.isError) return;
74
82
 
75
83
  // 提取错误文本:tool execute throw 时 Pi 把 error.message 塞进 result.content。
76
84
  // SDK 事件无 errorMessage 字段,只能从这里捞;拿不到也不阻断(降级到无详情)。
77
85
  const errorText = extractErrorText(e.result);
78
- const detail = errorText ? `: ${errorText}` : "";
79
- const msg = `[unified-hooks] ${e.toolName} error (callId=${e.toolCallId})${detail}`;
80
86
 
81
- // ctx.ui.notify 走 TUI 通知区,不越过 alternate screen 污染 input。
82
- // console.warn 会写 raw stderr,在 TUI 下泄漏到 input 区。
83
- // headless / RPC 会话 ctx.ui 可能为 undefined——降级到 console.warn 保证不 NPE。
84
- if (ctx.ui?.notify) {
85
- ctx.ui.notify(msg, "warning");
86
- } else {
87
- console.warn(msg);
88
- }
89
87
  // appendEntry 持久化到 session entries,供事后排查(无 UI、不泄漏)。
90
88
  // errorText 一起存上——事后排查能看到真实原因(如 "hub disposed")。
91
- pi.appendEntry("unified-hooks:tool-error", {
89
+ // 不调 ctx.ui.notify——tool error 已在对话流里(pi 原生 tool result),
90
+ // notify 会重复显示且措辞("bash error")误导。
91
+ //
92
+ // 注意:除了 logger.warn(内部走泛化 `unified-hooks:log` customType),
93
+ // 这里额外调一次 `pi.appendEntry("unified-hooks:tool-error", ...)`。
94
+ // 原因:logger 内部的 appendEntry 用的是泛化 customType,无法区分 entry
95
+ // 是否为 tool 错误;保留专属 entry type 让事后按 customType 过滤 tool
96
+ // 错误的脚本/dashboard 仍可工作(埋点契约)。
97
+ const entry = {
98
+ timestamp: Date.now(),
92
99
  toolName: e.toolName,
93
100
  toolCallId: e.toolCallId,
94
101
  errorText: errorText ?? null,
95
- });
102
+ };
103
+ pi.appendEntry("unified-hooks:tool-error", entry);
104
+ logger.warn(`[unified-hooks] ${e.toolName} error (callId=${e.toolCallId})`, entry);
96
105
  });
97
106
  }
package/src/index.ts CHANGED
@@ -6,18 +6,24 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
9
10
 
10
11
  // Re-export hook modules for easy access
11
12
 
12
13
  import { setupNetworkTimeoutGuard } from "./hooks/network-timeout-guard";
13
- import { setupSubagentListInjector } from "./hooks/subagent-list-injector";
14
14
  import { setupTestTimeoutGuard } from "./hooks/test-timeout-guard";
15
15
  import { type HookContext, setupToolErrorHandler } from "./hooks/tool-error-handler";
16
16
 
17
+ // 模块级 logger(setPiHandle 注入后自动走 appendEntry)
18
+ const logger = getLogger("unified-hooks");
19
+
17
20
  /**
18
21
  * Extension factory - registers all unified hooks
19
22
  */
20
23
  export default function unifiedHooksExtension(pi: ExtensionAPI): void {
24
+ // 注入 pi handle 给全局 extension-logger
25
+ setPiHandle(pi);
26
+
21
27
  // Initialize hook registry
22
28
  const hooks: Array<{ name: string; enabled: boolean }> = [];
23
29
 
@@ -27,7 +33,6 @@ export default function unifiedHooksExtension(pi: ExtensionAPI): void {
27
33
  { name: "tool-error-handler", setup: setupToolErrorHandler },
28
34
  { name: "network-timeout-guard", setup: setupNetworkTimeoutGuard },
29
35
  { name: "test-timeout-guard", setup: setupTestTimeoutGuard },
30
- { name: "subagent-list-injector", setup: setupSubagentListInjector },
31
36
  ];
32
37
 
33
38
  for (const hook of hookModules) {
@@ -35,21 +40,30 @@ export default function unifiedHooksExtension(pi: ExtensionAPI): void {
35
40
  hook.setup(pi);
36
41
  hooks.push({ name: hook.name, enabled: true });
37
42
  } catch (err) {
38
- console.error(`[unified-hooks] Failed to setup ${hook.name}:`, err);
43
+ logger.error(`[unified-hooks] Failed to setup ${hook.name}`, {
44
+ reason: err instanceof Error ? err.message : String(err),
45
+ });
39
46
  hooks.push({ name: hook.name, enabled: false });
40
47
  }
41
48
  }
42
49
 
43
- // Hook status surfaced via TUI notify (走通知区,不泄漏到 input area)
44
- // + appendEntry 持久化供事后排查。禁止用 console.warnraw stderr 在 TUI
45
- // alternate screen 下会越过渲染层污染 input 区)。
50
+ // Hook 状态:appendEntry 持久化(事后排查)。
51
+ // notify 仅在有 disabled hooksetup 失败)时提醒用户——全成功时不刷屏。
52
+ // 禁止用 console.warn(raw stderr 在 TUI alternate screen 下会越过渲染层污染 input 区)。
53
+ //
54
+ // 行为收敛(非向后兼容):旧实现每次 session_start 无条件 notify,现在改为
55
+ // 「全成功仅 appendEntry,有失败才 notify disabled 列表」。`unified-hooks:loaded`
56
+ // customEntry 仍每次写入(持久化面不变)。消费方若依赖「每 session 必发 notify」
57
+ // 需改读 session.jsonl 中的 `unified-hooks:loaded` entry。
46
58
  pi.on("session_start", (_event: unknown, ctx: HookContext) => {
47
59
  const enabled = hooks.filter((h) => h.enabled).map((h) => h.name);
48
60
  const disabled = hooks.filter((h) => !h.enabled).map((h) => h.name);
49
- const msg = `[unified-hooks] Loaded: ${enabled.join(", ") || "(none)"}${
50
- disabled.length ? ` | Failed: ${disabled.join(", ")}` : ""
51
- }`;
52
- ctx.ui?.notify(msg, disabled.length ? "warning" : "info");
53
61
  pi.appendEntry("unified-hooks:loaded", { enabled, disabled });
62
+ if (disabled.length > 0) {
63
+ ctx.ui?.notify(
64
+ `[unified-hooks] Failed: ${disabled.join(", ")}`,
65
+ "warning",
66
+ );
67
+ }
54
68
  });
55
69
  }
@@ -1,173 +0,0 @@
1
- /**
2
- * Subagent List Injector Hook
3
- *
4
- * Discovers all available subagents (builtin + user + project scope) and
5
- * injects their names and descriptions into the system prompt on every turn,
6
- * so the AI model can pick the correct agent name instead of fabricating one.
7
- *
8
- * Injection format mirrors Pi's built-in skill injection (XML tags).
9
- */
10
-
11
- import * as fs from "node:fs";
12
- import * as os from "node:os";
13
- import * as path from "node:path";
14
-
15
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
-
17
- /** Minimal agent info extracted from .md frontmatter */
18
- interface AgentEntry {
19
- name: string;
20
- description: string;
21
- }
22
-
23
- /**
24
- * Parse YAML frontmatter from a markdown file.
25
- * Returns null if the file has no valid frontmatter or missing name/description.
26
- */
27
- function parseAgentFrontmatter(content: string): AgentEntry | null {
28
- if (!content.startsWith("---")) return null;
29
-
30
- const FRONTMATTER_OPEN_LEN = 3;
31
- const endIndex = content.indexOf("\n---", FRONTMATTER_OPEN_LEN);
32
- if (endIndex === -1) return null;
33
-
34
- const block = content.slice(FRONTMATTER_OPEN_LEN, endIndex);
35
- let name = "";
36
- let description = "";
37
-
38
- for (const line of block.split("\n")) {
39
- const match = line.match(/^([\w-]+):\s*(.*)$/);
40
- if (!match) continue;
41
-
42
- const key = match[1]!;
43
- let value = match[2]!.trim();
44
- // Strip surrounding quotes
45
- if (
46
- (value.startsWith('"') && value.endsWith('"')) ||
47
- (value.startsWith("'") && value.endsWith("'"))
48
- ) {
49
- value = value.slice(1, -1);
50
- }
51
-
52
- if (key === "name") name = value;
53
- if (key === "description") description = value;
54
- }
55
-
56
- if (!name || !description) return null;
57
- return { name, description };
58
- }
59
-
60
- /** Read agent .md files from a directory, return parsed entries */
61
- function loadAgentsFromDir(dir: string): AgentEntry[] {
62
- if (!fs.existsSync(dir)) return [];
63
-
64
- const entries: AgentEntry[] = [];
65
- let dirents: fs.Dirent[];
66
- try {
67
- dirents = fs.readdirSync(dir, { withFileTypes: true });
68
- } catch {
69
- return [];
70
- }
71
-
72
- for (const entry of dirents) {
73
- if (!entry.name.endsWith(".md")) continue;
74
- if (entry.name.endsWith(".chain.md")) continue;
75
- if (!entry.isFile() && !entry.isSymbolicLink()) continue;
76
-
77
- const filePath = path.join(dir, entry.name);
78
- try {
79
- const content = fs.readFileSync(filePath, "utf8");
80
- const agent = parseAgentFrontmatter(content);
81
- if (agent) {
82
- entries.push(agent);
83
- }
84
- } catch (err) {
85
- // Individual file read failure should not block the entire agent list injection
86
- console.error(`[subagent-list-injector] skip unreadable file ${filePath}:`, err);
87
- }
88
- }
89
-
90
- return entries;
91
- }
92
-
93
- /** Escape special XML characters */
94
- function escapeXml(str: string): string {
95
- return str
96
- .replace(/&/g, "&amp;")
97
- .replace(/</g, "&lt;")
98
- .replace(/>/g, "&gt;")
99
- .replace(/"/g, "&quot;")
100
- .replace(/'/g, "&apos;");
101
- }
102
-
103
- /**
104
- * Discover all available agents across scopes.
105
- * Deduplicates by name: project > user > builtin.
106
- */
107
- function discoverAllAgents(cwd: string): AgentEntry[] {
108
- // Builtin agents from pi-subagents package
109
- const builtinDir = path.join(
110
- os.homedir(),
111
- ".pi/agent/npm/node_modules/pi-subagents/agents",
112
- );
113
-
114
- // User scope (both legacy and new paths)
115
- const userDirLegacy = path.join(os.homedir(), ".pi/agent/agents");
116
- const userDirNew = path.join(os.homedir(), ".agents");
117
-
118
- // Project scope (both legacy and new paths)
119
- const projectDirNew = path.join(cwd, ".pi/agents");
120
- const projectDirLegacy = path.join(cwd, ".agents");
121
-
122
- const agentMap = new Map<string, AgentEntry>();
123
-
124
- // Load in priority order: builtin first, then user overrides, then project overrides
125
- for (const agent of loadAgentsFromDir(builtinDir)) {
126
- agentMap.set(agent.name, agent);
127
- }
128
- for (const dir of [userDirLegacy, userDirNew]) {
129
- for (const agent of loadAgentsFromDir(dir)) {
130
- agentMap.set(agent.name, agent);
131
- }
132
- }
133
- for (const dir of [projectDirLegacy, projectDirNew]) {
134
- for (const agent of loadAgentsFromDir(dir)) {
135
- agentMap.set(agent.name, agent);
136
- }
137
- }
138
-
139
- return [...agentMap.values()];
140
- }
141
-
142
- /** Format agent list as XML injection block */
143
- function formatAgentList(agents: AgentEntry[]): string {
144
- if (agents.length === 0) return "";
145
-
146
- const lines = [
147
- "\n\n<available_subagents>",
148
- "The following agents are available for the subagent tool. When using the subagent tool, ONLY use agent names from this list. If no agent matches your task, pass systemPrompt alongside the agent name to create a dynamic agent.",
149
- ];
150
- for (const agent of agents) {
151
- lines.push(
152
- ` <agent><name>${escapeXml(agent.name)}</name><description>${escapeXml(agent.description)}</description></agent>`,
153
- );
154
- }
155
- lines.push("</available_subagents>");
156
- return lines.join("\n");
157
- }
158
-
159
- export function setupSubagentListInjector(pi: ExtensionAPI): void {
160
- pi.on(
161
- "before_agent_start",
162
- (event: unknown, _ctx: unknown) => {
163
- const e = event as { systemPrompt?: string };
164
- const cwd = process.cwd();
165
- const agents = discoverAllAgents(cwd);
166
- const injection = formatAgentList(agents);
167
-
168
- if (!injection) return;
169
-
170
- return { systemPrompt: (e.systemPrompt ?? "") + injection };
171
- },
172
- );
173
- }