@zhushanwen/pi-unified-hooks 0.1.0 → 0.1.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.1.0",
3
+ "version": "0.1.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",
@@ -19,11 +19,16 @@
19
19
  "typebox": "*",
20
20
  "@mariozechner/pi-coding-agent": "*"
21
21
  },
22
+ "devDependencies": {
23
+ "vitest": "^4.1.8"
24
+ },
22
25
  "files": [
23
26
  "src/",
24
27
  "index.ts"
25
28
  ],
26
29
  "scripts": {
27
- "typecheck": "npx tsc --noEmit"
30
+ "typecheck": "npx tsc --noEmit",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest"
28
33
  }
29
34
  }
@@ -0,0 +1,155 @@
1
+ // src/__tests__/session-start-handler.test.ts
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+
6
+ import type { HookContext } from "../hooks/tool-error-handler.ts";
7
+
8
+ // --- mocks for hook setup modules ---
9
+ vi.mock("../hooks/tool-error-handler.ts", () => ({
10
+ setupToolErrorHandler: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("../hooks/network-timeout-guard.ts", () => ({
14
+ setupNetworkTimeoutGuard: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("../hooks/test-timeout-guard.ts", () => ({
18
+ setupTestTimeoutGuard: vi.fn(),
19
+ }));
20
+
21
+ vi.mock("../hooks/subagent-list-injector.ts", () => ({
22
+ setupSubagentListInjector: vi.fn(),
23
+ }));
24
+
25
+ // Re-import after mocking so the mocked versions are used
26
+ import { setupToolErrorHandler } from "../hooks/tool-error-handler.ts";
27
+ import { setupNetworkTimeoutGuard } from "../hooks/network-timeout-guard.ts";
28
+ import { setupTestTimeoutGuard } from "../hooks/test-timeout-guard.ts";
29
+ import { setupSubagentListInjector } from "../hooks/subagent-list-injector.ts";
30
+
31
+ import unifiedHooksExtension from "../index.ts";
32
+
33
+ // --- helpers ---
34
+ function createMockPi(): {
35
+ on: ReturnType<typeof vi.fn>;
36
+ appendEntry: ReturnType<typeof vi.fn>;
37
+ } {
38
+ return {
39
+ on: vi.fn(),
40
+ appendEntry: vi.fn(),
41
+ };
42
+ }
43
+
44
+ function createMockCtx(): { ctx: HookContext; notify: ReturnType<typeof vi.fn> } {
45
+ const notify = vi.fn();
46
+ const ctx = { ui: { notify } } as unknown as HookContext;
47
+ return { ctx, notify };
48
+ }
49
+
50
+ function getSessionStartHandler(pi: ReturnType<typeof createMockPi>): (event: unknown, ctx: HookContext) => void {
51
+ return pi.on.mock.calls.find(
52
+ (c: unknown[]) => c[0] === "session_start",
53
+ )![1] as (event: unknown, ctx: HookContext) => void;
54
+ }
55
+
56
+ // --- tests ---
57
+ describe("session_start handler", () => {
58
+ it("notifies 'info' when all hooks are enabled", () => {
59
+ const pi = createMockPi();
60
+ const { ctx, notify } = createMockCtx();
61
+
62
+ // All hooks succeed
63
+ (setupToolErrorHandler as ReturnType<typeof vi.fn>).mockImplementation(() => {});
64
+ (setupNetworkTimeoutGuard as ReturnType<typeof vi.fn>).mockImplementation(() => {});
65
+ (setupTestTimeoutGuard as ReturnType<typeof vi.fn>).mockImplementation(() => {});
66
+ (setupSubagentListInjector as ReturnType<typeof vi.fn>).mockImplementation(() => {});
67
+
68
+ unifiedHooksExtension(pi as unknown as ExtensionAPI);
69
+
70
+ const handler = getSessionStartHandler(pi);
71
+ handler({}, ctx);
72
+
73
+ expect(notify).toHaveBeenCalledTimes(1);
74
+ expect(notify.mock.calls[0]![1]).toBe("info");
75
+ expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", {
76
+ enabled: ["tool-error-handler", "network-timeout-guard", "test-timeout-guard", "subagent-list-injector"],
77
+ disabled: [],
78
+ });
79
+ });
80
+
81
+ it("notifies 'warning' and lists disabled hooks when some hooks fail", () => {
82
+ const pi = createMockPi();
83
+ const { ctx, notify } = createMockCtx();
84
+
85
+ // Two hooks fail
86
+ (setupToolErrorHandler as ReturnType<typeof vi.fn>).mockImplementation(() => {});
87
+ (setupNetworkTimeoutGuard as ReturnType<typeof vi.fn>).mockImplementation(() => {
88
+ throw new Error("setup failed");
89
+ });
90
+ (setupTestTimeoutGuard as ReturnType<typeof vi.fn>).mockImplementation(() => {
91
+ throw new Error("timeout");
92
+ });
93
+ (setupSubagentListInjector as ReturnType<typeof vi.fn>).mockImplementation(() => {});
94
+
95
+ unifiedHooksExtension(pi as unknown as ExtensionAPI);
96
+
97
+ const handler = getSessionStartHandler(pi);
98
+ handler({}, ctx);
99
+
100
+ expect(notify).toHaveBeenCalledTimes(1);
101
+ const [msg, level] = notify.mock.calls[0]!;
102
+ expect(level).toBe("warning");
103
+ expect(msg).toContain("Failed: network-timeout-guard, test-timeout-guard");
104
+ expect(msg).toContain("Loaded: tool-error-handler, subagent-list-injector");
105
+ expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", {
106
+ enabled: ["tool-error-handler", "subagent-list-injector"],
107
+ disabled: ["network-timeout-guard", "test-timeout-guard"],
108
+ });
109
+ });
110
+
111
+ it("notifies 'warning' when all hooks are disabled", () => {
112
+ const pi = createMockPi();
113
+ const { ctx, notify } = createMockCtx();
114
+
115
+ // All hooks fail
116
+ (setupToolErrorHandler as ReturnType<typeof vi.fn>).mockImplementation(() => {
117
+ throw new Error("a");
118
+ });
119
+ (setupNetworkTimeoutGuard as ReturnType<typeof vi.fn>).mockImplementation(() => {
120
+ throw new Error("b");
121
+ });
122
+ (setupTestTimeoutGuard as ReturnType<typeof vi.fn>).mockImplementation(() => {
123
+ throw new Error("c");
124
+ });
125
+ (setupSubagentListInjector as ReturnType<typeof vi.fn>).mockImplementation(() => {
126
+ throw new Error("d");
127
+ });
128
+
129
+ unifiedHooksExtension(pi as unknown as ExtensionAPI);
130
+
131
+ const handler = getSessionStartHandler(pi);
132
+ handler({}, ctx);
133
+
134
+ expect(notify.mock.calls[0]![1]).toBe("warning");
135
+ const msg = notify.mock.calls[0]![0] as string;
136
+ expect(msg).toContain("(none)");
137
+ expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", {
138
+ enabled: [],
139
+ disabled: ["tool-error-handler", "network-timeout-guard", "test-timeout-guard", "subagent-list-injector"],
140
+ });
141
+ });
142
+
143
+ it("does not throw when ctx.ui is undefined (headless / RPC session)", () => {
144
+ // [HISTORICAL] headless 会话 ctx.ui 为 undefined,旧实现直接 ctx.ui.notify 会 NPE。
145
+ const pi = createMockPi();
146
+ const ctx = { ui: undefined } as unknown as HookContext;
147
+
148
+ unifiedHooksExtension(pi as unknown as ExtensionAPI);
149
+
150
+ const handler = getSessionStartHandler(pi);
151
+ expect(() => handler({}, ctx)).not.toThrow();
152
+ // appendEntry 仍应记录(无 UI 也不丢日志)
153
+ expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", expect.any(Object));
154
+ });
155
+ });
@@ -0,0 +1,244 @@
1
+ // src/__tests__/tool-error-handler.test.ts
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+
6
+ import { setupToolErrorHandler, type HookContext } from "../hooks/tool-error-handler.ts";
7
+
8
+ // --- helper types ---
9
+ interface MockPi {
10
+ on: ReturnType<typeof vi.fn>;
11
+ appendEntry: ReturnType<typeof vi.fn>;
12
+ }
13
+
14
+ function createMockPi(overrides?: Partial<MockPi>): MockPi {
15
+ return {
16
+ on: vi.fn(),
17
+ appendEntry: vi.fn(),
18
+ ...overrides,
19
+ };
20
+ }
21
+
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
+ describe("setupToolErrorHandler", () => {
29
+ it("registers a handler on the tool_execution_end event", () => {
30
+ const pi = createMockPi();
31
+
32
+ setupToolErrorHandler(pi as unknown as ExtensionAPI);
33
+
34
+ expect(pi.on).toHaveBeenCalledWith("tool_execution_end", expect.any(Function));
35
+ });
36
+
37
+ it("notifies via ctx.ui.notify and persists via appendEntry on isError:true", async () => {
38
+ const pi = createMockPi();
39
+ const { ctx, notify } = createMockCtx();
40
+
41
+ setupToolErrorHandler(pi as unknown as ExtensionAPI);
42
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
43
+
44
+ await handler(
45
+ { isError: true, toolName: "read", toolCallId: "call-42" },
46
+ ctx,
47
+ );
48
+
49
+ expect(notify).toHaveBeenCalledTimes(1);
50
+ expect(notify).toHaveBeenCalledWith(
51
+ "[unified-hooks] read error (callId=call-42)",
52
+ "warning",
53
+ );
54
+ 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,
72
+ );
73
+
74
+ expect(notify).not.toHaveBeenCalled();
75
+ expect(pi.appendEntry).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it("uses the warn notification type for errors", async () => {
79
+ const pi = createMockPi();
80
+ const { ctx, notify } = createMockCtx();
81
+
82
+ setupToolErrorHandler(pi as unknown as ExtensionAPI);
83
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
84
+
85
+ await handler(
86
+ { isError: true, toolName: "edit", toolCallId: "c1" },
87
+ ctx,
88
+ );
89
+
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");
92
+ });
93
+
94
+ // --- edge cases ---
95
+
96
+ it("propagates if pi.on throws during registration", () => {
97
+ const pi = createMockPi({
98
+ on: vi.fn(() => { throw new Error("registration failed"); }),
99
+ });
100
+
101
+ expect(() => setupToolErrorHandler(pi as unknown as ExtensionAPI)).toThrow("registration failed");
102
+ });
103
+
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
+ it("handles concurrent error events independently", async () => {
137
+ const pi = createMockPi();
138
+ const { ctx, notify } = createMockCtx();
139
+
140
+ setupToolErrorHandler(pi as unknown as ExtensionAPI);
141
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
142
+
143
+ 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),
147
+ ]);
148
+
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
+ ]),
159
+ );
160
+ });
161
+
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。
164
+ const pi = createMockPi();
165
+ const ctx = { ui: undefined } as unknown as HookContext;
166
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
167
+
168
+ setupToolErrorHandler(pi as unknown as ExtensionAPI);
169
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
170
+
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,
180
+ });
181
+ warnSpy.mockRestore();
182
+ });
183
+
184
+ // --- errorText 提取(核心新增能力)---
185
+
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,
198
+ toolName: "subagent",
199
+ toolCallId: "call-disposed",
200
+ result: { content: [{ type: "text", text: "hub disposed" }] },
201
+ },
202
+ ctx,
203
+ );
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
+ });
215
+
216
+ it("result 缺失或无 content 时降级到无详情(不崩)", async () => {
217
+ const pi = createMockPi();
218
+ const { ctx, notify } = createMockCtx();
219
+
220
+ setupToolErrorHandler(pi as unknown as ExtensionAPI);
221
+ const handler = pi.on.mock.calls[0]![1] as (event: unknown, ctx: HookContext) => Promise<void>;
222
+
223
+ // result 为 undefined(某些 headless 路径)
224
+ await handler({ isError: true, toolName: "bash", toolCallId: "x1" }, ctx);
225
+ // result.content 为空数组
226
+ await handler(
227
+ { isError: true, toolName: "bash", toolCallId: "x2", result: { content: [] } },
228
+ ctx,
229
+ );
230
+ // result 不是对象
231
+ await handler(
232
+ { isError: true, toolName: "bash", toolCallId: "x3", result: "oops" },
233
+ ctx,
234
+ );
235
+
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
+ });
243
+ });
244
+ });
@@ -11,20 +11,87 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
11
  * Subset of `ToolExecutionEndEvent` fields used by this hook.
