@zhushanwen/pi-unified-hooks 0.0.4 → 0.1.0
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
|
@@ -0,0 +1,173 @@
|
|
|
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 "@mariozechner/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
|
+
}
|
|
@@ -22,6 +22,9 @@ export function setupToolErrorHandler(pi: ExtensionAPI): void {
|
|
|
22
22
|
pi.on("tool_execution_end", async (event: unknown) => {
|
|
23
23
|
const e = event as ToolExecutionEndLikeEvent;
|
|
24
24
|
if (!e.isError) return;
|
|
25
|
-
|
|
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})`);
|
|
26
29
|
});
|
|
27
30
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,10 +9,12 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
|
9
9
|
|
|
10
10
|
// Re-export hook modules for easy access
|
|
11
11
|
export { setupNetworkTimeoutGuard } from "./hooks/network-timeout-guard";
|
|
12
|
+
export { setupSubagentListInjector } from "./hooks/subagent-list-injector";
|
|
12
13
|
export { setupTestTimeoutGuard } from "./hooks/test-timeout-guard";
|
|
13
14
|
export { setupToolErrorHandler } from "./hooks/tool-error-handler";
|
|
14
15
|
|
|
15
16
|
import { setupNetworkTimeoutGuard } from "./hooks/network-timeout-guard";
|
|
17
|
+
import { setupSubagentListInjector } from "./hooks/subagent-list-injector";
|
|
16
18
|
import { setupTestTimeoutGuard } from "./hooks/test-timeout-guard";
|
|
17
19
|
import { setupToolErrorHandler } from "./hooks/tool-error-handler";
|
|
18
20
|
|
|
@@ -29,6 +31,7 @@ export default function unifiedHooksExtension(pi: ExtensionAPI): void {
|
|
|
29
31
|
{ name: "tool-error-handler", setup: setupToolErrorHandler },
|
|
30
32
|
{ name: "network-timeout-guard", setup: setupNetworkTimeoutGuard },
|
|
31
33
|
{ name: "test-timeout-guard", setup: setupTestTimeoutGuard },
|
|
34
|
+
{ name: "subagent-list-injector", setup: setupSubagentListInjector },
|
|
32
35
|
];
|
|
33
36
|
|
|
34
37
|
for (const hook of hookModules) {
|
|
@@ -41,11 +44,13 @@ export default function unifiedHooksExtension(pi: ExtensionAPI): void {
|
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
//
|
|
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.
|
|
45
50
|
pi.on("session_start", () => {
|
|
46
51
|
const enabled = hooks.filter((h) => h.enabled).map((h) => h.name);
|
|
47
52
|
const disabled = hooks.filter((h) => !h.enabled).map((h) => h.name);
|
|
48
|
-
console.
|
|
53
|
+
console.warn(
|
|
49
54
|
`[unified-hooks] Loaded: ${enabled.join(", ") || "(none)"}${
|
|
50
55
|
disabled.length ? ` | Failed: ${disabled.join(", ")}` : ""
|
|
51
56
|
}`
|