@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.
|
|
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("
|
|
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
|
-
|
|
74
|
-
expect(notify.
|
|
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"
|
|
70
|
+
enabled: ["tool-error-handler", "network-timeout-guard", "test-timeout-guard"],
|
|
77
71
|
disabled: [],
|
|
78
72
|
});
|
|
79
73
|
});
|
|
80
74
|
|
|
81
|
-
it("notifies 'warning'
|
|
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"
|
|
98
|
+
enabled: ["tool-error-handler"],
|
|
107
99
|
disabled: ["network-timeout-guard", "test-timeout-guard"],
|
|
108
100
|
});
|
|
109
101
|
});
|
|
110
102
|
|
|
111
|
-
it("notifies 'warning'
|
|
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("
|
|
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"
|
|
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
|
-
|
|
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("
|
|
43
|
+
it("logs via logger.warn and appendEntry with dedicated customType on isError:true", async () => {
|
|
38
44
|
const pi = createMockPi();
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
expect(
|
|
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
|
-
|
|
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(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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("
|
|
76
|
+
it("does nothing on isError:false (no logger.warn)", async () => {
|
|
79
77
|
const pi = createMockPi();
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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" }
|
|
145
|
-
handler({ isError: true, toolName: "bash", toolCallId: "e2" }
|
|
146
|
-
handler({ isError: false, toolName: "edit", toolCallId: "e3" }
|
|
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(
|
|
150
|
-
expect(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
expect(
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
163
|
-
|
|
122
|
+
// --- errorText 提取(核心能力)---
|
|
123
|
+
|
|
124
|
+
it("从 result.content[0].text 提取错误文本(如 'hub disposed')", async () => {
|
|
164
125
|
const pi = createMockPi();
|
|
165
|
-
|
|
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
|
|
129
|
+
const handler = pi.on.mock.calls[0]![1] as (event: unknown) => Promise<void>;
|
|
170
130
|
|
|
171
|
-
await handler({
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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" }
|
|
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(
|
|
238
|
-
expect(
|
|
239
|
-
expect(
|
|
240
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
//
|
|
45
|
-
// alternate screen 下会越过渲染层污染 input 区)。
|
|
50
|
+
// Hook 状态:appendEntry 持久化(事后排查)。
|
|
51
|
+
// notify 仅在有 disabled hook(setup 失败)时提醒用户——全成功时不刷屏。
|
|
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, "&")
|
|
97
|
-
.replace(/</g, "<")
|
|
98
|
-
.replace(/>/g, ">")
|
|
99
|
-
.replace(/"/g, """)
|
|
100
|
-
.replace(/'/g, "'");
|
|
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
|
-
}
|