@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/package.json +2 -2
- package/src/agent-manager.ts +1654 -0
- package/src/agents.ts +58 -15
- package/src/compact.ts +60 -87
- package/src/execute.ts +8 -8
- package/src/frontmatter-io.ts +103 -0
- package/src/handlers.ts +25 -15
- package/src/index.ts +39 -5
- package/src/spawn.ts +19 -3
- package/src/stream.ts +28 -5
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 (
|
|
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
|
|
82
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
28
|
-
const
|
|
29
|
-
|
|
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 =
|
|
32
|
-
? " | " + formatDuration(Date.now() -
|
|
33
|
+
const durationPart = data.currentToolStartedAt
|
|
34
|
+
? " | " + formatDuration(Date.now() - data.currentToolStartedAt)
|
|
33
35
|
: "";
|
|
34
|
-
let line = theme.fg("syntaxFunction",
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
return
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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:
|
|
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 —
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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
|
|