@pi-archimedes/subagent 1.8.2 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "1.8.2",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/core": "1.8.2"
14
+ "@pi-archimedes/core": "1.9.0"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "@earendil-works/pi-ai": ">=0.1.0",
@@ -14,6 +14,13 @@ import {
14
14
  import type { AgentConfig } from "./agents.js";
15
15
  import { discoverAgentsAll } from "./agents.js";
16
16
  import { serializeAgent, validateAgentName } from "./frontmatter-io.js";
17
+ import {
18
+ writeLocalModel,
19
+ deleteLocalModel,
20
+ readLocalConfig,
21
+ setLocalConfig,
22
+ type LocalConfig,
23
+ } from "./local-config.js";
17
24
 
18
25
  // ── Screen constants ────────────────────────────────────────────────────────
19
26
 
@@ -1204,7 +1211,7 @@ function setFieldValue(agent: AgentConfig, field: EditField, value: string): voi
1204
1211
 
1205
1212
  // ── Save logic ──────────────────────────────────────────────────────────────
1206
1213
 
1207
- function saveAgent(state: ManagerState, requestRender: () => void): void {
1214
+ export function saveAgent(state: ManagerState, requestRender: () => void): void {
1208
1215
  if (!state.editAgent) return;
1209
1216
 
1210
1217
  const agent = state.editAgent;
@@ -1241,18 +1248,77 @@ function saveAgent(state: ManagerState, requestRender: () => void): void {
1241
1248
  const newName = agent.name.endsWith(".md") ? agent.name : `${agent.name}.md`;
1242
1249
  const newPath = path.join(dir, newName);
1243
1250
 
1251
+ // Capture model before entering the try block so it is available in the
1252
+ // catch block for .md rollback if a later step (JSON write, etc.) fails.
1253
+ const model = agent.model;
1254
+
1255
+ // Track whether the .md write succeeded so the catch block knows whether
1256
+ // to restore or clean up the on-disk file.
1257
+ const isRename = oldPath && oldPath !== newPath;
1258
+ let originalContent: string | undefined;
1259
+ if (!isRename && fs.existsSync(newPath)) {
1260
+ // Read existing .md content so we can restore it verbatim if a later
1261
+ // step (JSON write, re-discovery, etc.) fails.
1262
+ originalContent = fs.readFileSync(newPath, "utf-8");
1263
+ }
1264
+ let mdWritten = false;
1265
+ // Track whether the old .md file was already removed during a rename so
1266
+ // the catch block knows whether newPath is the sole surviving copy.
1267
+ let oldPathDeleted = false;
1268
+ // Track whether the JSON config write succeeded so the catch block can
1269
+ // roll it back if a later step (re-discovery, etc.) fails.
1270
+ let jsonWritten = false;
1271
+ // Snapshot of the JSON config captured before the write so the catch
1272
+ // block can restore it. Declared here (not inside try) so it is
1273
+ // accessible in the catch block.
1274
+ let jsonConfigBefore: LocalConfig = {};
1275
+
1244
1276
  try {
1245
1277
  // Ensure directory exists
1246
1278
  fs.mkdirSync(dir, { recursive: true });
1247
1279
 
1248
- // Serialize and write
1249
- const content = serializeAgent(agent);
1280
+ // Build a shallow copy without the model for .md serialization so the
1281
+ // live edit object is NOT mutated during serialization. If the .md
1282
+ // write fails below, the live object stays intact for a retry.
1283
+ const mdAgent = { ...agent };
1284
+ delete mdAgent.model;
1285
+
1286
+ // Serialize and write the .md file FIRST. If this fails, no JSON state
1287
+ // is persisted and the live edit object is untouched.
1288
+ const content = serializeAgent(mdAgent);
1250
1289
  fs.writeFileSync(newPath, content, "utf-8");
1290
+ mdWritten = true;
1291
+
1292
+ // Only after the .md write succeeds, perform JSON store mutations.
1293
+ // Capture a backup of the current JSON config so we can roll it back
1294
+ // if a later step (re-discovery, etc.) fails after this write succeeds.
1295
+ jsonConfigBefore = readLocalConfig();
1296
+ // Write/remove the NEW name entry first, then clean up the OLD name.
1297
+ if (model !== undefined) {
1298
+ writeLocalModel(agent.name, model);
1299
+ } else {
1300
+ deleteLocalModel(agent.name);
1301
+ }
1302
+ jsonWritten = true;
1303
+
1304
+ // Handle rename: delete old JSON entry keyed by original name (after
1305
+ // the new entry is safely written). Wrapped in try-catch so a failure
1306
+ // here does not leave the .md written but the live object un-stripped.
1307
+ const originalName = state.editOriginal?.name;
1308
+ if (originalName && originalName !== agent.name) {
1309
+ try {
1310
+ deleteLocalModel(originalName);
1311
+ } catch {
1312
+ // Best-effort: stale entry is harmless and will be cleaned up on
1313
+ // a subsequent save/rename
1314
+ }
1315
+ }
1251
1316
 
1252
- // Handle rename if name changed
1317
+ // Handle rename: delete old .md file if name changed
1253
1318
  if (oldPath && oldPath !== newPath) {
1254
1319
  try {
1255
1320
  fs.unlinkSync(oldPath);
1321
+ oldPathDeleted = true;
1256
1322
  } catch {
1257
1323
  // Old file may not exist (e.g., new agent)
1258
1324
  }
@@ -1281,7 +1347,52 @@ function saveAgent(state: ManagerState, requestRender: () => void): void {
1281
1347
  state.editDirty = false;
1282
1348
  state.editError = null;
1283
1349
  requestRender();
1350
+
1351
+ // Only strip model from the live edit object AFTER all operations
1352
+ // (including re-discovery) have succeeded. This ensures that if any
1353
+ // step fails, the catch block can restore the model to .md and the
1354
+ // live object retains it for a safe retry.
1355
+ delete agent.model;
1284
1356
  } catch (err) {
1357
+ // Restore prior .md state if the write succeeded but a later step
1358
+ // (JSON write, re-discovery, etc.) failed:
1359
+ // - rename (old file not yet unlinked): check whether the old file
1360
+ // still exists. If so, delete newPath so only the original remains.
1361
+ // If the old file is gone (deleted externally or by a prior attempt),
1362
+ // newPath may be the sole copy — keep it with a model fallback, or
1363
+ // delete it when there is no model to fall back on.
1364
+ // - existing file: write the original content back verbatim.
1365
+ // - new file with a model: keep a frontmatter fallback so the model
1366
+ // override survives for the next retry.
1367
+ // If the old .md was already unlinked during rename (oldPathDeleted),
1368
+ // newPath is the sole surviving copy — leave it in place.
1369
+ // If the .md write itself failed (mdWritten is false) there is nothing
1370
+ // to restore on disk.
1371
+ if (mdWritten) {
1372
+ if (isRename && !oldPathDeleted) {
1373
+ if (oldPath && fs.existsSync(oldPath)) {
1374
+ // Old file still exists — safe to delete newPath and restore prior state
1375
+ try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
1376
+ } else if (model !== undefined) {
1377
+ // Old file is gone — keep newPath with model as fallback
1378
+ try { fs.writeFileSync(newPath, serializeAgent({ ...agent, model }), "utf-8"); } catch { /* best-effort */ }
1379
+ } else {
1380
+ // Old file is gone and no model — delete newPath (no prior state to restore)
1381
+ try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
1382
+ }
1383
+ } else if (originalContent !== undefined) {
1384
+ try { fs.writeFileSync(newPath, originalContent, "utf-8"); } catch { /* best-effort */ }
1385
+ } else if (model !== undefined) {
1386
+ try { fs.writeFileSync(newPath, serializeAgent({ ...agent, model }), "utf-8"); } catch { /* best-effort */ }
1387
+ } else {
1388
+ // New file without model — delete it (no prior state to restore)
1389
+ try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
1390
+ }
1391
+ }
1392
+ // Roll back JSON if it was written but a later step failed
1393
+ if (jsonWritten) {
1394
+ try { setLocalConfig(jsonConfigBefore); } catch { /* best-effort */ }
1395
+ }
1285
1396
  state.editError = err instanceof Error ? err.message : "Failed to save agent";
1286
1397
  requestRender();
1287
1398
  }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import type { AgentConfig } from "./agents.js";
6
+ import { applyLocalOverrides, formatAgentList } from "./agents.js";
7
+
8
+ // Redirect getAgentDir() to a temp directory via PI_CODING_AGENT_DIR.
9
+ // This must happen before any function calls so readLocalConfig()
10
+ // resolves agents.local.json to our sandbox directory.
11
+ const testDir = join(tmpdir(), "pi-test-local-config-agents");
12
+ process.env.PI_CODING_AGENT_DIR = testDir;
13
+
14
+ function makeAgent(
15
+ name: string,
16
+ model?: string,
17
+ ): AgentConfig {
18
+ return {
19
+ name,
20
+ description: `Agent ${name}`,
21
+ systemPrompt: "hello",
22
+ source: "global" as const,
23
+ filePath: join(testDir, `${name}.md`),
24
+ ...(model !== undefined ? { model } : {}),
25
+ };
26
+ }
27
+
28
+ describe("applyLocalOverrides", () => {
29
+ beforeEach(() => {
30
+ mkdirSync(testDir, { recursive: true });
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(testDir, { recursive: true, force: true });
35
+ });
36
+
37
+ it("sets model from JSON override", () => {
38
+ const path = join(testDir, "agents.local.json");
39
+ writeFileSync(path, JSON.stringify({ codex: { model: "o1" } }), "utf-8");
40
+
41
+ const agent = makeAgent("codex");
42
+ applyLocalOverrides([agent]);
43
+ expect(agent.model).toBe("o1");
44
+ });
45
+
46
+ it("leaves model unchanged when no JSON entry exists", () => {
47
+ const agent = makeAgent("codex", "M1");
48
+ applyLocalOverrides([agent]);
49
+ expect(agent.model).toBe("M1");
50
+ });
51
+
52
+ it("leaves model unchanged when JSON entry has no model field", () => {
53
+ const path = join(testDir, "agents.local.json");
54
+ writeFileSync(path, JSON.stringify({ codex: {} }), "utf-8");
55
+
56
+ const agent = makeAgent("codex", "M1");
57
+ applyLocalOverrides([agent]);
58
+ expect(agent.model).toBe("M1");
59
+ });
60
+
61
+ it("handles empty agent list", () => {
62
+ expect(() => applyLocalOverrides([])).not.toThrow();
63
+ });
64
+
65
+ it("works with corrupt JSON file (agent model stays unchanged)", () => {
66
+ const path = join(testDir, "agents.local.json");
67
+ writeFileSync(path, "{ broken json", "utf-8");
68
+
69
+ const agent = makeAgent("codex", "M1");
70
+ applyLocalOverrides([agent]);
71
+ expect(agent.model).toBe("M1");
72
+ });
73
+ });
74
+
75
+ function mkAgent(overrides: Partial<AgentConfig> & Pick<AgentConfig, "name">): AgentConfig {
76
+ return {
77
+ name: overrides.name,
78
+ description: overrides.description ?? "desc",
79
+ systemPrompt: overrides.systemPrompt ?? "prompt",
80
+ source: overrides.source ?? "user",
81
+ filePath: overrides.filePath ?? "/x.md",
82
+ ...(overrides.model !== undefined ? { model: overrides.model } : {}),
83
+ ...(overrides.tools !== undefined ? { tools: overrides.tools } : {}),
84
+ };
85
+ }
86
+
87
+ describe("formatAgentList", () => {
88
+ it("reports no agents when empty", () => {
89
+ expect(formatAgentList([])).toContain("No agents configured");
90
+ });
91
+
92
+ it("formats a single agent (singular)", () => {
93
+ const out = formatAgentList([mkAgent({ name: "general" })]);
94
+ expect(out).toContain("1 agent:");
95
+ expect(out).toContain("• general [user] — desc");
96
+ });
97
+
98
+ it("formats multiple agents (plural)", () => {
99
+ const out = formatAgentList([mkAgent({ name: "general" }), mkAgent({ name: "explore" })]);
100
+ expect(out).toContain("2 agents:");
101
+ });
102
+
103
+ it("includes model and tools overrides only when set", () => {
104
+ const withExtras = formatAgentList([mkAgent({ name: "reviewer", model: "anthropic/claude-sonnet-4-5", tools: ["read", "bash"] })]);
105
+ expect(withExtras).toContain("(model: anthropic/claude-sonnet-4-5, 2 tools)");
106
+ const withoutExtras = formatAgentList([mkAgent({ name: "general" })]);
107
+ expect(withoutExtras).not.toMatch(/\(model:|tools\)/);
108
+ });
109
+ });
package/src/agents.ts CHANGED
@@ -7,6 +7,7 @@ import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
+ import { readLocalConfig } from "./local-config.js";
10
11
 
11
12
  export interface AgentConfig {
12
13
  name: string;
@@ -136,6 +137,20 @@ export interface AgentsDiscoveryResult {
136
137
  projectDir: string | null; // e.g., .pi/agents or null if not found
137
138
  }
138
139
 
140
+ /**
141
+ * Apply local model overrides from agents.local.json to a list of agents.
142
+ * Reads the config once and mutates matching agents in place.
143
+ */
144
+ export function applyLocalOverrides(agents: AgentConfig[]): void {
145
+ const config = readLocalConfig();
146
+ for (const agent of agents) {
147
+ const local = config[agent.name];
148
+ if (local?.model !== undefined) {
149
+ agent.model = local.model;
150
+ }
151
+ }
152
+ }
153
+
139
154
  /**
140
155
  * Discover all available agents from global, user and/or project directories.
141
156
  * Precedence (highest last): global < user < project
@@ -155,7 +170,9 @@ export function discoverAgents(cwd: string): AgentConfig[] {
155
170
  for (const agent of userAgents) agentMap.set(agent.name, agent);
156
171
  for (const agent of projectAgents) agentMap.set(agent.name, agent);
157
172
 
158
- return Array.from(agentMap.values());
173
+ const all = Array.from(agentMap.values());
174
+ applyLocalOverrides(all);
175
+ return all;
159
176
  }
160
177
 
161
178
  /**
@@ -170,6 +187,10 @@ export function discoverAgentsAll(cwd: string): AgentsDiscoveryResult {
170
187
  const userAgents = loadAgentsFromDir(userDir, "user");
171
188
  const projectAgents = projectDir ? loadAgentsFromDir(projectDir, "project") : [];
172
189
 
190
+ applyLocalOverrides(globalAgents);
191
+ applyLocalOverrides(userAgents);
192
+ applyLocalOverrides(projectAgents);
193
+
173
194
  return {
174
195
  global: globalAgents,
175
196
  user: userAgents,
@@ -186,3 +207,23 @@ export function discoverAgentsAll(cwd: string): AgentsDiscoveryResult {
186
207
  export function findAgent(agents: AgentConfig[], name: string): AgentConfig | undefined {
187
208
  return agents.find((a) => a.name === name);
188
209
  }
210
+
211
+ /**
212
+ * Format discovered agents as a compact, readable listing for the `list_agents`
213
+ * tool. Standard detail: name, source, description, and model/tools overrides
214
+ * only when set.
215
+ */
216
+ export function formatAgentList(agents: AgentConfig[]): string {
217
+ if (agents.length === 0) {
218
+ return "No agents configured. Create one in ~/.pi/agent/agents/ or .agents/agents/.";
219
+ }
220
+ const lines = agents.map((a) => {
221
+ let line = `• ${a.name} [${a.source}] — ${a.description}`;
222
+ const extras: string[] = [];
223
+ if (a.model) extras.push(`model: ${a.model}`);
224
+ if (a.tools && a.tools.length > 0) extras.push(`${a.tools.length} tools`);
225
+ if (extras.length > 0) line += ` (${extras.join(", ")})`;
226
+ return line;
227
+ });
228
+ return `${agents.length} agent${agents.length === 1 ? "" : "s"}:\n${lines.join("\n")}`;
229
+ }
package/src/compact.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Text } from "@earendil-works/pi-tui";
2
- import type { SubagentDetails, SubagentProgress, SubagentResult, SubagentToolResult } from "./types.js";
2
+ import type { SubagentDetails, SubagentProgress, SubagentResult, SubagentToolCall, SubagentToolResult } from "./types.js";
3
3
  import { formatTokens, formatDuration, formatCost, truncLine, buildStatsLine, buildAgentLabel } from "./format.js";
4
4
 
5
5
  type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
@@ -14,7 +14,7 @@ interface ActivityData {
14
14
  finalOutput: string | undefined;
15
15
  status: "running" | "completed" | "failed" | undefined;
16
16
  error: string | undefined;
17
- toolCalls?: string[] | undefined;
17
+ toolCalls?: (SubagentToolCall | string)[] | undefined;
18
18
  }
19
19
 
20
20
  export function buildActivityLine(
@@ -35,7 +35,7 @@ export function buildActivityLine(
35
35
  return theme.fg("error", "✗ Failed");
36
36
  }
37
37
 
38
- // Running: show the current tool with live duration
38
+ // Running: show the current tool with live duration (grey while running)
39
39
  if (data.currentTool) {
40
40
  const arrow = theme.fg("muted", "↳ ");
41
41
  const argsPreview = data.currentToolArgs
@@ -44,7 +44,7 @@ export function buildActivityLine(
44
44
  const durationPart = data.currentToolStartedAt
45
45
  ? " | " + formatDuration(Date.now() - data.currentToolStartedAt)
46
46
  : "";
47
- let line = theme.fg("syntaxFunction", data.currentTool);
47
+ let line = theme.fg("muted", data.currentTool);
48
48
  if (argsPreview) {
49
49
  line += theme.fg("dim", ": " + argsPreview);
50
50
  }
@@ -55,9 +55,21 @@ export function buildActivityLine(
55
55
  }
56
56
 
57
57
  // Running, no active tool: show the most recently completed tool call
58
+ // Color only the tool name green (success) or red (error)
58
59
  if (data.toolCalls && data.toolCalls.length > 0) {
59
60
  const lastCall = data.toolCalls[data.toolCalls.length - 1];
60
- if (lastCall) return theme.fg("muted", "↳ " + lastCall);
61
+ if (lastCall) {
62
+ if (typeof lastCall === "string") {
63
+ return theme.fg("dim", "↳ " + truncLine(lastCall, 60));
64
+ }
65
+ const color = lastCall.error ? "error" : "success";
66
+ const arrow = theme.fg("muted", "↳ ");
67
+ const name = theme.fg(color, lastCall.name);
68
+ const argsPart = lastCall.argsPreview
69
+ ? theme.fg("dim", ": " + truncLine(lastCall.argsPreview, 60))
70
+ : "";
71
+ return arrow + name + argsPart;
72
+ }
61
73
  }
62
74
 
63
75
  // Running, no tool history: show first line of streamed output if any
package/src/expanded.ts CHANGED
@@ -1,9 +1,25 @@
1
1
  import { Text } from "@earendil-works/pi-tui";
2
- import type { SubagentDetails, SubagentProgress, SubagentResult } from "./types.js";
2
+ import type { SubagentDetails, SubagentProgress, SubagentResult, SubagentToolCall } from "./types.js";
3
3
  import { formatTokens, formatDuration, truncLine, buildStatsLine, buildAgentLabel } from "./format.js";
4
4
 
5
5
  type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
6
6
 
7
+ // ── Helpers ─────────────────────────────────────────────────────────────────
8
+
9
+ function formatToolCall(call: SubagentToolCall | string, theme: Theme): string {
10
+ // Backward compat: old persisted sessions may have toolCalls as string[]
11
+ if (typeof call === "string") {
12
+ return theme.fg("dim", "↳ " + call);
13
+ }
14
+ const arrow = theme.fg("muted", "↳ ");
15
+ const color = call.error ? "error" : "success";
16
+ const name = theme.fg(color, call.name);
17
+ const argsPart = call.argsPreview
18
+ ? theme.fg("dim", ": " + call.argsPreview)
19
+ : "";
20
+ return arrow + name + argsPart;
21
+ }
22
+
7
23
  // ── Expanded completed result ───────────────────────────────────────────────
8
24
 
9
25
  export function buildExpandedText(
@@ -43,7 +59,7 @@ export function buildExpandedText(
43
59
  if (toolCalls && toolCalls.length > 0) {
44
60
  lines.push("");
45
61
  for (const call of toolCalls) {
46
- lines.push(theme.fg("dim", "↳ " + call));
62
+ lines.push(formatToolCall(call, theme));
47
63
  }
48
64
  }
49
65
 
@@ -116,7 +132,7 @@ export function renderProgressExpanded(
116
132
  if (progress.toolCalls && progress.toolCalls.length > 0) {
117
133
  lines.push("");
118
134
  for (const call of progress.toolCalls) {
119
- lines.push(theme.fg("dim", "↳ " + call));
135
+ lines.push(formatToolCall(call, theme));
120
136
  }
121
137
  }
122
138
 
@@ -129,7 +145,7 @@ export function renderProgressExpanded(
129
145
  const durationPart = progress.currentToolStartedAt
130
146
  ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
131
147
  : "";
132
- let line = theme.fg("syntaxFunction", progress.currentTool);
148
+ let line = theme.fg("muted", progress.currentTool);
133
149
  if (argsPreview) {
134
150
  line += theme.fg("dim", ": " + argsPreview);
135
151
  }
@@ -192,7 +208,7 @@ export function buildProgressExpandedText(
192
208
  if (progress.toolCalls && progress.toolCalls.length > 0) {
193
209
  lines.push("");
194
210
  for (const call of progress.toolCalls) {
195
- lines.push(theme.fg("dim", "↳ " + call));
211
+ lines.push(formatToolCall(call, theme));
196
212
  }
197
213
  }
198
214
 
@@ -204,7 +220,7 @@ export function buildProgressExpandedText(
204
220
  const durationPart = progress.currentToolStartedAt
205
221
  ? " | " + formatDuration(Date.now() - progress.currentToolStartedAt)
206
222
  : "";
207
- let line = theme.fg("syntaxFunction", progress.currentTool);
223
+ let line = theme.fg("muted", progress.currentTool);
208
224
  if (argsPreview) {
209
225
  line += theme.fg("dim", ": " + argsPreview);
210
226
  }
package/src/handlers.ts CHANGED
@@ -50,11 +50,16 @@ export function extractArgsPreview(args: unknown): string {
50
50
  export function handleToolStart(state: StreamState, event: JsonEvent): void {
51
51
  state.toolCount++;
52
52
  state.currentTool = event.toolName as string;
53
- state.currentToolArgs = JSON.stringify(event.args);
53
+ // Use extractArgsPreview instead of JSON.stringify for readable display
54
+ const argsPreview = extractArgsPreview(event.args);
55
+ state.currentToolArgs = argsPreview;
54
56
  state.currentToolStartedAt = Date.now();
55
57
  // Record tool call with args preview
56
- const argsPreview = extractArgsPreview(event.args);
57
- state.toolCalls.push(`${state.currentTool}: ${argsPreview}`);
58
+ state.toolCalls.push({
59
+ name: state.currentTool,
60
+ argsPreview,
61
+ error: false,
62
+ });
58
63
  if (state.toolCalls.length > TOOL_CALLS_MAX) {
59
64
  state.toolCalls.splice(0, state.toolCalls.length - TOOL_CALLS_MAX);
60
65
  }
@@ -75,6 +80,14 @@ export function handleToolEnd(state: StreamState): void {
75
80
  */
76
81
  export function handleToolResult(state: StreamState, event: JsonEvent): void {
77
82
  const result = event.result as Record<string, unknown> | undefined;
83
+
84
+ // Mark the last tool call as errored if the result indicates an error
85
+ const lastCall = state.toolCalls[state.toolCalls.length - 1];
86
+ const isError = event.isError === true || result?.isError === true;
87
+ if (lastCall && lastCall.name === (event.toolName as string) && isError) {
88
+ lastCall.error = true;
89
+ }
90
+
78
91
  if (!result) return;
79
92
 
80
93
  const toolName = (event.toolName as string) ?? "tool";
package/src/index.ts CHANGED
@@ -3,7 +3,8 @@ import { Text, TUI } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  // execute.js + agent-manager.js lazy-loaded below to keep subagent tool registration fast
5
5
  import { renderSubagentResult } from "./render.js";
6
- import { discoverAgents, discoverAgentsAll, findAgent } from "./agents.js";
6
+ import { discoverAgents, discoverAgentsAll, findAgent, formatAgentList } from "./agents.js";
7
+ import { validateModel, firstError } from "./model-validation.js";
7
8
  import type {
8
9
  SubagentDetails,
9
10
  SubagentProgress,
@@ -14,7 +15,9 @@ import type {
14
15
  // ── JSON Schema for tool parameters (TypeBox) ──────────────────────────────
15
16
 
16
17
  const TaskItem = Type.Object({
17
- agent: Type.Optional(Type.String()),
18
+ agent: Type.Optional(Type.String({
19
+ description: "Agent name for this task (optional). If omitted, runs config-less.",
20
+ })),
18
21
  task: Type.String(),
19
22
  model: Type.Optional(Type.String()),
20
23
  cwd: Type.Optional(Type.String()),
@@ -22,7 +25,7 @@ const TaskItem = Type.Object({
22
25
 
23
26
  const SUBAGENT_PARAMS_SCHEMA = Type.Object({
24
27
  agent: Type.Optional(Type.String({
25
- description: "Agent name/identifier (optional, defaults to 'general')",
28
+ description: "Agent name (optional). If omitted, the subagent runs config-less — parent's current model, all tools, no system-prompt override. Call list_agents to see available agents.",
26
29
  })),
27
30
  task: Type.Optional(Type.String({
28
31
  description: "Task description for the subagent. Required when not using 'tasks' array.",
@@ -55,7 +58,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
55
58
  name: "subagent",
56
59
  label: "Subagent",
57
60
  description:
58
- "Delegate tasks to subagents. Provide either 'task' (single) or 'tasks' (parallel). Never omit both. Options: agent, model, cwd.",
61
+ "Delegate tasks to subagents. Provide either 'task' (single) or 'tasks' (parallel). Agent is optional — omit for a config-less run with the parent's model and all tools. Model override is rarely needed; the agent config or parent model is used by default.",
59
62
  parameters: SUBAGENT_PARAMS_SCHEMA,
60
63
 
61
64
  async execute(
@@ -78,12 +81,34 @@ export function registerSubagent(pi: ExtensionAPI): void {
78
81
 
79
82
  // Parallel mode
80
83
  if (params.tasks && params.tasks.length > 0) {
81
- const missingAgents = params.tasks.filter((t) => t.agent && !findAgent(agents, t.agent));
82
- if (missingAgents.length > 0) {
84
+ // Combined pre-spawn checks for parallel mode: unknown agents + invalid
85
+ // models. If ANY task is invalid, abort the whole batch with a single
86
+ // tool result listing all errors (no tasks spawn).
87
+ const errors: string[] = [];
88
+ const unknownAgents = params.tasks
89
+ .filter((t) => t.agent && !findAgent(agents, t.agent!))
90
+ .map((t) => `"${t.agent}"`);
91
+ if (unknownAgents.length > 0) {
83
92
  const available = agents.map((a) => a.name).join(", ") || "none";
84
- const unknown = missingAgents.map((t) => `"${t.agent}"`).join(", ");
93
+ errors.push(`Unknown agent(s): ${unknownAgents.join(", ")}. Available: ${available}. Call list_agents for details.`);
94
+ }
95
+ const unknownAgentSet = new Set(unknownAgents.map((n) => n.replace(/"/g, '')));
96
+ for (const t of params.tasks) {
97
+ // Skip model validation for tasks already caught by unknown-agent check
98
+ if (t.agent && unknownAgentSet.has(t.agent)) continue;
99
+ const taskAgentConfig = t.agent ? findAgent(agents, t.agent) : undefined;
100
+ const me = firstError(
101
+ validateModel(t.model, ctx.modelRegistry, { agentName: t.agent }),
102
+ validateModel(taskAgentConfig?.model, ctx.modelRegistry, {
103
+ agentName: t.agent,
104
+ agentFilePath: taskAgentConfig?.filePath,
105
+ }),
106
+ );
107
+ if (me) errors.push(me);
108
+ }
109
+ if (errors.length > 0) {
85
110
  return {
86
- content: [{ type: "text", text: `Unknown agent(s): ${unknown}. Available: ${available}` }],
111
+ content: [{ type: "text", text: errors.join("\n") }],
87
112
  details: {
88
113
  mode: "parallel",
89
114
  results: [],
@@ -134,7 +159,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
134
159
  if (params.agent && !agentConfig) {
135
160
  const available = agents.map((a) => a.name).join(", ") || "none";
136
161
  return {
137
- content: [{ type: "text", text: `Unknown agent: "${params.agent}". Available: ${available}` }],
162
+ content: [{ type: "text", text: `Unknown agent: "${params.agent}". Available: ${available}. Call list_agents for details.` }],
138
163
  details: {
139
164
  mode: "single",
140
165
  results: [],
@@ -143,6 +168,22 @@ export function registerSubagent(pi: ExtensionAPI): void {
143
168
  isError: true,
144
169
  };
145
170
  }
171
+ // Pre-spawn model validation (P2): fail fast with a friendly error
172
+ // instead of spawning a child that will crash on a bogus --model.
173
+ const modelError = firstError(
174
+ validateModel(params.model, ctx.modelRegistry, { agentName: params.agent }),
175
+ validateModel(agentConfig?.model, ctx.modelRegistry, {
176
+ agentName: params.agent,
177
+ agentFilePath: agentConfig?.filePath,
178
+ }),
179
+ );
180
+ if (modelError) {
181
+ return {
182
+ content: [{ type: "text", text: modelError }],
183
+ details: { mode: "single", results: [], progress: undefined },
184
+ isError: true,
185
+ };
186
+ }
146
187
  const result: SubagentResult = await executeSubagent({
147
188
  agent: params.agent ?? undefined,
148
189
  agentConfig,
@@ -234,10 +275,29 @@ export function registerSubagent(pi: ExtensionAPI): void {
234
275
  }
235
276
  },
236
277
  });
278
+
279
+ registerListAgentsTool(pi);
237
280
  }
238
281
 
239
282
  // ── Helpers ─────────────────────────────────────────────────────────────────
240
283
 
284
+ export function registerListAgentsTool(pi: ExtensionAPI): void {
285
+ pi.registerTool({
286
+ name: "list_agents",
287
+ label: "Agents",
288
+ description:
289
+ "List available subagent configurations (name, description, source, model/tools overrides). Call before dispatching if unsure which agents exist or which fits the task.",
290
+ parameters: Type.Object({}),
291
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
292
+ const agents = discoverAgents(ctx.cwd);
293
+ return {
294
+ content: [{ type: "text" as const, text: formatAgentList(agents) }],
295
+ details: { count: agents.length },
296
+ };
297
+ },
298
+ });
299
+ }
300
+
241
301
  function formatProgressSummary(progress: SubagentProgress[]): string {
242
302
  if (progress.length === 0) return "";
243
303
  const lines = progress.map((p) => {