12
12
  * Local interface because the SDK's full event type is not re-exported
13
13
  * by the CI ambient type stubs in `shared/types/mariozechner/index.d.ts`.
14
+ *
15
+ * `result` 形如 `{ content: Array<{ type: "text", text: string }>, isError: boolean }`
16
+ * (Pi 框架在 tool execute throw 时塞入 error content)。SDK 事件结构里没有独立
17
+ * errorMessage 字段——错误文本只能从 result.content 里取。
14
18
  */
15
19
  interface ToolExecutionEndLikeEvent {
16
20
  isError: boolean;
17
21
  toolName: string;
18
22
  toolCallId: string;
23
+ result?: unknown;
24
+ }
25
+
26
+ /** ExtensionContext 的最小子集,仅声明 unified-hooks 内部用到的 ui 字段。 */
27
+ export interface HookContext {
28
+ // headless / RPC 会话 ctx.ui 可能为 undefined(TUI 未初始化)。
29
+ ui?: {
30
+ // type 必须用 SDK 字面量联合,否则非法值(如 "warn")会被 Pi 降级为 info 静默丢失。
31
+ notify(msg: string, type?: "info" | "warning" | "error"): void;
32
+ };
33
+ }
34
+
35
+ /**
36
+ * 从 tool 执行结果里提取错误文本。
37
+ *
38
+ * Pi 框架在 tool execute 抛错时,构造 `{ content: [{ type: "text", text }] }`
39
+ * 塞进 result.content[0].text。不同 tool / 框架版本可能格式略有差异,这里防御性
40
+ * 取多种结构,取不到就返回 undefined(调用方降级,不阻断)。
41
+ */
42
+ function extractErrorText(result: unknown): string | undefined {
43
+ // 常见结构:{ content: [{ type: "text", text: "..." }] }
44
+ const contentArr = getContentArray(result);
45
+ if (contentArr) {
46
+ for (const item of contentArr) {
47
+ const text = getStringProperty(item, "text");
48
+ if (text) return text;
49
+ }
50
+ }
51
+
52
+ // 兜底:某些工具直接塞 { error: "..." }
53
+ return getStringProperty(result, "error");
54
+ }
55
+
56
+ /** 若 result.content 是数组则返回它,否则 undefined。 */
57
+ function getContentArray(result: unknown): unknown[] | undefined {
58
+ if (typeof result !== "object" || result === null) return undefined;
59
+ const content = (result as Record<string, unknown>).content;
60
+ return Array.isArray(content) ? content : undefined;
61
+ }
62
+
63
+ /** 类型守卫:返回 obj[key] 当它是非空 string,否则 undefined。 */
64
+ function getStringProperty(obj: unknown, key: string): string | undefined {
65
+ if (typeof obj !== "object" || obj === null) return undefined;
66
+ const val = (obj as Record<string, unknown>)[key];
67
+ return typeof val === "string" && val.length > 0 ? val : undefined;
19
68
  }
