@pi-archimedes/subagent 0.7.0 → 0.9.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/src/agents.ts CHANGED
@@ -10,12 +10,14 @@ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
10
  export interface AgentConfig {
11
11
  name: string;
12
12
  description: string;
13
+ tools?: string[];
13
14
  model?: string;
14
15
  thinking?: string;
15
- tools?: string[];
16
16
  systemPrompt: string;
17
17
  source: "user" | "project";
18
18
  filePath: string;
19
+ // Extra fields preserved from frontmatter but not editable in TUI
20
+ extraFields?: Record<string, unknown>;
19
21
  }
20
22
 
21
23
  function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
@@ -46,9 +48,26 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
46
48
 
47
49
  if (!frontmatter.name || !frontmatter.description) continue;
48
50
 
49
- const tools = typeof frontmatter.tools === "string"
50
- ? frontmatter.tools.split(",").map((t: string) => t.trim()).filter(Boolean)
51
- : undefined;
51
+ // Handle tools specially preserve non-string values in extraFields
52
+ let parsedTools: string[] | undefined;
53
+ if (typeof frontmatter.tools === "string") {
54
+ parsedTools = frontmatter.tools.split(",").map((t: string) => t.trim()).filter(Boolean);
55
+ }
56
+
57
+ const knownKeys = new Set(["name", "description", "model", "thinking"]);
58
+ const extraFields: Record<string, unknown> = {};
59
+ for (const [key, value] of Object.entries(frontmatter)) {
60
+ if (key === "tools") continue; // tools is handled separately above/below
61
+ if (!knownKeys.has(key) && value != null && typeof value !== "object") {
62
+ extraFields[key] = String(value);
63
+ } else if (!knownKeys.has(key) && value != null) {
64
+ extraFields[key] = value;
65
+ }
66
+ }
67
+ // If tools was non-string, store it in extraFields for round-trip preservation
68
+ if (frontmatter.tools !== undefined && !parsedTools && "tools" in frontmatter) {
69
+ extraFields.tools = frontmatter.tools;
70
+ }
52
71
 
53
72
  const config: AgentConfig = {
54
73
  name: frontmatter.name as string,
@@ -59,7 +78,8 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
59
78
  };
60
79
  if (frontmatter.model) config.model = frontmatter.model as string;
61
80
  if (frontmatter.thinking) config.thinking = frontmatter.thinking as string;
62
- if (tools && tools.length > 0) config.tools = tools;
81
+ if (parsedTools && parsedTools.length > 0) config.tools = parsedTools;
82
+ if (Object.keys(extraFields).length > 0) config.extraFields = extraFields;
63
83
 
64
84
  agents.push(config);
65
85
  }
@@ -67,19 +87,14 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
67
87
  return agents;
68
88
  }
69
89
 
70
- function isDirectory(p: string): boolean {
71
- try {
72
- return fs.statSync(p).isDirectory();
73
- } catch {
74
- return false;
75
- }
76
- }
77
-
78
90
  function findNearestProjectAgentsDir(cwd: string): string | null {
91
+ // Walk up looking for a git repo, even if .pi/agents doesn't exist yet
79
92
  let currentDir = cwd;
80
93
  while (true) {
81
- const candidate = path.join(currentDir, ".pi", "agents");
82
- if (isDirectory(candidate)) return candidate;
94
+ const gitPath = path.join(currentDir, ".git");
95
+ if (fs.existsSync(gitPath)) {
96
+ return path.join(currentDir, ".pi", "agents");
97
+ }
83
98
 
84
99
  const parentDir = path.dirname(currentDir);
85
100
  if (parentDir === currentDir) return null;
@@ -87,6 +102,16 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
87
102
  }
88
103
  }
89
104
 
105
+ /**
106
+ * Result of discovering agents — separated by source with directory paths.
107
+ */
108
+ export interface AgentsDiscoveryResult {
109
+ user: AgentConfig[];
110
+ project: AgentConfig[];
111
+ userDir: string; // e.g., ~/.pi/agent/agents
112
+ projectDir: string | null; // e.g., .pi/agents or null if not found
113
+ }
114
+
90
115
  /**
91
116
  * Discover all available agents from user and/or project directories.
92
117
  */
