@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-unified-hooks",
3
- "version": "0.0.4",
3
+ "version": "0.1.0",
4
4
  "description": "Unified hooks extension - collect scattered hooks in one place for easy maintenance",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -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, "&lt;")
98
+ .replace(/>/g, "&gt;")
99
+ .replace(/"/g, "&quot;")
100
+ .replace(/'/g, "&apos;");
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
- console.log(`[unified-hooks] ${e.toolName} error (callId=${e.toolCallId})`);
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
- // Log hook status on session start for debugging
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.log(
53
+ console.warn(
49
54
  `[unified-hooks] Loaded: ${enabled.join(", ") || "(none)"}${
50
55
  disabled.length ? ` | Failed: ${disabled.join(", ")}` : ""
51
56
  }`