20
69
 
21
70
  export function setupToolErrorHandler(pi: ExtensionAPI): void {
22
- pi.on("tool_execution_end", async (event: unknown) => {
71
+ pi.on("tool_execution_end", async (event: unknown, ctx: HookContext) => {
23
72
  const e = event as ToolExecutionEndLikeEvent;
24
73
  if (!e.isError) return;
25
- // Tool error diagnostics are internal — surfacing to the terminal
26
- // would leak to the input area. ctx.ui.notify is unnecessary because
27
- // tool errors are already surfaced by Pi's own error rendering.
28
- console.warn(`[unified-hooks] ${e.toolName} error (callId=${e.toolCallId})`);
74
+
75
+ // 提取错误文本:tool execute throw 时 Pi 把 error.message 塞进 result.content。
76
+ // SDK 事件无 errorMessage 字段,只能从这里捞;拿不到也不阻断(降级到无详情)。
77
+ const errorText = extractErrorText(e.result);
78
+ const detail = errorText ? `: ${errorText}` : "";
79
+ const msg = `[unified-hooks] ${e.toolName} error (callId=${e.toolCallId})${detail}`;
80
+
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
+ // appendEntry 持久化到 session entries,供事后排查(无 UI、不泄漏)。
90
+ // errorText 一起存上——事后排查能看到真实原因(如 "hub disposed")。
91
+ pi.appendEntry("unified-hooks:tool-error", {
92
+ toolName: e.toolName,
93
+ toolCallId: e.toolCallId,
94
+ errorText: errorText ?? null,
95
+ });
29
96
  });
30
97
  }
package/src/index.ts CHANGED
@@ -8,15 +8,11 @@
8
8
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
9
 
10
10
  // Re-export hook modules for easy access
11
- export { setupNetworkTimeoutGuard } from "./hooks/network-timeout-guard";
12
- export { setupSubagentListInjector } from "./hooks/subagent-list-injector";
13
- export { setupTestTimeoutGuard } from "./hooks/test-timeout-guard";
14
- export { setupToolErrorHandler } from "./hooks/tool-error-handler";
15
11
 
16
12
  import { setupNetworkTimeoutGuard } from "./hooks/network-timeout-guard";
17
13
  import { setupSubagentListInjector } from "./hooks/subagent-list-injector";
18
14
  import { setupTestTimeoutGuard } from "./hooks/test-timeout-guard";
19
- import { setupToolErrorHandler } from "./hooks/tool-error-handler";
15
+ import { type HookContext, setupToolErrorHandler } from "./hooks/tool-error-handler";
20
16
 
21
17
  /**
22
18
  * Extension factory - registers all unified hooks
@@ -44,16 +40,16 @@ export default function unifiedHooksExtension(pi: ExtensionAPI): void {
44
40
  }
45
41
  }
46
42
 
47
- // Hook status is an internal diagnostic — surfacing to the terminal
48
- // would leak to the input area. Use console.warn (stderr) for
49
- // development debugging; production should be silent.
50
- pi.on("session_start", () => {
43
+ // Hook status surfaced via TUI notify (走通知区,不泄漏到 input area)
44
+ // + appendEntry 持久化供事后排查。禁止用 console.warn(raw stderr 在 TUI
45
+ // alternate screen 下会越过渲染层污染 input 区)。
46
+ pi.on("session_start", (_event: unknown, ctx: HookContext) => {
51
47
  const enabled = hooks.filter((h) => h.enabled).map((h) => h.name);
52
48
  const disabled = hooks.filter((h) => !h.enabled).map((h) => h.name);
53
- console.warn(
54
- `[unified-hooks] Loaded: ${enabled.join(", ") || "(none)"}${
55
- disabled.length ? ` | Failed: ${disabled.join(", ")}` : ""
56
- }`
57
- );
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
+ pi.appendEntry("unified-hooks:loaded", { enabled, disabled });
58
54
  });
59
- }
55
+ }