@@ -105,6 +130,24 @@ export function discoverAgents(cwd: string): AgentConfig[] {
105
130
  return Array.from(agentMap.values());
106
131
  }
107
132
 
133
+ /**
134
+ * Discover agents and return them separated by source with directory paths.
135
+ */
136
+ export function discoverAgentsAll(cwd: string): AgentsDiscoveryResult {
137
+ const userDir = path.join(getAgentDir(), "agents");
138
+ const projectDir = findNearestProjectAgentsDir(cwd);
139
+
140
+ const userAgents = loadAgentsFromDir(userDir, "user");
141
+ const projectAgents = projectDir ? loadAgentsFromDir(projectDir, "project") : [];
142
+
143
+ return {
144
+ user: userAgents,
145
+ project: projectAgents,
146
+ userDir,
147
+ projectDir,
148
+ };
149
+ }
150
+
108
151
  /**
109
152
  * Look up an agent config by name.
110
153
  */
package/src/compact.ts CHANGED
@@ -7,46 +7,61 @@ type RenderContext = { state: Record<string, unknown>; invalidate: () => void };
7
7
 
8
8
  // ── Activity line builder ───────────────────────────────────────────────────
9
9
 
10
+ interface ActivityData {
11
+ currentTool: string | undefined;
12
+ currentToolArgs: string | undefined;
13
+ currentToolStartedAt: number | undefined;
14
+ finalOutput: string | undefined;
15
+ status: "running" | "completed" | "failed" | undefined;
16
+ error: string | undefined;
17
+ toolCalls?: string[] | undefined;
18
+ }
19
+
10
20
  export function buildActivityLine(
11
- progress: {
12
- currentTool: string | undefined;
13
- currentToolArgs: string | undefined;
14
- currentToolStartedAt: number | undefined;
15
- finalOutput: string | undefined;
16
- status: "running" | "completed" | "failed" | undefined;
17
- error: string | undefined;
18
- },
21
+ data: ActivityData,
19
22
  theme: Theme,
20
23
  ): string {
21
- const prefix = theme.fg("dim", " ⎿ ");
22
-
23
- if (progress.error) {
24
- return prefix + theme.fg("error", truncLine(progress.error, 80));
24
+ if (data.error) {
25
+ return theme.fg("error", "✗ " + truncLine(data.error, 80));
25
26
  }
26
27
 
27
- if (progress.currentTool) {
28
- const argsPreview = progress.currentToolArgs
29
- ? truncLine(progress.currentToolArgs ?? "", 60)
28
+ if (data.currentTool) {
29
+ const arrow = theme.fg("muted", "↳ ");
30
+ const argsPreview = data.currentToolArgs
31
+ ? truncLine(data.currentToolArgs ?? "", 60)
30
32
  : "";
31
- const durationPart = progress.currentToolStartedAt
32
- ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
33
+ const durationPart = data.currentToolStartedAt
34
+ ? " | " + formatDuration(Date.now() - data.currentToolStartedAt)
33
35
  : "";
34
- let line = theme.fg("syntaxFunction", progress.currentTool);
36
+ let line = theme.fg("syntaxFunction", data.currentTool);
35
37
  if (argsPreview) {
36
38
  line += theme.fg("dim", ": " + argsPreview);
37
39
  }
38
40
  if (durationPart) {
39
41
  line += theme.fg("dim", durationPart);
40
42
  }
41
- return prefix + line;
43
+ return arrow + line;
44
+ }
45
+
46
+ // Show last completed tool call from history
47
+ if (data.toolCalls && data.toolCalls.length > 0) {
48
+ const lastCall = data.toolCalls[data.toolCalls.length - 1];
49
+ if (lastCall) return theme.fg("muted", "↳ " + lastCall);
50
+ }
51
+
52
+ if (data.finalOutput) {
53
+ const firstLine = data.finalOutput.split("\n")[0] ?? "";
54
+ return theme.fg("muted", "↳ " + truncLine(firstLine, 80));
42
55
  }
43
56
 
44
- if (progress.finalOutput) {
45
- const firstLine = progress.finalOutput.split("\n")[0] ?? "";
46
- return prefix + truncLine(firstLine, 80);
57
+ // Running with no tool info yet
58
+ if (data.status === "running") {
59
+ return theme.fg("muted", "↳ Starting...");
47
60
  }
48
61
 
49
- return "";
62
+ return data.status === "completed"
63
+ ? theme.fg("success", "✓ Done")
64
+ : theme.fg("error", "✗ Failed");
50
65
  }
51
66
 
52
67
  // ── Status glyph ────────────────────────────────────────────────────────────
@@ -90,40 +105,18 @@ export function renderCompactSingle(
90
105
  };
91
106
  const statsLine = buildStatsLine(statsData, theme);
92
107
 
93
- const statsPart = statsLine ?? "";
108
+ const statsPart = statsLine;
94
109
 
95
110
  // Activity: arrow + current tool if running, status if finished
96
- let activityLine: string;
97
- if (isRunning) {
98
- const arrow = theme.fg("muted", "↳ ");
99
- if (progress?.currentTool) {
100
- const argsPreview = progress.currentToolArgs
101
- ? truncLine(progress.currentToolArgs ?? "", 60)
102
- : "";
103
- const durationPart = progress.currentToolStartedAt
104
- ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
105
- : "";
106
- let line = theme.fg("syntaxFunction", progress.currentTool);
107
- if (argsPreview) {
108
- line += theme.fg("dim", ": " + argsPreview);
109
- }
110
- if (durationPart) {
111
- line += theme.fg("dim", durationPart);
112
- }
113
- activityLine = arrow + line;
114
- } else if (progress?.toolCalls && progress.toolCalls.length > 0) {
115
- const lastCall = progress.toolCalls[progress.toolCalls.length - 1];
116
- activityLine = theme.fg("dim", " ⎿ ") + theme.fg("muted", lastCall ?? "");
117
- } else {
118
- activityLine = theme.fg("muted", " ⎿ Working...");
119
- }
120
- } else if (result.error) {
121
- activityLine = theme.fg("error", "✗ " + truncLine(result.error, 80));
122
- } else if (status === "completed") {
123
- activityLine = theme.fg("success", "✓ Done");
124
- } else {
125
- activityLine = theme.fg("error", "✗ Failed");
126
- }
111
+ const activityLine = buildActivityLine({
112
+ currentTool: progress?.currentTool,
113
+ currentToolArgs: progress?.currentToolArgs,
114
+ currentToolStartedAt: progress?.currentToolStartedAt,
115
+ finalOutput: result.finalOutput,
116
+ status: isRunning ? "running" : status,
117
+ error: result.error,
118
+ toolCalls: progress?.toolCalls,
119
+ }, theme);
127
120
 
128
121
  const modelName = progress?.model ?? result.model;
129
122
  const modelLabel = modelName
@@ -176,6 +169,7 @@ export function renderCompactParallel(
176
169
  finalOutput: result.finalOutput,
177
170
  status,
178
171
  error: result.error,
172
+ toolCalls: progress?.toolCalls,
179
173
  };
180
174
  const activityLine = buildActivityLine(activityData, theme);
181
175
 
@@ -228,42 +222,20 @@ export function renderCompactProgress(
228
222
  const statsLine = buildStatsLine(statsData, theme);
229
223
 
230
224
  // Activity: arrow + current tool if running, status if finished
231
- let activityLine: string;
232
- if (isRunning) {
233
- const arrow = theme.fg("muted", "↳ ");
234
- if (progress.currentTool) {
235
- const argsPreview = progress.currentToolArgs
236
- ? truncLine(progress.currentToolArgs ?? "", 60)
237
- : "";
238
- const durationPart = progress.currentToolStartedAt
239
- ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
240
- : "";
241
- let line = theme.fg("syntaxFunction", progress.currentTool);
242
- if (argsPreview) {
243
- line += theme.fg("dim", ": " + argsPreview);
244
- }
245
- if (durationPart) {
246
- line += theme.fg("dim", durationPart);
247
- }
248
- activityLine = arrow + line;
249
- } else if (progress.toolCalls && progress.toolCalls.length > 0) {
250
- const lastCall = progress.toolCalls[progress.toolCalls.length - 1];
251
- activityLine = arrow + theme.fg("muted", lastCall ?? "");
252
- } else {
253
- activityLine = arrow + theme.fg("muted", "Working...");
254
- }
255
- } else if (progress.error) {
256
- activityLine = theme.fg("error", "✗ " + truncLine(progress.error, 80));
257
- } else if (status === "completed") {
258
- activityLine = theme.fg("success", "✓ Done");
259
- } else {
260
- activityLine = theme.fg("error", "✗ Failed");
261
- }
225
+ const activityLine = buildActivityLine({
226
+ currentTool: progress.currentTool,
227
+ currentToolArgs: progress.currentToolArgs,
228
+ currentToolStartedAt: progress.currentToolStartedAt,
229
+ finalOutput: undefined,
230
+ status,
231
+ error: progress.error,
232
+ toolCalls: progress.toolCalls,
233
+ }, theme);
262
234
 
263
235
  const modelLabel = progress.model
264
236
  ? theme.fg("accent", progress.model)
265
237
  : "";
266
- const statsPart = statsLine ?? "";
238
+ const statsPart = statsLine;
267
239
  const expandHint = theme.fg("muted", "(ctrl+o)");
268
240
  let output = [modelLabel, statsPart, expandHint].filter(Boolean).join(" ");
269
241
  output += "\n" + activityLine;
@@ -309,6 +281,7 @@ export function renderCompactParallelProgress(
309
281
  finalOutput: undefined,
310
282
  status,
311
283
  error: progress.error,
284
+ toolCalls: progress.toolCalls,
312
285
  };
313
286
  const activityLine = buildActivityLine(activityData, theme);
314
287
 
package/src/execute.ts CHANGED
@@ -21,20 +21,20 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
21
21
  const agentName = options.agent ?? "subagent";
22
22
  const startTime = Date.now();
23
23
 
24
- const child = spawnSubagent({
25
- task: options.task,
26
- model: options.model,
27
- cwd: options.cwd,
28
- signal: options.signal,
29
- agent: options.agentConfig,
30
- });
31
-
32
24
  // Track previously emitted values to only emit deltas
33
25
  let lastEmittedInput = 0;
34
26
  let lastEmittedOutput = 0;
35
27
  let lastEmittedCost = 0;
36
28
 
37
29
  try {
30
+ const child = spawnSubagent({
31
+ task: options.task,
32
+ model: options.model,
33
+ cwd: options.cwd,
34
+ signal: options.signal,
35
+ agent: options.agentConfig,
36
+ });
37
+
38
38
  const result = await streamEvents(child, {
39
39
  agent: agentName,
40
40
  task: options.task,
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Frontmatter serialization and validation for agent config files.
3
+ */
4
+
5
+ import type { AgentConfig } from "./agents.js";
6
+
7
+ /**
8
+ * Regex for valid agentspec names: lowercase alphanumeric + hyphens, 3-50 chars.
9
+ * Alphanumeric start/end. Also allows single-char names.
10
+ */
11
+ export const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/;
12
+
13
+ const SINGLE_CHAR_NAME_REGEX = /^[a-z0-9]$/;
14
+
15
+ /**
16
+ * Validate an agent name against agentspec format rules.
17
+ * Returns an error message string if invalid, null if valid.
18
+ */
19
+ export function validateAgentName(name: string): string | null {
20
+ if (!name || name.length === 0) {
21
+ return "Name is required";
22
+ }
23
+ if (SINGLE_CHAR_NAME_REGEX.test(name)) {
24
+ return null;
25
+ }
26
+ if (AGENT_NAME_REGEX.test(name)) {
27
+ return null;
28
+ }
29
+ return "Name must be 3-50 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric";
30
+ }
31
+
32
+ function needsYamlQuoting(value: string): boolean {
33
+ if (value === "") return true;
34
+ // Special YAML characters at start
35
+ if (/^["'#\{\[\|>&!*%?@`-]/.test(value)) return true;
36
+ // Contains colon (could be mistaken for mapping)
37
+ if (value.includes(":")) return true;
38
+ // Contains # (inline comment marker) anywhere
39
+ if (value.includes("#")) return true;
40
+ // Contains newline
41
+ if (value.includes("\n")) return true;
42
+ // Leading/trailing whitespace
43
+ if (value !== value.trim()) return true;
44
+ // YAML booleans/nulls
45
+ if (/^(true|false|yes|no|on|off|null|True|False|Yes|No|On|Off|NULL|Null)$/i.test(value)) return true;
46
+ return false;
47
+ }
48
+
49
+ function quoteYamlValue(value: string): string {
50
+ if (!needsYamlQuoting(value)) return value;
51
+ // Escape backslashes and double quotes, wrap in double quotes
52
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
53
+ return `"${escaped}"`;
54
+ }
55
+
56
+ /**
57
+ * Serialize an AgentConfig back to a valid .md file with frontmatter.
58
+ * Produces output that parseFrontmatter can re-parse faithfully.
59
+ */
60
+ export function serializeAgent(config: AgentConfig): string {
61
+ const lines: string[] = ["---"];
62
+
63
+ // Required fields
64
+ lines.push(`name: ${quoteYamlValue(config.name)}`);
65
+ lines.push(`description: ${quoteYamlValue(config.description)}`);
66
+
67
+ // Optional known fields
68
+ if (config.tools && config.tools.length > 0) {
69
+ lines.push(`tools: ${quoteYamlValue(config.tools.join(", "))}`);
70
+ }
71
+ if (config.model) {
72
+ lines.push(`model: ${quoteYamlValue(config.model)}`);
73
+ }
74
+ if (config.thinking) {
75
+ lines.push(`thinking: ${quoteYamlValue(config.thinking)}`);
76
+ }
77
+
78
+ // Extra fields (sorted alphabetically)
79
+ if (config.extraFields) {
80
+ for (const key of Object.keys(config.extraFields).sort()) {
81
+ const value = config.extraFields[key]!;
82
+ if (typeof value === "string") {
83
+ lines.push(`${key}: ${quoteYamlValue(value)}`);
84
+ } else if (Array.isArray(value)) {
85
+ // Serialize array as YAML block sequence
86
+ lines.push(`${key}:`);
87
+ for (const item of value) {
88
+ lines.push(` - ${quoteYamlValue(String(item))}`);
89
+ }
90
+ } else {
91
+ // Fallback: JSON stringify for objects
92
+ lines.push(`${key}: ${JSON.stringify(value)}`);
93
+ }
94
+ }
95
+ }
96
+
97
+ lines.push("---");
98
+ lines.push("");
99
+ lines.push(config.systemPrompt);
100
+ lines.push("");
101
+
102
+ return lines.join("\n");
103
+ }
package/src/handlers.ts CHANGED
@@ -19,11 +19,14 @@ export function extractArgsPreview(args: unknown): string {
19
19
  if (args && typeof args === "object" && !Array.isArray(args)) {
20
20
  const obj = args as Record<string, unknown>;
21
21
  const keys = Object.keys(obj);
22
- // Single-key object: just show the value
22
+ // Single-key object: show the value directly (or serialize if complex)
23
23
  if (keys.length === 1) {
24
24
  const v = obj[keys[0]!];
25
25
  if (typeof v === "string") return v.slice(0, ARGS_PREVIEW_MAX);
26
26
  if (typeof v === "number" || typeof v === "boolean") return String(v);
27
+ // Complex value (array/nested object) — serialize just this value
28
+ const serialized = JSON.stringify(v);
29
+ if (serialized) return serialized.slice(0, ARGS_PREVIEW_MAX);
27
30
  }
28
31
  // Multi-key: find the longest string value (likely the main payload)
29
32
  let best: string | undefined;
@@ -104,7 +107,7 @@ export function handleMessageEnd(state: StreamState, event: JsonEvent): void {
104
107
  state.model = message.model as string;
105
108
  }
106
109
 
107
- // Collect text output
110
+ // Collect text + thinking output
108
111
  const content = message.content as Array<Record<string, unknown>> | string | undefined;
109
112
  if (typeof content === "string" && content.trim()) {
110
113
  state.accumulatedOutput.push(content);
@@ -117,6 +120,11 @@ export function handleMessageEnd(state: StreamState, event: JsonEvent): void {
117
120
  state.accumulatedOutput.push(text);
118
121
  const lines = text.split("\n").filter((l) => l.trim());
119
122
  state.recentOutput.push(...lines.slice(-10));
123
+ } else if (part.type === "thinking" && (part.thinking as string)?.trim()) {
124
+ const thinking = part.thinking as string;
125
+ state.accumulatedOutput.push(`[thinking] ${thinking.trim()}`);
126
+ const lines = thinking.split("\n").filter((l) => l.trim());
127
+ state.recentOutput.push(...lines.slice(-5).map((l) => `[thinking] ${l}`));
120
128
  }
121
129
  }
122
130
  }
@@ -139,26 +147,28 @@ export function handleMessageEnd(state: StreamState, event: JsonEvent): void {
139
147
  }
140
148
 
141
149
  /**
142
- * Handle an agent_end event — collect all assistant text for final output.
150
+ * Handle an agent_end event — extract final output from the last assistant message.
143
151
  */
144
152
  export function handleAgentEnd(state: StreamState, event: JsonEvent): void {
145
153
  const messages = event.messages as Array<Record<string, unknown>> | undefined;
146
154
  if (!messages || messages.length === 0) return;
147
155
 
156
+ // Use only the last assistant message for final output
157
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
158
+ if (!lastAssistant) return;
159
+
148
160
  const allText: string[] = [];
149
- for (const msg of messages) {
150
- if (msg.role === "assistant") {
151
- const content = msg.content as Array<Record<string, unknown>> | string | undefined;
152
- if (typeof content === "string" && content.trim()) {
153
- allText.push(content);
154
- } else if (Array.isArray(content)) {
155
- for (const part of content) {
156
- if (part.type === "text" && (part.text as string)?.trim()) {
157
- allText.push(part.text as string);
158
- }
159
- }
161
+ const content = lastAssistant.content as Array<Record<string, unknown>> | string | undefined;
162
+ if (typeof content === "string" && content.trim()) {
163
+ allText.push(content);
164
+ } else if (Array.isArray(content)) {
165
+ for (const part of content) {
166
+ if (part.type === "text" && (part.text as string)?.trim()) {
167
+ allText.push(part.text as string);
168
+ } else if (part.type === "thinking" && (part.thinking as string)?.trim()) {
169
+ allText.push(`[thinking] ${(part.thinking as string).trim()}`);
160
170
  }
161
171
  }
162
172
  }
163
- state.finalOutput = allText.join("\n\n");
173
+ state.finalOutput = allText.length > 0 ? allText.join("\n\n") : undefined;
164
174
  }
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text, TUI } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import { executeSubagent, executeParallel } from "./execute.js";
5
5
  import { renderSubagentResult } from "./render.js";
6
- import { discoverAgents, findAgent } from "./agents.js";
6
+ import { discoverAgents, discoverAgentsAll, findAgent } from "./agents.js";
7
+ import { createAgentManager } from "./agent-manager.js";
7
8
  import type {
8
9
  SubagentDetails,
9
10
  SubagentProgress,
@@ -96,7 +97,9 @@ export function registerSubagent(pi: ExtensionAPI): void {
96
97
  agent: t.agent ?? undefined,
97
98
  agentConfig: t.agent ? findAgent(agents, t.agent) : undefined,
98
99
  task: t.task,
99
- model: t.model ?? undefined,
100
+ // Fall back to the parent's currently-selected model when the caller
101
+ // (and the agent frontmatter) didn't specify one.
102
+ model: t.model ?? ctx.model?.id,
100
103
  cwd: t.cwd ?? undefined,
101
104
  })),
102
105
  signal: signal ?? undefined,
@@ -141,7 +144,9 @@ export function registerSubagent(pi: ExtensionAPI): void {
141
144
  agent: params.agent ?? undefined,
142
145
  agentConfig,
143
146
  task: params.task,
144
- model: params.model ?? undefined,
147
+ // Fall back to the parent's currently-selected model when the caller
148
+ // (and the agent frontmatter) didn't specify one.
149
+ model: params.model ?? ctx.model?.id,
145
150
  cwd: params.cwd ?? undefined,
146
151
  signal: signal ?? undefined,
147
152
  onUpdate: (progress: SubagentProgress) => {
@@ -255,6 +260,35 @@ function formatResultsSummary(results: SubagentResult[]): string {
255
260
  return lines.join("\n");
256
261
  }
257
262
 
263
+ // ── Command registration ────────────────────────────────────────────────────
264
+
265
+ export function registerAgentsCommand(pi: ExtensionAPI): void {
266
+ pi.registerCommand("agents", {
267
+ description: "Open the Agents Manager",
268
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
269
+ const { user, project, userDir, projectDir } = discoverAgentsAll(ctx.cwd);
270
+
271
+ const availableModels = ctx.modelRegistry.getAvailable().map((m) => ({
272
+ id: m.id,
273
+ provider: m.provider,
274
+ fullId: `${m.provider}/${m.id}`,
275
+ }));
276
+
277
+ const availableTools = pi.getAllTools().map((t) => ({
278
+ name: t.name,
279
+ description: t.description ?? "",
280
+ }));
281
+
282
+ await ctx.ui.custom<void>(
283
+ (tui: TUI, theme: Theme, _keybindings, done: () => void) => {
284
+ return createAgentManager(user, project, userDir, projectDir, tui, theme, done, availableModels, availableTools);
285
+ },
286
+ { overlay: true, overlayOptions: { anchor: "center", width: 84, maxHeight: "80%" } },
287
+ );
288
+ },
289
+ });
290
+ }
291
+
258
292
  // ── Default export ──────────────────────────────────────────────────────────
259
293
 
260
294
  export default function (pi: ExtensionAPI): void {
package/src/spawn.ts CHANGED
@@ -1,10 +1,21 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync, writeFileSync, unlinkSync, mkdtempSync, rmdirSync } from "node:fs";
3
+ import { existsSync, writeFileSync, unlinkSync, mkdtempSync, rmdirSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import type { AgentConfig } from "./agents.js";
7
7
 
8
+ // Track all temp dirs for cleanup on process exit (graceful shutdown only).
9
+ // Note: SIGKILL/OOM cannot be caught — the exit handler only fires for
10
+ // normal exits and catchable signals (SIGTERM, SIGINT, SIGHUP, etc.).
11
+ const tempDirs = new Set<string>();
12
+
13
+ process.on("exit", () => {
14
+ for (const dir of tempDirs) {
15
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
16
+ }
17
+ });
18
+
8
19
  export interface SpawnOptions {
9
20
  task: string;
10
21
  model: string | undefined;
@@ -42,6 +53,7 @@ export function resolvePiCommand(): { command: string; args: string[] } {
42
53
  function writePromptToFile(agentName: string, prompt: string): { dir: string; filePath: string } {
43
54
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
44
55
  const tmpDir = mkdtempSync(join(tmpdir(), "pi-subagent-"));
56
+ tempDirs.add(tmpDir);
45
57
  const filePath = join(tmpDir, `prompt-${safeName}.md`);
46
58
  writeFileSync(filePath, prompt, { encoding: "utf-8" });
47
59
  return { dir: tmpDir, filePath };
@@ -55,7 +67,10 @@ function cleanupTempFiles(dir: string | null, filePath: string | null): void {
55
67
  try { unlinkSync(filePath); } catch { /* ignore */ }
56
68
  }
57
69
  if (dir) {
58
- try { rmdirSync(dir); } catch { /* ignore */ }
70
+ try {
71
+ rmdirSync(dir);
72
+ tempDirs.delete(dir);
73
+ } catch { /* leave in Set so exit handler can retry with rmSync */ }
59
74
  }
60
75
  }
61
76
 
@@ -86,7 +101,8 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
86
101
  args.push("--tools", agent.tools.join(","));
87
102
  }
88
103
  } else if (options.model) {
89
- // Fallback: use model from tool call params (legacy)
104
+ // Fallback: tool-call model (which itself falls back to the parent's
105
+ // currently-selected model via ctx.model?.id at the call site).
90
106
  args.push("--model", options.model);
91
107
  }
92
108