@zhushanwen/pi-unified-hooks 0.2.1 → 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",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
],
|
|
17
17
|
"license": "MIT",
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@zhushanwen/pi-extension-logger": "0.2.
|
|
19
|
+
"@zhushanwen/pi-extension-logger": "0.2.1"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"@earendil-works/pi-coding-agent": "*"
|
|
@@ -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
|
|
|
@@ -63,7 +58,6 @@ 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
|
|
|
@@ -73,7 +67,7 @@ describe("session_start handler", () => {
|
|
|
73
67
|
// 全成功时不 notify(避免刷屏),只 appendEntry
|
|
74
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
|
});
|
|
@@ -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
|
|
|
@@ -102,7 +95,7 @@ describe("session_start handler", () => {
|
|
|
102
95
|
expect(level).toBe("warning");
|
|
103
96
|
expect(msg).toContain("Failed: network-timeout-guard, test-timeout-guard");
|
|
104
97
|
expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", {
|
|
105
|
-
enabled: ["tool-error-handler"
|
|
98
|
+
enabled: ["tool-error-handler"],
|
|
106
99
|
disabled: ["network-timeout-guard", "test-timeout-guard"],
|
|
107
100
|
});
|
|
108
101
|
});
|
|
@@ -121,9 +114,6 @@ describe("session_start handler", () => {
|
|
|
121
114
|
(setupTestTimeoutGuard as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
122
115
|
throw new Error("c");
|
|
123
116
|
});
|
|
124
|
-
(setupSubagentListInjector as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
125
|
-
throw new Error("d");
|
|
126
|
-
});
|
|
127
117
|
|
|
128
118
|
unifiedHooksExtension(pi as unknown as ExtensionAPI);
|
|
129
119
|
|
|
@@ -132,10 +122,10 @@ describe("session_start handler", () => {
|
|
|
132
122
|
|
|
133
123
|
expect(notify.mock.calls[0]![1]).toBe("warning");
|
|
134
124
|
const msg = notify.mock.calls[0]![0] as string;
|
|
135
|
-
expect(msg).toContain("Failed: tool-error-handler, network-timeout-guard, test-timeout-guard
|
|
125
|
+
expect(msg).toContain("Failed: tool-error-handler, network-timeout-guard, test-timeout-guard");
|
|
136
126
|
expect(pi.appendEntry).toHaveBeenCalledWith("unified-hooks:loaded", {
|
|
137
127
|
enabled: [],
|
|
138
|
-
disabled: ["tool-error-handler", "network-timeout-guard", "test-timeout-guard"
|
|
128
|
+
disabled: ["tool-error-handler", "network-timeout-guard", "test-timeout-guard"],
|
|
139
129
|
});
|
|
140
130
|
});
|
|
141
131
|
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,6 @@ import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
|
|
|
11
11
|
// Re-export hook modules for easy access
|
|
12
12
|
|
|
13
13
|
import { setupNetworkTimeoutGuard } from "./hooks/network-timeout-guard";
|
|
14
|
-
import { setupSubagentListInjector } from "./hooks/subagent-list-injector";
|
|
15
14
|
import { setupTestTimeoutGuard } from "./hooks/test-timeout-guard";
|
|
16
15
|
import { type HookContext, setupToolErrorHandler } from "./hooks/tool-error-handler";
|
|
17
16
|
|
|
@@ -34,7 +33,6 @@ export default function unifiedHooksExtension(pi: ExtensionAPI): void {
|
|
|
34
33
|
{ name: "tool-error-handler", setup: setupToolErrorHandler },
|
|
35
34
|
{ name: "network-timeout-guard", setup: setupNetworkTimeoutGuard },
|
|
36
35
|
{ name: "test-timeout-guard", setup: setupTestTimeoutGuard },
|
|
37
|
-
{ name: "subagent-list-injector", setup: setupSubagentListInjector },
|
|
38
36
|
];
|
|
39
37
|
|
|
40
38
|
for (const hook of hookModules) {
|
|
@@ -1,179 +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 { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
16
|
-
|
|
17
|
-
const logger = getLogger("unified-hooks");
|
|
18
|
-
|
|
19
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
-
|
|
21
|
-
/** Minimal agent info extracted from .md frontmatter */
|
|
22
|
-
interface AgentEntry {
|
|
23
|
-
name: string;
|
|
24
|
-
description: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Parse YAML frontmatter from a markdown file.
|
|
29
|
-
* Returns null if the file has no valid frontmatter or missing name/description.
|
|
30
|
-
*/
|
|
31
|
-
function parseAgentFrontmatter(content: string): AgentEntry | null {
|
|
32
|
-
if (!content.startsWith("---")) return null;
|
|
33
|
-
|
|
34
|
-
const FRONTMATTER_OPEN_LEN = 3;
|
|
35
|
-
const endIndex = content.indexOf("\n---", FRONTMATTER_OPEN_LEN);
|
|
36
|
-
if (endIndex === -1) return null;
|
|
37
|
-
|
|
38
|
-
const block = content.slice(FRONTMATTER_OPEN_LEN, endIndex);
|
|
39
|
-
let name = "";
|
|
40
|
-
let description = "";
|
|
41
|
-
|
|
42
|
-
for (const line of block.split("\n")) {
|
|
43
|
-
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
|
44
|
-
if (!match) continue;
|
|
45
|
-
|
|
46
|
-
const key = match[1]!;
|
|
47
|
-
let value = match[2]!.trim();
|
|
48
|
-
// Strip surrounding quotes
|
|
49
|
-
if (
|
|
50
|
-
(value.startsWith('"') && value.endsWith('"')) ||
|
|
51
|
-
(value.startsWith("'") && value.endsWith("'"))
|
|
52
|
-
) {
|
|
53
|
-
value = value.slice(1, -1);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (key === "name") name = value;
|
|
57
|
-
if (key === "description") description = value;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (!name || !description) return null;
|
|
61
|
-
return { name, description };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Read agent .md files from a directory, return parsed entries */
|
|
65
|
-
function loadAgentsFromDir(dir: string): AgentEntry[] {
|
|
66
|
-
if (!fs.existsSync(dir)) return [];
|
|
67
|
-
|
|
68
|
-
const entries: AgentEntry[] = [];
|
|
69
|
-
let dirents: fs.Dirent[];
|
|
70
|
-
try {
|
|
71
|
-
dirents = fs.readdirSync(dir, { withFileTypes: true });
|
|
72
|
-
} catch {
|
|
73
|
-
return [];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
for (const entry of dirents) {
|
|
77
|
-
if (!entry.name.endsWith(".md")) continue;
|
|
78
|
-
if (entry.name.endsWith(".chain.md")) continue;
|
|
79
|
-
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
80
|
-
|
|
81
|
-
const filePath = path.join(dir, entry.name);
|
|
82
|
-
try {
|
|
83
|
-
const content = fs.readFileSync(filePath, "utf8");
|
|
84
|
-
const agent = parseAgentFrontmatter(content);
|
|
85
|
-
if (agent) {
|
|
86
|
-
entries.push(agent);
|
|
87
|
-
}
|
|
88
|
-
} catch (err) {
|
|
89
|
-
// Individual file read failure should not block the entire agent list injection
|
|
90
|
-
logger.error(`[subagent-list-injector] skip unreadable file ${filePath}`, {
|
|
91
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return entries;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Escape special XML characters */
|
|
100
|
-
function escapeXml(str: string): string {
|
|
101
|
-
return str
|
|
102
|
-
.replace(/&/g, "&")
|
|
103
|
-
.replace(/</g, "<")
|
|
104
|
-
.replace(/>/g, ">")
|
|
105
|
-
.replace(/"/g, """)
|
|
106
|
-
.replace(/'/g, "'");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Discover all available agents across scopes.
|
|
111
|
-
* Deduplicates by name: project > user > builtin.
|
|
112
|
-
*/
|
|
113
|
-
function discoverAllAgents(cwd: string): AgentEntry[] {
|
|
114
|
-
// Builtin agents from pi-subagents package
|
|
115
|
-
const builtinDir = path.join(
|
|
116
|
-
os.homedir(),
|
|
117
|
-
".pi/agent/npm/node_modules/pi-subagents/agents",
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
// User scope (both legacy and new paths)
|
|
121
|
-
const userDirLegacy = path.join(os.homedir(), ".pi/agent/agents");
|
|
122
|
-
const userDirNew = path.join(os.homedir(), ".agents");
|
|
123
|
-
|
|
124
|
-
// Project scope (both legacy and new paths)
|
|
125
|
-
const projectDirNew = path.join(cwd, ".pi/agents");
|
|
126
|
-
const projectDirLegacy = path.join(cwd, ".agents");
|
|
127
|
-
|
|
128
|
-
const agentMap = new Map<string, AgentEntry>();
|
|
129
|
-
|
|
130
|
-
// Load in priority order: builtin first, then user overrides, then project overrides
|
|
131
|
-
for (const agent of loadAgentsFromDir(builtinDir)) {
|
|
132
|
-
agentMap.set(agent.name, agent);
|
|
133
|
-
}
|
|
134
|
-
for (const dir of [userDirLegacy, userDirNew]) {
|
|
135
|
-
for (const agent of loadAgentsFromDir(dir)) {
|
|
136
|
-
agentMap.set(agent.name, agent);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
for (const dir of [projectDirLegacy, projectDirNew]) {
|
|
140
|
-
for (const agent of loadAgentsFromDir(dir)) {
|
|
141
|
-
agentMap.set(agent.name, agent);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return [...agentMap.values()];
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Format agent list as XML injection block */
|
|
149
|
-
function formatAgentList(agents: AgentEntry[]): string {
|
|
150
|
-
if (agents.length === 0) return "";
|
|
151
|
-
|
|
152
|
-
const lines = [
|
|
153
|
-
"\n\n<available_subagents>",
|
|
154
|
-
"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.",
|
|
155
|
-
];
|
|
156
|
-
for (const agent of agents) {
|
|
157
|
-
lines.push(
|
|
158
|
-
` <agent><name>${escapeXml(agent.name)}</name><description>${escapeXml(agent.description)}</description></agent>`,
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
lines.push("</available_subagents>");
|
|
162
|
-
return lines.join("\n");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export function setupSubagentListInjector(pi: ExtensionAPI): void {
|
|
166
|
-
pi.on(
|
|
167
|
-
"before_agent_start",
|
|
168
|
-
(event: unknown, _ctx: unknown) => {
|
|
169
|
-
const e = event as { systemPrompt?: string };
|
|
170
|
-
const cwd = process.cwd();
|
|
171
|
-
const agents = discoverAllAgents(cwd);
|
|
172
|
-
const injection = formatAgentList(agents);
|
|
173
|
-
|
|
174
|
-
if (!injection) return;
|
|
175
|
-
|
|
176
|
-
return { systemPrompt: (e.systemPrompt ?? "") + injection };
|
|
177
|
-
},
|
|
178
|
-
);
|
|
179
|
-
}
|