@pi-unipi/subagents 2.6.0 → 2.6.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.
Files changed (107) hide show
  1. package/README.md +89 -78
  2. package/agents/delegate.md +14 -0
  3. package/agents/oracle.md +78 -0
  4. package/agents/researcher.md +52 -0
  5. package/agents/reviewer.md +79 -0
  6. package/agents/scout.md +50 -0
  7. package/agents/worker.md +59 -0
  8. package/package.json +19 -17
  9. package/prompts/council.md +48 -0
  10. package/prompts/gather-context-and-clarify.md +13 -0
  11. package/prompts/parallel-cleanup.md +59 -0
  12. package/prompts/parallel-research.md +50 -0
  13. package/prompts/parallel-review.md +54 -0
  14. package/prompts/review-loop.md +43 -0
  15. package/skills/council-mode/SKILL.md +230 -0
  16. package/skills/subagents/SKILL.md +49 -0
  17. package/skills/subagents/references/constraints-and-recipes.md +259 -0
  18. package/skills/subagents/references/execution-controls.md +454 -0
  19. package/skills/subagents/references/management-authoring-rpc.md +161 -0
  20. package/skills/subagents/references/multi-lane-orchestration.md +39 -0
  21. package/skills/subagents/references/prompting-and-roles.md +267 -0
  22. package/src/acceptance.ts +331 -0
  23. package/src/agent-manager.ts +405 -0
  24. package/src/agent-memory.ts +300 -0
  25. package/src/agent-overrides.ts +183 -0
  26. package/src/agent-runner.ts +265 -0
  27. package/src/async-runner.ts +417 -0
  28. package/src/authority-policy.ts +47 -0
  29. package/src/budgets.ts +285 -0
  30. package/src/child-safety.ts +140 -0
  31. package/src/config.ts +226 -0
  32. package/src/conversation-viewer.ts +281 -0
  33. package/src/core-compat.ts +94 -0
  34. package/src/custom-agents.ts +323 -0
  35. package/src/file-system-retry.ts +50 -0
  36. package/src/fleet-data.ts +61 -0
  37. package/src/fleet-view.ts +316 -0
  38. package/src/foreground-detach.ts +59 -0
  39. package/src/fork-context.ts +285 -0
  40. package/src/global.d.ts +7 -0
  41. package/src/guide.ts +129 -0
  42. package/src/index.ts +1149 -0
  43. package/src/mission-state.ts +133 -0
  44. package/src/mission-store.ts +434 -0
  45. package/src/model-resolver.ts +79 -0
  46. package/src/output-limits.ts +142 -0
  47. package/src/parity-types.ts +315 -0
  48. package/src/pi-args.ts +161 -0
  49. package/src/pi-spawn.ts +147 -0
  50. package/src/result-files.ts +232 -0
  51. package/src/result-watcher.ts +221 -0
  52. package/src/retained-children.ts +176 -0
  53. package/src/run-fanout-budget.ts +356 -0
  54. package/src/scheduled-runs.ts +351 -0
  55. package/src/schemas.ts +263 -0
  56. package/src/slash-commands.ts +77 -0
  57. package/src/supervisor-channel.ts +249 -0
  58. package/src/tool-handler.ts +1144 -0
  59. package/src/types.ts +238 -0
  60. package/src/widget.ts +447 -0
  61. package/src/workflow-script.ts +737 -0
  62. package/src/workflow-worker.ts +384 -0
  63. package/src/worktree.ts +614 -0
  64. package/dist/agent-manager.d.ts +0 -81
  65. package/dist/agent-manager.d.ts.map +0 -1
  66. package/dist/agent-manager.js +0 -295
  67. package/dist/agent-manager.js.map +0 -1
  68. package/dist/agent-runner.d.ts +0 -51
  69. package/dist/agent-runner.d.ts.map +0 -1
  70. package/dist/agent-runner.js +0 -262
  71. package/dist/agent-runner.js.map +0 -1
  72. package/dist/config.d.ts +0 -24
  73. package/dist/config.d.ts.map +0 -1
  74. package/dist/config.js +0 -132
  75. package/dist/config.js.map +0 -1
  76. package/dist/conversation-viewer.d.ts +0 -40
  77. package/dist/conversation-viewer.d.ts.map +0 -1
  78. package/dist/conversation-viewer.js +0 -276
  79. package/dist/conversation-viewer.js.map +0 -1
  80. package/dist/core-compat.d.ts +0 -21
  81. package/dist/core-compat.d.ts.map +0 -1
  82. package/dist/core-compat.js +0 -86
  83. package/dist/core-compat.js.map +0 -1
  84. package/dist/custom-agents.d.ts +0 -14
  85. package/dist/custom-agents.d.ts.map +0 -1
  86. package/dist/custom-agents.js +0 -113
  87. package/dist/custom-agents.js.map +0 -1
  88. package/dist/file-lock.d.ts +0 -42
  89. package/dist/file-lock.d.ts.map +0 -1
  90. package/dist/file-lock.js +0 -91
  91. package/dist/file-lock.js.map +0 -1
  92. package/dist/index.d.ts +0 -10
  93. package/dist/index.d.ts.map +0 -1
  94. package/dist/index.js +0 -758
  95. package/dist/index.js.map +0 -1
  96. package/dist/model-resolver.d.ts +0 -19
  97. package/dist/model-resolver.d.ts.map +0 -1
  98. package/dist/model-resolver.js +0 -61
  99. package/dist/model-resolver.js.map +0 -1
  100. package/dist/types.d.ts +0 -98
  101. package/dist/types.d.ts.map +0 -1
  102. package/dist/types.js +0 -47
  103. package/dist/types.js.map +0 -1
  104. package/dist/widget.d.ts +0 -56
  105. package/dist/widget.d.ts.map +0 -1
  106. package/dist/widget.js +0 -396
  107. package/dist/widget.js.map +0 -1
