@zhushanwen/pi-unified-hooks 0.2.0 → 0.2.1

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.1",
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.0"
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
  },
@@ -55,7 +55,7 @@ function getSessionStartHandler(pi: ReturnType<typeof createMockPi>): (event: un
55
55
 
56
56
  // --- tests ---
57
57
  describe("session_start handler", () => {
58
- it("notifies 'info' when all hooks are enabled", () => {
58
+ it("does not notify when all hooks are enabled (only appendEntry)", () => {
59
59
  const pi = createMockPi();
60
60
  const { ctx, notify } = createMockCtx();
61
61
 
@@ -70,15 +70,15 @@ describe("session_start handler", () => {
70
70
  const handler = getSessionStartHandler(pi);
71
71
  handler({}, ctx);
72
72
 
73
- expect(notify).toHaveBeenCalledTimes(1);
74
- expect(notify.mock.calls[0]![1]).toBe("info");
73
+ // 全成功时不 notify(避免刷屏),只 appendEntry
74
+ expect(notify).not.toHaveBeenCalled();
75
75
  expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", {
76
76
  enabled: ["tool-error-handler", "network-timeout-guard", "test-timeout-guard", "subagent-list-injector"],
77
77
  disabled: [],
78
78
  });
79
79
  });
80
80
 
81
- it("notifies 'warning' and lists disabled hooks when some hooks fail", () => {
81
+ it("notifies 'warning' listing only disabled hooks when some hooks fail", () => {
82
82
  const pi = createMockPi();
83
83
  const { ctx, notify } = createMockCtx();
84
84
 
@@ -101,14 +101,13 @@ describe("session_start handler", () => {
101
101
  const [msg, level] = notify.mock.calls[0]!;
102
102
  expect(level).toBe("warning");
103
103
  expect(msg).toContain("Failed: network-timeout-guard, test-timeout-guard");
104
- expect(msg).toContain("Loaded: tool-error-handler, subagent-list-injector");
105
104
  expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", {
106
105
  enabled: ["tool-error-handler", "subagent-list-injector"],
107
106
  disabled: ["network-timeout-guard", "test-timeout-guard"],
108
107
  });
109
108
  });
110
109
 
111
- it("notifies 'warning' when all hooks are disabled", () => {
110
+ it("notifies 'warning' listing all hooks when all are disabled", () => {
112
111
  const pi = createMockPi();
113
112
  const { ctx, notify } = createMockCtx();
114
113
 
@@ -133,7 +132,7 @@ describe("session_start handler", () => {
133
132
 
134
133
  expect(notify.mock.calls[0]![1]).toBe("warning");
135
134
  const msg = notify.mock.calls[0]![0] as string;
136
- expect(msg).toContain("(none)");
135
+ expect(msg).toContain("Failed: tool-error-handler, network-timeout-guard, test-timeout-guard, subagent-list-injector");
137
136
  expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", {
138
137
  enabled: [],
139
138
  disabled: ["tool-error-handler", "network-timeout-guard", "test-timeout-guard", "subagent-list-injector"],
@@ -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
  });
@@ -12,6 +12,10 @@ import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
14
 
15
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
16
+
17
+ const logger = getLogger("unified-hooks");
18
+
15
19
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
20
 
17
21
  /** Minimal agent info extracted from .md frontmatter */
@@ -83,7 +87,9 @@ function loadAgentsFromDir(dir: string): AgentEntry[] {
83
87
  }
84
88
  } catch (err) {
85
89
  // Individual file read failure should not block the entire agent list injection
86
- console.error(`[subagent-list-injector] skip unreadable file ${filePath}:`, err);
90
+ logger.error(`[subagent-list-injector] skip unreadable file ${filePath}`, {
91
+ reason: err instanceof Error ? err.message : String(err),
92
+ });
87
93
  }
88
94
  }
89
95
 
@@ -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,6 +6,7 @@
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
 
@@ -14,10 +15,16 @@ import { setupSubagentListInjector } from "./hooks/subagent-list-injector";
14
15
  import { setupTestTimeoutGuard } from "./hooks/test-timeout-guard";
15
16
  import { type HookContext, setupToolErrorHandler } from "./hooks/tool-error-handler";
16
17
 
18
+ // 模块级 logger(setPiHandle 注入后自动走 appendEntry)
19
+ const logger = getLogger("unified-hooks");
20
+
17
21
  /**
18
22
  * Extension factory - registers all unified hooks
19
23
  */
20
24
  export default function unifiedHooksExtension(pi: ExtensionAPI): void {
25
+ // 注入 pi handle 给全局 extension-logger
26
+ setPiHandle(pi);
27
+
21
28
  // Initialize hook registry
22
29
  const hooks: Array<{ name: string; enabled: boolean }> = [];
23
30
 
@@ -35,21 +42,30 @@ export default function unifiedHooksExtension(pi: ExtensionAPI): void {
35
42
  hook.setup(pi);
36
43
  hooks.push({ name: hook.name, enabled: true });
37
44
  } catch (err) {
38
- console.error(`[unified-hooks] Failed to setup ${hook.name}:`, err);
45
+ logger.error(`[unified-hooks] Failed to setup ${hook.name}`, {
46
+ reason: err instanceof Error ? err.message : String(err),
47
+ });
39
48
  hooks.push({ name: hook.name, enabled: false });
40
49
  }
41
50
  }
42
51
 
43
- // Hook status surfaced via TUI notify (走通知区,不泄漏到 input area)
44
- // + appendEntry 持久化供事后排查。禁止用 console.warnraw stderr 在 TUI
45
- // alternate screen 下会越过渲染层污染 input 区)。
52
+ // Hook 状态:appendEntry 持久化(事后排查)。
53
+ // notify 仅在有 disabled hooksetup 失败)时提醒用户——全成功时不刷屏。
54
+ // 禁止用 console.warn(raw stderr 在 TUI alternate screen 下会越过渲染层污染 input 区)。
55
+ //
56
+ // 行为收敛(非向后兼容):旧实现每次 session_start 无条件 notify,现在改为
57
+ // 「全成功仅 appendEntry,有失败才 notify disabled 列表」。`unified-hooks:loaded`
58
+ // customEntry 仍每次写入(持久化面不变)。消费方若依赖「每 session 必发 notify」
59
+ // 需改读 session.jsonl 中的 `unified-hooks:loaded` entry。
46
60
  pi.on("session_start", (_event: unknown, ctx: HookContext) => {
47
61
  const enabled = hooks.filter((h) => h.enabled).map((h) => h.name);
48
62
  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
63
  pi.appendEntry("unified-hooks:loaded", { enabled, disabled });
64
+ if (disabled.length > 0) {
65
+ ctx.ui?.notify(
66
+ `[unified-hooks] Failed: ${disabled.join(", ")}`,
67
+ "warning",
68
+ );
69
+ }
54
70
  });
55
71
  }