@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 +2 -2
- package/src/agent-manager.ts +115 -4
- package/src/agents.test.ts +109 -0
- package/src/agents.ts +42 -1
- package/src/compact.ts +17 -5
- package/src/expanded.ts +22 -6
- package/src/handlers.ts +16 -3
- package/src/index.ts +69 -9
- package/src/local-config.test.ts +111 -0
- package/src/local-config.ts +74 -0
- package/src/model-validation.test.ts +82 -0
- package/src/model-validation.ts +93 -0
- package/src/save-agent.test.ts +483 -0
- package/src/types.ts +9 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/subagent",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
14
|
+
"@pi-archimedes/core": "1.9.0"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
17
17
|
"@earendil-works/pi-ai": ">=0.1.0",
|
package/src/agent-manager.ts
CHANGED
|
@@ -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
|
-
//
|
|
1249
|
-
|
|
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
|
-
|
|
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("
|
|
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)
|
|
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(
|
|
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(
|
|
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("
|
|
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(
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
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).
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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:
|
|
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) => {
|