@@ -0,0 +1,281 @@
1
+ /**
2
+ * @pi-unipi/subagents — Conversation Viewer
3
+ *
4
+ * Live-scrolling overlay for viewing agent conversations.
5
+ * Subscribes to session events for real-time streaming updates.
6
+ * Supports keyboard navigation: ↑↓, PgUp/PgDn, Home/End, Esc/q to close.
7
+ */
8
+
9
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
10
+ import { formatMs, describeActivity } from "./widget.js";
11
+ import {
12
+ type Component,
13
+ matchesKey,
14
+ type TUI,
15
+ truncateToWidth,
16
+ visibleWidth,
17
+ wrapTextWithAnsi,
18
+ } from "@earendil-works/pi-tui";
19
+ import type { AgentActivity } from "./types.js";
20
+
21
+ /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
22
+ const CHROME_LINES = 6;
23
+ const MIN_VIEWPORT = 3;
24
+
25
+ /** Extract text from content array. */
26
+ function extractText(content: string | Array<{ type: string; text?: string }>): string {
27
+ if (typeof content === "string") return content;
28
+ return content
29
+ .filter((p): p is { type: "text"; text: string } => p.type === "text" && typeof p.text === "string")
30
+ .map((p) => p.text)
31
+ .join("");
32
+ }
33
+
34
+ /** Format tokens compactly. */
35
+ function formatTokens(count: number): string {
36
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
37
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
38
+ return `${count} token`;
39
+ }
40
+
41
+ /** Describe current activity from active tools. */
42
+
43
+ interface ViewerRecord {
44
+ type: string;
45
+ description: string;
46
+ status: string;
47
+ toolUses: number;
48
+ startedAt: number;
49
+ completedAt?: number;
50
+ }
51
+
52
+ export class ConversationViewer implements Component {
53
+ private scrollOffset = 0;
54
+ private autoScroll = true;
55
+ private unsubscribe: (() => void) | undefined;
56
+ private lastInnerW = 0;
57
+ private closed = false;
58
+
59
+ constructor(
60
+ private tui: TUI,
61
+ private session: AgentSession,
62
+ private record: ViewerRecord,
63
+ private activity: AgentActivity | undefined,
64
+ private theme: any,
65
+ private done: (result: undefined) => void,
66
+ ) {
67
+ this.unsubscribe = session.subscribe(() => {
68
+ if (this.closed) return;
69
+ this.tui.requestRender();
70
+ });
71
+ }
72
+
73
+ handleInput(data: string): void {
74
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
75
+ this.closed = true;
76
+ this.done(undefined);
77
+ return;
78
+ }
79
+
80
+ const totalLines = this.buildContentLines(this.lastInnerW).length;
81
+ const viewportHeight = this.viewportHeight();
82
+ const maxScroll = Math.max(0, totalLines - viewportHeight);
83
+
84
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
85
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
86
+ this.autoScroll = this.scrollOffset >= maxScroll;
87
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
88
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
89
+ this.autoScroll = this.scrollOffset >= maxScroll;
90
+ } else if (matchesKey(data, "pageUp")) {
91
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
92
+ this.autoScroll = false;
93
+ } else if (matchesKey(data, "pageDown")) {
94
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
95
+ this.autoScroll = this.scrollOffset >= maxScroll;
96
+ } else if (matchesKey(data, "home")) {
97
+ this.scrollOffset = 0;
98
+ this.autoScroll = false;
99
+ } else if (matchesKey(data, "end")) {
100
+ this.scrollOffset = maxScroll;
101
+ this.autoScroll = true;
102
+ }
103
+ }
104
+
105
+ render(width: number): string[] {
106
+ if (width < 6) return [];
107
+ const th = this.theme;
108
+ const innerW = width - 4; // border + padding
109
+ this.lastInnerW = innerW;
110
+ const lines: string[] = [];
111
+
112
+ const pad = (s: string, len: number) => {
113
+ const vis = visibleWidth(s);
114
+ return s + " ".repeat(Math.max(0, len - vis));
115
+ };
116
+ const row = (content: string) =>
117
+ th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
118
+ const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
119
+ const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
120
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
121
+
122
+ // Header
123
+ lines.push(hrTop);
124
+ const name = this.record.type;
125
+ const statusIcon =
126
+ this.record.status === "running"
127
+ ? th.fg("accent", "●")
128
+ : this.record.status === "completed"
129
+ ? th.fg("success", "✓")
130
+ : this.record.status === "error"
131
+ ? th.fg("error", "✗")
132
+ : th.fg("dim", "○");
133
+
134
+ const duration = this.record.completedAt
135
+ ? formatMs(this.record.completedAt - this.record.startedAt)
136
+ : `${formatMs(Date.now() - this.record.startedAt)} (running)`;
137
+
138
+ const headerParts: string[] = [duration];
139
+ const toolUses = this.activity?.toolUses ?? this.record.toolUses;
140
+ if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
141
+ if (this.activity?.session) {
142
+ try {
143
+ const tokens = (this.activity.session as any).getSessionStats().tokens.total;
144
+ if (tokens > 0) headerParts.push(formatTokens(tokens));
145
+ } catch {
146
+ /* */
147
+ }
148
+ }
149
+
150
+ lines.push(
151
+ row(
152
+ `${statusIcon} ${th.bold(name)} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
153
+ ),
154
+ );
155
+ lines.push(hrMid);
156
+
157
+ // Content area
158
+ const contentLines = this.buildContentLines(innerW);
159
+ const viewportHeight = this.viewportHeight();
160
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
161
+
162
+ if (this.autoScroll) {
163
+ this.scrollOffset = maxScroll;
164
+ }
165
+
166
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
167
+ const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
168
+
169
+ for (let i = 0; i < viewportHeight; i++) {
170
+ lines.push(row(visible[i] ?? ""));
171
+ }
172
+
173
+ // Footer
174
+ lines.push(hrMid);
175
+ const scrollPct =
176
+ contentLines.length <= viewportHeight
177
+ ? "100%"
178
+ : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
179
+ const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
180
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
181
+ const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
182
+ lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
183
+ lines.push(hrBot);
184
+
185
+ return lines;
186
+ }
187
+
188
+ invalidate(): void {
189
+ /* no cached state to clear */
190
+ }
191
+
192
+ dispose(): void {
193
+ this.closed = true;
194
+ if (this.unsubscribe) {
195
+ this.unsubscribe();
196
+ this.unsubscribe = undefined;
197
+ }
198
+ }
199
+
200
+ // ---- Private ----
201
+
202
+ private viewportHeight(): number {
203
+ return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
204
+ }
205
+
206
+ private buildContentLines(width: number): string[] {
207
+ if (width <= 0) return [];
208
+
209
+ const th = this.theme;
210
+ const messages = (this.session as any).messages;
211
+ const lines: string[] = [];
212
+
213
+ if (!messages || messages.length === 0) {
214
+ lines.push(th.fg("dim", "(waiting for first message...)"));
215
+ return lines;
216
+ }
217
+
218
+ let needsSeparator = false;
219
+ for (const msg of messages) {
220
+ if (msg.role === "user") {
221
+ const text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
222
+ if (!text.trim()) continue;
223
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
224
+ lines.push(th.fg("accent", "[User]"));
225
+ for (const line of wrapTextWithAnsi(text.trim(), width)) {
226
+ lines.push(line);
227
+ }
228
+ } else if (msg.role === "assistant") {
229
+ const textParts: string[] = [];
230
+ const toolCalls: string[] = [];
231
+ for (const c of msg.content) {
232
+ if (c.type === "text" && c.text) textParts.push(c.text);
233
+ else if (c.type === "tool_use" || c.type === "toolCall") {
234
+ toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown");
235
+ }
236
+ }
237
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
238
+ lines.push(th.bold("[Assistant]"));
239
+ if (textParts.length > 0) {
240
+ for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
241
+ lines.push(line);
242
+ }
243
+ }
244
+ for (const name of toolCalls) {
245
+ lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
246
+ }
247
+ } else if (msg.role === "toolResult") {
248
+ const text = extractText(msg.content);
249
+ const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
250
+ if (!truncated.trim()) continue;
251
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
252
+ lines.push(th.fg("dim", "[Result]"));
253
+ for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
254
+ lines.push(th.fg("dim", line));
255
+ }
256
+ } else if ((msg as any).role === "bashExecution") {
257
+ const bash = msg as any;
258
+ if (needsSeparator) lines.push(th.fg("dim", "───"));
259
+ lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
260
+ if (bash.output?.trim()) {
261
+ const out = bash.output.length > 500 ? bash.output.slice(0, 500) + "... (truncated)" : bash.output;
262
+ for (const line of wrapTextWithAnsi(out.trim(), width)) {
263
+ lines.push(th.fg("dim", line));
264
+ }
265
+ }
266
+ } else {
267
+ continue;
268
+ }
269
+ needsSeparator = true;
270
+ }
271
+
272
+ // Streaming indicator for running agents
273
+ if (this.record.status === "running" && this.activity) {
274
+ const act = describeActivity(this.activity.activeTools, this.activity.responseText);
275
+ lines.push("");
276
+ lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
277
+ }
278
+
279
+ return lines.map((l) => truncateToWidth(l, width));
280
+ }
281
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @pi-unipi/subagents — Local helpers (not in @pi-unipi/core)
3
+ *
4
+ * boundHelperOutput: bounded output with artifact-to-disk fallback.
5
+ * withHerdrBlocked: wraps a fn with herdr:blocked events.
6
+ */
7
+
8
+ import { emitEvent } from "@pi-unipi/core";
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { chmodSync, lstatSync, mkdirSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { randomUUID } from "node:crypto";
14
+
15
+ export interface BoundedHelperOutput {
16
+ text: string;
17
+ truncated: boolean;
18
+ originalBytes: number;
19
+ artifactPath?: string;
20
+ }
21
+
22
+ const MAX_RAW_HELPER_ARTIFACT_BYTES = 16 * 1024 * 1024;
23
+
24
+ export function boundHelperOutput(
25
+ text: string,
26
+ maxBytes = 64 * 1024,
27
+ existingArtifactPath?: string,
28
+ ): BoundedHelperOutput {
29
+ const originalBytes = Buffer.byteLength(text, "utf8");
30
+ if (originalBytes <= maxBytes) return { text, truncated: false, originalBytes };
31
+
32
+ let artifactPath = existingArtifactPath;
33
+ let artifactWarning: string | undefined;
34
+ if (!artifactPath && originalBytes <= MAX_RAW_HELPER_ARTIFACT_BYTES) {
35
+ try {
36
+ const dir = join(homedir(), ".unipi", "tool-results");
37
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
38
+ let stat = lstatSync(dir);
39
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
40
+ throw new Error(`Refusing unsafe tool-result directory: ${dir}`);
41
+ }
42
+ if ((stat.mode & 0o077) !== 0) {
43
+ chmodSync(dir, 0o700);
44
+ stat = lstatSync(dir);
45
+ if ((stat.mode & 0o077) !== 0) throw new Error(`Refusing non-private tool-result directory: ${dir}`);
46
+ }
47
+ artifactPath = join(dir, `helper-${randomUUID()}.txt`);
48
+ writeFileSync(artifactPath, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
49
+ } catch (error) {
50
+ artifactWarning = `Full-output artifact unavailable: ${error instanceof Error ? error.message : String(error)}`;
51
+ artifactPath = undefined;
52
+ }
53
+ } else if (!artifactPath) {
54
+ artifactWarning = `Full output exceeded the ${MAX_RAW_HELPER_ARTIFACT_BYTES}-byte local artifact safety cap and was not retained.`;
55
+ }
56
+
57
+ const marker = [
58
+ "",
59
+ "--- output bounded by UniPi ---",
60
+ artifactPath ? `Full output: ${artifactPath}` : artifactWarning!,
61
+ `Original size: ${originalBytes} bytes; model-visible ceiling: ${maxBytes} bytes.`,
62
+ ...(artifactPath ? ["Use the read tool with offset/limit to inspect only the needed region."] : []),
63
+ ].join("\n");
64
+ const markerBytes = Buffer.byteLength(marker, "utf8");
65
+ const contentBudget = Math.max(1, maxBytes - markerBytes - 80);
66
+ const bytes = Buffer.from(text, "utf8");
67
+ const headBytes = Math.ceil(contentBudget * 0.75);
68
+ const tailBytes = Math.max(0, contentBudget - headBytes);
69
+ const head = bytes.subarray(0, headBytes).toString("utf8").replace(/\uFFFD+$/u, "");
70
+ const tail = bytes.subarray(originalBytes - tailBytes).toString("utf8").replace(/^\uFFFD+/u, "");
71
+ let bounded = `${head}\n… ${Math.max(0, originalBytes - contentBudget)} bytes omitted …\n${tail}${marker}`;
72
+ if (Buffer.byteLength(bounded, "utf8") > maxBytes) {
73
+ bounded = Buffer.from(bounded, "utf8").subarray(0, maxBytes).toString("utf8").replace(/\uFFFD+$/u, "");
74
+ }
75
+ return {
76
+ text: bounded,
77
+ truncated: true,
78
+ originalBytes,
79
+ artifactPath,
80
+ };
81
+ }
82
+
83
+ export async function withHerdrBlocked<T>(
84
+ pi: Pick<ExtensionAPI, "events">,
85
+ label: string,
86
+ fn: () => Promise<T>,
87
+ ): Promise<T> {
88
+ emitEvent(pi, "herdr:blocked", { active: true, label });
89
+ try {
90
+ return await fn();
91
+ } finally {
92
+ emitEvent(pi, "herdr:blocked", { active: false, label });
93
+ }
94
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * @pi-unipi/subagents — Custom agent loader
3
+ *
4
+ * Discovers agent types from:
5
+ * - <workspace>/.unipi/config/agents/*.md (project, highest priority)
6
+ * - ~/.unipi/config/agents/*.md (global)
7
+ * - packages/subagents/agents/*.md (builtin, lowest priority — pi-subagents parity)
8
+ *
9
+ * Recursive subdirectory discovery: project wins name collisions.
10
+ */
11
+
12
+ import { existsSync, readdirSync, readFileSync, renameSync } from "node:fs";
13
+ import { dirname, join, relative } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { fileURLToPath } from "node:url";
16
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
17
+ import type { AgentConfig } from "./types.js";
18
+ import { parseMemoryFrontmatter } from "./agent-memory.js";
19
+
20
+ function compareCodeUnits(a: string, b: string): number {
21
+ return a < b ? -1 : a > b ? 1 : 0;
22
+ }
23
+
24
+ /** Backup a corrupted file by renaming to .bak */
25
+ function backupCorrupted(filePath: string): void {
26
+ const backupPath = filePath + ".bak";
27
+ try {
28
+ renameSync(filePath, backupPath);
29
+ } catch {
30
+ // If backup fails, just leave it
31
+ }
32
+ }
33
+
34
+ /** Get project agents directory. */
35
+ function getProjectAgentsDir(cwd: string): string {
36
+ return join(cwd, ".unipi", "config", "agents");
37
+ }
38
+
39
+ /** Get global agents directory. */
40
+ function getGlobalAgentsDir(): string {
41
+ return join(homedir(), ".unipi", "config", "agents");
42
+ }
43
+
44
+ /** All known built-in tool names. */
45
+ const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"];
46
+
47
+ /** Known frontmatter fields (pi-subagents KNOWN_FIELDS + our unipi extensions). */
48
+ const KNOWN_FIELDS = new Set([
49
+ "name",
50
+ "display_name",
51
+ "description",
52
+ "alias",
53
+ "aliases",
54
+ "tools",
55
+ "disallowed_tools",
56
+ "model",
57
+ "fallbackModels",
58
+ "thinking",
59
+ "extensions",
60
+ "skills",
61
+ "max_turns",
62
+ "systemPromptMode",
63
+ "prompt_mode",
64
+ "inheritProjectContext",
65
+ "inheritSkills",
66
+ "defaultContext",
67
+ "async",
68
+ "run_in_background",
69
+ "isolated",
70
+ "timeoutMs",
71
+ "toolTimeoutMs",
72
+ "turnBudget",
73
+ "skill",
74
+ "skillPath",
75
+ "subagentOnlyExtensions",
76
+ "output",
77
+ "outputMode",
78
+ "defaultReads",
79
+ "defaultProgress",
80
+ "interactive",
81
+ "maxSubagentDepth",
82
+ "completionGuard",
83
+ "toolBudget",
84
+ "memory",
85
+ "enabled",
86
+ "runner",
87
+ ]);
88
+
89
+ /** Parse a comma-separated frontmatter list (reference parseFrontmatterList). */
90
+ function parseList(raw: unknown): string[] | undefined {
91
+ if (raw === undefined || raw === null) return undefined;
92
+ if (Array.isArray(raw)) return raw.map(String).map((s) => s.trim()).filter(Boolean);
93
+ if (typeof raw === "string") return raw.split(",").map((s) => s.trim()).filter(Boolean);
94
+ return undefined;
95
+ }
96
+
97
+ /** Default systemPromptMode per reference (delegate appends, others replace). */
98
+ function defaultSystemPromptMode(name: string): "replace" | "append" {
99
+ return name === "delegate" || name === "explore" ? "append" : "replace";
100
+ }
101
+
102
+ /** Default inheritProjectContext per reference (delegate only). */
103
+ function defaultInheritProjectContext(name: string): boolean {
104
+ return name === "delegate";
105
+ }
106
+
107
+ /** Parse a boolean-ish frontmatter value ("true"/"false" strings or actual booleans). */
108
+ function parseBool(raw: unknown): boolean | undefined {
109
+ if (raw === "true" || raw === true) return true;
110
+ if (raw === "false" || raw === false) return false;
111
+ return undefined;
112
+ }
113
+
114
+ /** Parse a positive-integer frontmatter value with a visible error. */
115
+ function parsePositiveInt(raw: unknown, label: string): number | undefined {
116
+ if (raw === undefined || raw === null || raw === "") return undefined;
117
+ const parsed = Number(raw);
118
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 2_147_483_647) {
119
+ throw new Error(`${label} must be a positive integer no greater than 2147483647 (got ${JSON.stringify(raw)})`);
120
+ }
121
+ return parsed;
122
+ }
123
+
124
+ /** Directory names pruned during recursive discovery. */
125
+ const DISCOVERY_PRUNED_DIR_NAMES = new Set(["node_modules", ".git", ".pi", ".unipi"]);
126
+
127
+ /** List agent .md files recursively under a directory (skips .chain.md, prunes node_modules). */
128
+ function listAgentFilesRecursive(dir: string): string[] {
129
+ const files: string[] = [];
130
+ if (!existsSync(dir)) return files;
131
+ let entries: import("node:fs").Dirent[];
132
+ try {
133
+ entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
134
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
135
+ );
136
+ } catch {
137
+ return files;
138
+ }
139
+ for (const entry of entries) {
140
+ const filePath = join(dir, entry.name);
141
+ if (entry.isDirectory()) {
142
+ if (!DISCOVERY_PRUNED_DIR_NAMES.has(entry.name)) {
143
+ files.push(...listAgentFilesRecursive(filePath));
144
+ }
145
+ continue;
146
+ }
147
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
148
+ if (!entry.name.endsWith(".md") || entry.name.endsWith(".chain.md")) continue;
149
+ files.push(filePath);
150
+ }
151
+ return files;
152
+ }
153
+
154
+ /** Builtin agent definition files shipped in packages/subagents/agents/. */
155
+ const BUILTIN_AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "agents");
156
+
157
+ /** Frontmatter fields stored as-is on the config for later phases. */
158
+ function collectExtraFields(frontmatter: Record<string, unknown>): Record<string, string> | undefined {
159
+ const extra: Record<string, string> = {};
160
+ for (const [key, value] of Object.entries(frontmatter)) {
161
+ if (!KNOWN_FIELDS.has(key)) extra[key] = String(value);
162
+ }
163
+ return Object.keys(extra).length ? extra : undefined;
164
+ }
165
+
166
+ /**
167
+ * Load a single agent from a .md file. Accepts both our legacy unipi frontmatter
168
+ * (display_name, disallowed_tools, prompt_mode, max_turns, run_in_background,
169
+ * isolated, enabled) and the pi-subagents reference frontmatter (aliases,
170
+ * systemPromptMode, inheritProjectContext, defaultContext, timeoutMs, ...).
171
+ */
172
+ function loadAgentFromFile(filePath: string, source: "project" | "global" | "builtin"): AgentConfig | null {
173
+ try {
174
+ const content = readFileSync(filePath, "utf-8");
175
+ const { frontmatter, body } = parseFrontmatter(content);
176
+
177
+ if (!frontmatter || typeof frontmatter !== "object") {
178
+ return null;
179
+ }
180
+ const fm = frontmatter as Record<string, unknown>;
181
+
182
+ // Name: frontmatter name wins; else filename (our legacy convention).
183
+ const name =
184
+ (typeof fm.name === "string" && fm.name.trim()) ||
185
+ filePath.split("/").pop()?.replace(/\.md$/, "") ||
186
+ "unknown";
187
+
188
+ const description =
189
+ (typeof fm.description === "string" && fm.description) || `${name} agent`;
190
+
191
+ // Tools: comma-separated string; unknown tool names kept (mcp/extension tools).
192
+ const toolsList = parseList(fm.tools);
193
+ const builtinToolNames = toolsList ?? [...BUILTIN_TOOL_NAMES];
194
+
195
+ const aliases = parseList(fm.aliases ?? fm.alias);
196
+
197
+ // Prompt mode: reference systemPromptMode or our legacy prompt_mode.
198
+ const promptModeRaw = fm.systemPromptMode ?? fm.prompt_mode;
199
+ const promptMode: "replace" | "append" =
200
+ promptModeRaw === "replace" || promptModeRaw === "append"
201
+ ? promptModeRaw
202
+ : defaultSystemPromptMode(name);
203
+
204
+ const inheritProjectContext =
205
+ parseBool(fm.inheritProjectContext) ?? defaultInheritProjectContext(name);
206
+ const inheritSkills = parseBool(fm.inheritSkills) ?? false;
207
+
208
+ const defaultContext =
209
+ fm.defaultContext === "fork" ? "fork" : fm.defaultContext === "fresh" ? "fresh" : undefined;
210
+
211
+ const timeoutMs = parsePositiveInt(fm.timeoutMs, `Agent '${name}' timeoutMs`);
212
+ const toolTimeoutMs = parsePositiveInt(fm.toolTimeoutMs, `Agent '${name}' toolTimeoutMs`);
213
+ const maxSubagentDepth =
214
+ fm.maxSubagentDepth !== undefined && fm.maxSubagentDepth !== null && fm.maxSubagentDepth !== ""
215
+ ? parsePositiveInt(fm.maxSubagentDepth, `Agent '${name}' maxSubagentDepth`)
216
+ : undefined;
217
+
218
+ let defaultAsync: boolean | undefined;
219
+ if (fm.async !== undefined) {
220
+ const parsed = parseBool(fm.async);
221
+ if (parsed === undefined) {
222
+ throw new Error(`Agent '${name}' has invalid async frontmatter; expected true or false.`);
223
+ }
224
+ defaultAsync = parsed;
225
+ }
226
+
227
+ let outputMode: "inline" | "file-only" | undefined;
228
+ if (fm.outputMode !== undefined && fm.outputMode !== "") {
229
+ if (fm.outputMode === "inline" || fm.outputMode === "file-only") outputMode = fm.outputMode;
230
+ else throw new Error(`Agent '${name}' has invalid outputMode frontmatter; expected 'inline' or 'file-only'.`);
231
+ }
232
+
233
+ return {
234
+ name,
235
+ displayName: (fm.display_name as string | undefined) ?? undefined,
236
+ description,
237
+ ...(aliases?.length ? { aliases } : {}),
238
+ builtinToolNames,
239
+ disallowedTools: parseList(fm.disallowed_tools),
240
+ extensions: fm.extensions !== false,
241
+ skills: fm.skills !== false,
242
+ ...(parseList(fm.skillPath)?.length ? { skillPath: parseList(fm.skillPath) } : {}),
243
+ model: (fm.model as string | undefined) || undefined,
244
+ ...(parseList(fm.fallbackModels)?.length ? { fallbackModels: parseList(fm.fallbackModels) } : {}),
245
+ thinking: fm.thinking as any,
246
+ maxTurns: parsePositiveInt(fm.max_turns, `Agent '${name}' max_turns`),
247
+ systemPrompt: body.trim(),
248
+ promptMode,
249
+ inheritProjectContext,
250
+ inheritSkills,
251
+ ...(defaultContext !== undefined ? { defaultContext } : {}),
252
+ ...(defaultAsync !== undefined ? { runInBackground: defaultAsync } : {}),
253
+ runInBackground: (fm.run_in_background as boolean | undefined) ?? defaultAsync,
254
+ isolated: fm.isolated as boolean | undefined,
255
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
256
+ ...(toolTimeoutMs !== undefined ? { toolTimeoutMs } : {}),
257
+ ...(maxSubagentDepth !== undefined ? { maxSubagentDepth } : {}),
258
+ ...(outputMode !== undefined ? { outputMode } : {}),
259
+ ...(typeof fm.output === "string" && fm.output ? { output: fm.output } : {}),
260
+ ...(parseList(fm.defaultReads)?.length ? { defaultReads: parseList(fm.defaultReads) } : {}),
261
+ ...(parseBool(fm.defaultProgress) !== undefined ? { defaultProgress: parseBool(fm.defaultProgress) } : {}),
262
+ ...(parseMemoryFrontmatter(fm.memory) ? { memory: parseMemoryFrontmatter(fm.memory)! } : undefined),
263
+ enabled: fm.enabled !== false,
264
+ source,
265
+ ...(collectExtraFields(fm) ? { extraFields: collectExtraFields(fm) } : {}),
266
+ };
267
+ } catch (err) {
268
+ // Corrupted file — backup and skip (builtin files rethrow: they ship with us)
269
+ if (source === "builtin") throw err;
270
+ backupCorrupted(filePath);
271
+ return null;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Load builtin definition-file agents from packages/subagents/agents/.
277
+ * Loaded at the LOWEST priority: user/global and project agents override by name.
278
+ */
279
+ export function loadBuiltinFileAgents(): Map<string, AgentConfig> {
280
+ const agents = new Map<string, AgentConfig>();
281
+ for (const filePath of listAgentFilesRecursive(BUILTIN_AGENTS_DIR)) {
282
+ const agent = loadAgentFromFile(filePath, "builtin");
283
+ if (agent) agents.set(agent.name, agent);
284
+ }
285
+ return agents;
286
+ }
287
+
288
+ /**
289
+ * Load all custom agents from project and global directories.
290
+ * Priority: project > global > builtin (code BUILTIN_CONFIGS last).
291
+ */
292
+ export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
293
+ const agents = new Map<string, AgentConfig>();
294
+
295
+ // Builtin file agents first (lowest priority)
296
+ for (const [name, agent] of loadBuiltinFileAgents()) {
297
+ agents.set(name, agent);
298
+ }
299
+
300
+ // Global agents (override builtins)
301
+ const globalDir = getGlobalAgentsDir();
302
+ if (existsSync(globalDir)) {
303
+ for (const filePath of listAgentFilesRecursive(globalDir)) {
304
+ const agent = loadAgentFromFile(filePath, "global");
305
+ if (agent) {
306
+ agents.set(agent.name, agent);
307
+ }
308
+ }
309
+ }
310
+
311
+ // Project agents (override global + builtin)
312
+ const projectDir = getProjectAgentsDir(cwd);
313
+ if (existsSync(projectDir)) {
314
+ for (const filePath of listAgentFilesRecursive(projectDir)) {
315
+ const agent = loadAgentFromFile(filePath, "project");
316
+ if (agent) {
317
+ agents.set(agent.name, agent);
318
+ }
319
+ }
320
+ }
321
+
322
+ return agents;
323
+ }