@tintinweb/pi-subagents 0.2.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/CHANGELOG.md +81 -0
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/package.json +46 -0
- package/src/agent-manager.ts +287 -0
- package/src/agent-runner.ts +341 -0
- package/src/agent-types.ts +137 -0
- package/src/context.ts +58 -0
- package/src/custom-agents.ts +94 -0
- package/src/env.ts +33 -0
- package/src/index.ts +855 -0
- package/src/prompts.ts +163 -0
- package/src/types.ts +84 -0
- package/src/ui/agent-widget.ts +326 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-agents — A pi extension providing Claude Code-style autonomous sub-agents.
|
|
3
|
+
*
|
|
4
|
+
* Tools:
|
|
5
|
+
* Agent — LLM-callable: spawn a sub-agent
|
|
6
|
+
* get_subagent_result — LLM-callable: check background agent status/result
|
|
7
|
+
* steer_subagent — LLM-callable: send a steering message to a running agent
|
|
8
|
+
*
|
|
9
|
+
* Commands:
|
|
10
|
+
* /agent <type> <prompt> — User-invocable agent spawning
|
|
11
|
+
* /agents — List all agents with status
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
16
|
+
import { Type } from "@sinclair/typebox";
|
|
17
|
+
import { AgentManager } from "./agent-manager.js";
|
|
18
|
+
import { steerAgent, getAgentConversation } from "./agent-runner.js";
|
|
19
|
+
import { SUBAGENT_TYPES, type SubagentType, type ThinkingLevel, type CustomAgentConfig } from "./types.js";
|
|
20
|
+
import { getConfig, getAvailableTypes, getCustomAgentNames, getCustomAgentConfig, isValidType, registerCustomAgents } from "./agent-types.js";
|
|
21
|
+
import { loadCustomAgents } from "./custom-agents.js";
|
|
22
|
+
import {
|
|
23
|
+
AgentWidget,
|
|
24
|
+
SPINNER,
|
|
25
|
+
formatTokens,
|
|
26
|
+
formatMs,
|
|
27
|
+
formatDuration,
|
|
28
|
+
getDisplayName,
|
|
29
|
+
describeActivity,
|
|
30
|
+
type AgentDetails,
|
|
31
|
+
type AgentActivity,
|
|
32
|
+
type UICtx,
|
|
33
|
+
} from "./ui/agent-widget.js";
|
|
34
|
+
|
|
35
|
+
// ---- Shared helpers ----
|
|
36
|
+
|
|
37
|
+
/** Tool execute return value for a text response. */
|
|
38
|
+
function textResult(msg: string, details?: AgentDetails) {
|
|
39
|
+
return { content: [{ type: "text" as const, text: msg }], details: details as any };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Safe token formatting — wraps session.getSessionStats() in try-catch. */
|
|
43
|
+
function safeFormatTokens(session: { getSessionStats(): { tokens: { total: number } } } | undefined): string {
|
|
44
|
+
if (!session) return "";
|
|
45
|
+
try { return formatTokens(session.getSessionStats().tokens.total); } catch { return ""; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
50
|
+
* Used by both foreground and background paths to avoid duplication.
|
|
51
|
+
*/
|
|
52
|
+
function createActivityTracker(onStreamUpdate?: () => void) {
|
|
53
|
+
const state: AgentActivity = { activeTools: new Map(), toolUses: 0, tokens: "", responseText: "", session: undefined };
|
|
54
|
+
|
|
55
|
+
const callbacks = {
|
|
56
|
+
onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
|
|
57
|
+
if (activity.type === "start") {
|
|
58
|
+
state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName);
|
|
59
|
+
} else {
|
|
60
|
+
for (const [key, name] of state.activeTools) {
|
|
61
|
+
if (name === activity.toolName) { state.activeTools.delete(key); break; }
|
|
62
|
+
}
|
|
63
|
+
state.toolUses++;
|
|
64
|
+
}
|
|
65
|
+
state.tokens = safeFormatTokens(state.session);
|
|
66
|
+
onStreamUpdate?.();
|
|
67
|
+
},
|
|
68
|
+
onTextDelta: (_delta: string, fullText: string) => {
|
|
69
|
+
state.responseText = fullText;
|
|
70
|
+
onStreamUpdate?.();
|
|
71
|
+
},
|
|
72
|
+
onSessionCreated: (session: any) => {
|
|
73
|
+
state.session = session;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return { state, callbacks };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Human-readable status label for agent completion. */
|
|
81
|
+
function getStatusLabel(status: string, error?: string): string {
|
|
82
|
+
switch (status) {
|
|
83
|
+
case "error": return `Error: ${error ?? "unknown"}`;
|
|
84
|
+
case "aborted": return "Aborted (max turns exceeded)";
|
|
85
|
+
case "steered": return "Wrapped up (turn limit)";
|
|
86
|
+
case "stopped": return "Stopped";
|
|
87
|
+
default: return "Done";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parenthetical status note for completed agent result text. */
|
|
92
|
+
function getStatusNote(status: string): string {
|
|
93
|
+
switch (status) {
|
|
94
|
+
case "aborted": return " (aborted — max turns exceeded, output may be incomplete)";
|
|
95
|
+
case "steered": return " (wrapped up — reached turn limit)";
|
|
96
|
+
case "stopped": return " (stopped by user)";
|
|
97
|
+
default: return "";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Build AgentDetails from a base + record-specific fields. */
|
|
102
|
+
function buildDetails(
|
|
103
|
+
base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
|
|
104
|
+
record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any },
|
|
105
|
+
overrides?: Partial<AgentDetails>,
|
|
106
|
+
): AgentDetails {
|
|
107
|
+
return {
|
|
108
|
+
...base,
|
|
109
|
+
toolUses: record.toolUses,
|
|
110
|
+
tokens: safeFormatTokens(record.session),
|
|
111
|
+
durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
|
|
112
|
+
status: record.status as AgentDetails["status"],
|
|
113
|
+
agentId: record.id,
|
|
114
|
+
error: record.error,
|
|
115
|
+
...overrides,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Resolve system prompt overrides from a custom agent config. */
|
|
120
|
+
function resolveCustomPrompt(config: CustomAgentConfig | undefined): {
|
|
121
|
+
systemPromptOverride?: string;
|
|
122
|
+
systemPromptAppend?: string;
|
|
123
|
+
} {
|
|
124
|
+
if (!config?.systemPrompt) return {};
|
|
125
|
+
if (config.promptMode === "append") return { systemPromptAppend: config.systemPrompt };
|
|
126
|
+
return { systemPromptOverride: config.systemPrompt };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve a model string to a Model instance.
|
|
131
|
+
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
|
|
132
|
+
* Returns the Model on success, or an error message string on failure.
|
|
133
|
+
*/
|
|
134
|
+
function resolveModel(
|
|
135
|
+
input: string,
|
|
136
|
+
registry: { find(provider: string, modelId: string): any; getAll(): any[]; getAvailable?(): any[] },
|
|
137
|
+
): any | string {
|
|
138
|
+
// 1. Exact match: "provider/modelId"
|
|
139
|
+
const slashIdx = input.indexOf("/");
|
|
140
|
+
if (slashIdx !== -1) {
|
|
141
|
+
const provider = input.slice(0, slashIdx);
|
|
142
|
+
const modelId = input.slice(slashIdx + 1);
|
|
143
|
+
const found = registry.find(provider, modelId);
|
|
144
|
+
if (found) return found;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 2. Fuzzy match against available models (those with auth configured)
|
|
148
|
+
const all = (registry.getAvailable?.() ?? registry.getAll()) as { id: string; name: string; provider: string }[];
|
|
149
|
+
const query = input.toLowerCase();
|
|
150
|
+
|
|
151
|
+
// Score each model: prefer exact id match > id contains > name contains > provider+id contains
|
|
152
|
+
let bestMatch: typeof all[number] | undefined;
|
|
153
|
+
let bestScore = 0;
|
|
154
|
+
|
|
155
|
+
for (const m of all) {
|
|
156
|
+
const id = m.id.toLowerCase();
|
|
157
|
+
const name = m.name.toLowerCase();
|
|
158
|
+
const full = `${m.provider}/${m.id}`.toLowerCase();
|
|
159
|
+
|
|
160
|
+
let score = 0;
|
|
161
|
+
if (id === query || full === query) {
|
|
162
|
+
score = 100; // exact
|
|
163
|
+
} else if (id.includes(query) || full.includes(query)) {
|
|
164
|
+
score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
|
|
165
|
+
} else if (name.includes(query)) {
|
|
166
|
+
score = 40 + (query.length / name.length) * 20;
|
|
167
|
+
} else if (query.split(/[\s\-/]+/).every(part => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))) {
|
|
168
|
+
score = 20; // all parts present somewhere
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (score > bestScore) {
|
|
172
|
+
bestScore = score;
|
|
173
|
+
bestMatch = m;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (bestMatch && bestScore >= 20) {
|
|
178
|
+
const found = registry.find(bestMatch.provider, bestMatch.id);
|
|
179
|
+
if (found) return found;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 3. No match — list available models
|
|
183
|
+
const modelList = all
|
|
184
|
+
.map(m => ` ${m.provider}/${m.id}`)
|
|
185
|
+
.sort()
|
|
186
|
+
.join("\n");
|
|
187
|
+
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export default function (pi: ExtensionAPI) {
|
|
191
|
+
/** Reload custom agents from .pi/agents/*.md (called on init and each Agent invocation). */
|
|
192
|
+
const reloadCustomAgents = () => {
|
|
193
|
+
const agents = loadCustomAgents(process.cwd());
|
|
194
|
+
registerCustomAgents(agents);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Initial load
|
|
198
|
+
reloadCustomAgents();
|
|
199
|
+
|
|
200
|
+
// ---- Agent activity tracking + widget ----
|
|
201
|
+
const agentActivity = new Map<string, AgentActivity>();
|
|
202
|
+
|
|
203
|
+
// Background completion: push notification into conversation
|
|
204
|
+
const manager = new AgentManager((record) => {
|
|
205
|
+
const displayName = getDisplayName(record.type);
|
|
206
|
+
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
207
|
+
|
|
208
|
+
const status = getStatusLabel(record.status, record.error);
|
|
209
|
+
|
|
210
|
+
const resultPreview = record.result
|
|
211
|
+
? record.result.length > 500
|
|
212
|
+
? record.result.slice(0, 500) + "\n...(truncated, use get_subagent_result for full output)"
|
|
213
|
+
: record.result
|
|
214
|
+
: "No output.";
|
|
215
|
+
|
|
216
|
+
agentActivity.delete(record.id);
|
|
217
|
+
widget.markFinished(record.id);
|
|
218
|
+
|
|
219
|
+
// Poke the main agent so it processes the result (queues as follow-up if busy)
|
|
220
|
+
pi.sendUserMessage(
|
|
221
|
+
`Background agent completed: ${displayName} (${record.description})\n` +
|
|
222
|
+
`Agent ID: ${record.id} | Status: ${status} | Tool uses: ${record.toolUses} | Duration: ${duration}\n\n` +
|
|
223
|
+
resultPreview,
|
|
224
|
+
{ deliverAs: "followUp" },
|
|
225
|
+
);
|
|
226
|
+
widget.update();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Live widget: show running agents above editor
|
|
230
|
+
const widget = new AgentWidget(manager, agentActivity);
|
|
231
|
+
|
|
232
|
+
// Grab UI context from first tool execution + clear lingering widget on new turn
|
|
233
|
+
pi.on("tool_execution_start", async (_event, ctx) => {
|
|
234
|
+
widget.setUICtx(ctx.ui as UICtx);
|
|
235
|
+
widget.onTurnStart();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Build type description text (static built-in + dynamic custom note)
|
|
239
|
+
const builtinDescs = [
|
|
240
|
+
"- general-purpose: Full tool access for complex multi-step tasks.",
|
|
241
|
+
"- Explore: Fast codebase exploration (read-only, defaults to haiku).",
|
|
242
|
+
"- Plan: Software architect for implementation planning (read-only).",
|
|
243
|
+
"- statusline-setup: Configuration editor (read + edit only).",
|
|
244
|
+
"- claude-code-guide: Documentation and help queries (read-only).",
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
/** Build the full type list text, including any currently loaded custom agents. */
|
|
248
|
+
const buildTypeListText = () => {
|
|
249
|
+
const names = getCustomAgentNames();
|
|
250
|
+
const customDescs = names.map((name) => {
|
|
251
|
+
const cfg = getCustomAgentConfig(name);
|
|
252
|
+
return `- ${name}: ${cfg?.description ?? name}`;
|
|
253
|
+
});
|
|
254
|
+
return [
|
|
255
|
+
"Built-in types:",
|
|
256
|
+
...builtinDescs,
|
|
257
|
+
...(customDescs.length > 0 ? ["", "Custom types:", ...customDescs] : []),
|
|
258
|
+
"",
|
|
259
|
+
"Custom agents can be defined in .pi/agents/<name>.md — they are picked up automatically.",
|
|
260
|
+
].join("\n");
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const typeListText = buildTypeListText();
|
|
264
|
+
|
|
265
|
+
// ---- Agent tool ----
|
|
266
|
+
|
|
267
|
+
pi.registerTool<any, AgentDetails>({
|
|
268
|
+
name: "Agent",
|
|
269
|
+
label: "Agent",
|
|
270
|
+
description: `Launch a new agent to handle complex, multi-step tasks autonomously.
|
|
271
|
+
|
|
272
|
+
The Agent tool launches specialized agents that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
|
|
273
|
+
|
|
274
|
+
Available agent types:
|
|
275
|
+
${typeListText}
|
|
276
|
+
|
|
277
|
+
Guidelines:
|
|
278
|
+
- For parallel work, use run_in_background: true on each agent. Foreground calls run sequentially — only one executes at a time.
|
|
279
|
+
- Use Explore for codebase searches and code understanding.
|
|
280
|
+
- Use Plan for architecture and implementation planning.
|
|
281
|
+
- Use general-purpose for complex tasks that need file editing.
|
|
282
|
+
- Provide clear, detailed prompts so the agent can work autonomously.
|
|
283
|
+
- Agent results are returned as text — summarize them for the user.
|
|
284
|
+
- Use run_in_background for work you don't need immediately. You will be notified when it completes.
|
|
285
|
+
- Use resume with an agent ID to continue a previous agent's work.
|
|
286
|
+
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
287
|
+
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
288
|
+
- Use thinking to control extended thinking level.
|
|
289
|
+
- Use inherit_context if the agent needs the parent conversation history.`,
|
|
290
|
+
parameters: Type.Object({
|
|
291
|
+
prompt: Type.String({
|
|
292
|
+
description: "The task for the agent to perform.",
|
|
293
|
+
}),
|
|
294
|
+
description: Type.String({
|
|
295
|
+
description: "A short (3-5 word) description of the task (shown in UI).",
|
|
296
|
+
}),
|
|
297
|
+
subagent_type: Type.String({
|
|
298
|
+
description: `The type of specialized agent to use. Built-in: ${SUBAGENT_TYPES.join(", ")}. Custom agents from .pi/agents/*.md are also available.`,
|
|
299
|
+
}),
|
|
300
|
+
model: Type.Optional(
|
|
301
|
+
Type.String({
|
|
302
|
+
description:
|
|
303
|
+
'Optional model to use. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). If omitted, Explore defaults to haiku; others inherit from parent.',
|
|
304
|
+
}),
|
|
305
|
+
),
|
|
306
|
+
thinking: Type.Optional(
|
|
307
|
+
Type.String({
|
|
308
|
+
description: "Thinking level: off, minimal, low, medium, high, xhigh. Overrides agent default.",
|
|
309
|
+
}),
|
|
310
|
+
),
|
|
311
|
+
max_turns: Type.Optional(
|
|
312
|
+
Type.Number({
|
|
313
|
+
description: "Maximum number of agentic turns before stopping.",
|
|
314
|
+
minimum: 1,
|
|
315
|
+
}),
|
|
316
|
+
),
|
|
317
|
+
run_in_background: Type.Optional(
|
|
318
|
+
Type.Boolean({
|
|
319
|
+
description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
|
|
320
|
+
}),
|
|
321
|
+
),
|
|
322
|
+
resume: Type.Optional(
|
|
323
|
+
Type.String({
|
|
324
|
+
description: "Optional agent ID to resume from. Continues from previous context.",
|
|
325
|
+
}),
|
|
326
|
+
),
|
|
327
|
+
isolated: Type.Optional(
|
|
328
|
+
Type.Boolean({
|
|
329
|
+
description: "If true, agent gets no extension/MCP tools — only built-in tools.",
|
|
330
|
+
}),
|
|
331
|
+
),
|
|
332
|
+
inherit_context: Type.Optional(
|
|
333
|
+
Type.Boolean({
|
|
334
|
+
description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
|
|
335
|
+
}),
|
|
336
|
+
),
|
|
337
|
+
}),
|
|
338
|
+
|
|
339
|
+
// ---- Custom rendering: Claude Code style ----
|
|
340
|
+
|
|
341
|
+
renderCall(args, theme) {
|
|
342
|
+
const displayName = args.subagent_type ? getDisplayName(args.subagent_type) : "Agent";
|
|
343
|
+
const desc = args.description ?? "";
|
|
344
|
+
return new Text("▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
348
|
+
const details = result.details as AgentDetails | undefined;
|
|
349
|
+
if (!details) {
|
|
350
|
+
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
351
|
+
return new Text(text, 0, 0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Helper: build "haiku · thinking: high · 3 tool uses · 33.8k tokens" stats string
|
|
355
|
+
const stats = (d: AgentDetails) => {
|
|
356
|
+
const parts: string[] = [];
|
|
357
|
+
if (d.modelName) parts.push(d.modelName);
|
|
358
|
+
if (d.tags) parts.push(...d.tags);
|
|
359
|
+
if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
360
|
+
if (d.tokens) parts.push(d.tokens);
|
|
361
|
+
return parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// ---- While running (streaming) ----
|
|
365
|
+
if (isPartial || details.status === "running") {
|
|
366
|
+
const frame = SPINNER[details.spinnerFrame ?? 0];
|
|
367
|
+
const s = stats(details);
|
|
368
|
+
let line = theme.fg("accent", frame) + (s ? " " + s : "");
|
|
369
|
+
line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`);
|
|
370
|
+
return new Text(line, 0, 0);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---- Background agent launched ----
|
|
374
|
+
if (details.status === "background") {
|
|
375
|
+
return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---- Completed / Steered ----
|
|
379
|
+
if (details.status === "completed" || details.status === "steered") {
|
|
380
|
+
const duration = formatMs(details.durationMs);
|
|
381
|
+
const isSteered = details.status === "steered";
|
|
382
|
+
const icon = isSteered ? theme.fg("warning", "✓") : theme.fg("success", "✓");
|
|
383
|
+
const s = stats(details);
|
|
384
|
+
let line = icon + (s ? " " + s : "");
|
|
385
|
+
line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration);
|
|
386
|
+
|
|
387
|
+
if (expanded) {
|
|
388
|
+
const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
389
|
+
if (resultText) {
|
|
390
|
+
const lines = resultText.split("\n").slice(0, 50);
|
|
391
|
+
for (const l of lines) {
|
|
392
|
+
line += "\n" + theme.fg("dim", ` ${l}`);
|
|
393
|
+
}
|
|
394
|
+
if (resultText.split("\n").length > 50) {
|
|
395
|
+
line += "\n" + theme.fg("muted", " ... (use get_subagent_result with verbose for full output)");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
|
|
400
|
+
line += "\n" + theme.fg("dim", ` ⎿ ${doneText}`);
|
|
401
|
+
}
|
|
402
|
+
return new Text(line, 0, 0);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---- Stopped (user-initiated abort) ----
|
|
406
|
+
if (details.status === "stopped") {
|
|
407
|
+
const s = stats(details);
|
|
408
|
+
let line = theme.fg("dim", "■") + (s ? " " + s : "");
|
|
409
|
+
line += "\n" + theme.fg("dim", " ⎿ Stopped");
|
|
410
|
+
return new Text(line, 0, 0);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---- Error / Aborted (hard max_turns) ----
|
|
414
|
+
const s = stats(details);
|
|
415
|
+
let line = theme.fg("error", "✗") + (s ? " " + s : "");
|
|
416
|
+
|
|
417
|
+
if (details.status === "error") {
|
|
418
|
+
line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`);
|
|
419
|
+
} else {
|
|
420
|
+
line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return new Text(line, 0, 0);
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
// ---- Execute ----
|
|
427
|
+
|
|
428
|
+
execute: async (_toolCallId, params, signal, onUpdate, ctx) => {
|
|
429
|
+
// Ensure we have UI context for widget rendering
|
|
430
|
+
widget.setUICtx(ctx.ui as UICtx);
|
|
431
|
+
|
|
432
|
+
// Reload custom agents so new .pi/agents/*.md files are picked up without restart
|
|
433
|
+
reloadCustomAgents();
|
|
434
|
+
|
|
435
|
+
const subagentType = params.subagent_type as SubagentType;
|
|
436
|
+
|
|
437
|
+
// Validate subagent type
|
|
438
|
+
if (!isValidType(subagentType)) {
|
|
439
|
+
return textResult(`Unknown agent type: "${params.subagent_type}". Valid types: ${getAvailableTypes().join(", ")}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const displayName = getDisplayName(subagentType);
|
|
443
|
+
|
|
444
|
+
// Get custom agent config (if any)
|
|
445
|
+
const customConfig = getCustomAgentConfig(subagentType);
|
|
446
|
+
|
|
447
|
+
// Resolve model if specified (supports exact "provider/modelId" or fuzzy match)
|
|
448
|
+
let model = ctx.model;
|
|
449
|
+
if (params.model) {
|
|
450
|
+
const resolved = resolveModel(params.model, ctx.modelRegistry);
|
|
451
|
+
if (typeof resolved === "string") {
|
|
452
|
+
return textResult(resolved);
|
|
453
|
+
}
|
|
454
|
+
model = resolved;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Resolve thinking: explicit param > custom config > undefined
|
|
458
|
+
const thinking = (params.thinking ?? customConfig?.thinking) as ThinkingLevel | undefined;
|
|
459
|
+
|
|
460
|
+
// Resolve spawn-time defaults from custom config (caller overrides)
|
|
461
|
+
const inheritContext = params.inherit_context ?? customConfig?.inheritContext ?? false;
|
|
462
|
+
const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
|
|
463
|
+
const isolated = params.isolated ?? customConfig?.isolated ?? false;
|
|
464
|
+
|
|
465
|
+
const { systemPromptOverride, systemPromptAppend } = resolveCustomPrompt(customConfig);
|
|
466
|
+
|
|
467
|
+
// Build display tags for non-default config
|
|
468
|
+
const parentModelId = ctx.model?.id;
|
|
469
|
+
const effectiveModelId = model?.id;
|
|
470
|
+
const agentModelName = effectiveModelId && effectiveModelId !== parentModelId
|
|
471
|
+
? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
|
|
472
|
+
: undefined;
|
|
473
|
+
const agentTags: string[] = [];
|
|
474
|
+
if (thinking) agentTags.push(`thinking: ${thinking}`);
|
|
475
|
+
if (isolated) agentTags.push("isolated");
|
|
476
|
+
// Shared base fields for all AgentDetails in this call
|
|
477
|
+
const detailBase = {
|
|
478
|
+
displayName,
|
|
479
|
+
description: params.description,
|
|
480
|
+
subagentType,
|
|
481
|
+
modelName: agentModelName,
|
|
482
|
+
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// Resume existing agent
|
|
486
|
+
if (params.resume) {
|
|
487
|
+
const existing = manager.getRecord(params.resume);
|
|
488
|
+
if (!existing) {
|
|
489
|
+
return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
|
|
490
|
+
}
|
|
491
|
+
if (!existing.session) {
|
|
492
|
+
return textResult(`Agent "${params.resume}" has no active session to resume.`);
|
|
493
|
+
}
|
|
494
|
+
const record = await manager.resume(params.resume, params.prompt, signal);
|
|
495
|
+
if (!record) {
|
|
496
|
+
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
497
|
+
}
|
|
498
|
+
return textResult(
|
|
499
|
+
record.result ?? record.error ?? "No output.",
|
|
500
|
+
buildDetails(detailBase, record),
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Background execution
|
|
505
|
+
if (runInBackground) {
|
|
506
|
+
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker();
|
|
507
|
+
|
|
508
|
+
const id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
509
|
+
description: params.description,
|
|
510
|
+
model,
|
|
511
|
+
maxTurns: params.max_turns,
|
|
512
|
+
isolated,
|
|
513
|
+
inheritContext,
|
|
514
|
+
thinkingLevel: thinking,
|
|
515
|
+
systemPromptOverride,
|
|
516
|
+
systemPromptAppend,
|
|
517
|
+
isBackground: true,
|
|
518
|
+
...bgCallbacks,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
agentActivity.set(id, bgState);
|
|
522
|
+
widget.ensureTimer();
|
|
523
|
+
widget.update();
|
|
524
|
+
const record = manager.getRecord(id);
|
|
525
|
+
const isQueued = record?.status === "queued";
|
|
526
|
+
return textResult(
|
|
527
|
+
`Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
528
|
+
`Agent ID: ${id}\n` +
|
|
529
|
+
`Type: ${displayName}\n` +
|
|
530
|
+
`Description: ${params.description}\n` +
|
|
531
|
+
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
532
|
+
`\nYou will be notified when this agent completes.\n` +
|
|
533
|
+
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
534
|
+
`Do not duplicate this agent's work.`,
|
|
535
|
+
{ ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Foreground (synchronous) execution — stream progress via onUpdate
|
|
540
|
+
let spinnerFrame = 0;
|
|
541
|
+
const startedAt = Date.now();
|
|
542
|
+
let fgId: string | undefined;
|
|
543
|
+
|
|
544
|
+
const streamUpdate = () => {
|
|
545
|
+
const details: AgentDetails = {
|
|
546
|
+
...detailBase,
|
|
547
|
+
toolUses: fgState.toolUses,
|
|
548
|
+
tokens: fgState.tokens,
|
|
549
|
+
durationMs: Date.now() - startedAt,
|
|
550
|
+
status: "running",
|
|
551
|
+
activity: describeActivity(fgState.activeTools, fgState.responseText),
|
|
552
|
+
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
553
|
+
};
|
|
554
|
+
onUpdate?.({
|
|
555
|
+
content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
|
|
556
|
+
details: details as any,
|
|
557
|
+
});
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(streamUpdate);
|
|
561
|
+
|
|
562
|
+
// Wire session creation to register in widget
|
|
563
|
+
const origOnSession = fgCallbacks.onSessionCreated;
|
|
564
|
+
fgCallbacks.onSessionCreated = (session: any) => {
|
|
565
|
+
origOnSession(session);
|
|
566
|
+
for (const a of manager.listAgents()) {
|
|
567
|
+
if (a.session === session) {
|
|
568
|
+
fgId = a.id;
|
|
569
|
+
agentActivity.set(a.id, fgState);
|
|
570
|
+
widget.ensureTimer();
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// Animate spinner at ~80ms (smooth rotation through 10 braille frames)
|
|
577
|
+
const spinnerInterval = setInterval(() => {
|
|
578
|
+
spinnerFrame++;
|
|
579
|
+
streamUpdate();
|
|
580
|
+
}, 80);
|
|
581
|
+
|
|
582
|
+
streamUpdate();
|
|
583
|
+
|
|
584
|
+
const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
|
|
585
|
+
description: params.description,
|
|
586
|
+
model,
|
|
587
|
+
maxTurns: params.max_turns,
|
|
588
|
+
isolated,
|
|
589
|
+
inheritContext,
|
|
590
|
+
thinkingLevel: thinking,
|
|
591
|
+
systemPromptOverride,
|
|
592
|
+
systemPromptAppend,
|
|
593
|
+
...fgCallbacks,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
clearInterval(spinnerInterval);
|
|
597
|
+
|
|
598
|
+
// Clean up foreground agent from widget
|
|
599
|
+
if (fgId) {
|
|
600
|
+
agentActivity.delete(fgId);
|
|
601
|
+
widget.markFinished(fgId);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Get final token count
|
|
605
|
+
const tokenText = safeFormatTokens(fgState.session);
|
|
606
|
+
|
|
607
|
+
const details = buildDetails(detailBase, record, { tokens: tokenText });
|
|
608
|
+
|
|
609
|
+
if (record.status === "error") {
|
|
610
|
+
return textResult(`Agent failed: ${record.error}`, details);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
|
|
614
|
+
return textResult(
|
|
615
|
+
`Agent completed in ${formatMs(durationMs)} (${record.toolUses} tool uses)${getStatusNote(record.status)}.\n\n` +
|
|
616
|
+
(record.result ?? "No output."),
|
|
617
|
+
details,
|
|
618
|
+
);
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// ---- get_subagent_result tool ----
|
|
623
|
+
|
|
624
|
+
pi.registerTool({
|
|
625
|
+
name: "get_subagent_result",
|
|
626
|
+
label: "Get Agent Result",
|
|
627
|
+
description:
|
|
628
|
+
"Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
|
|
629
|
+
parameters: Type.Object({
|
|
630
|
+
agent_id: Type.String({
|
|
631
|
+
description: "The agent ID to check.",
|
|
632
|
+
}),
|
|
633
|
+
wait: Type.Optional(
|
|
634
|
+
Type.Boolean({
|
|
635
|
+
description: "If true, wait for the agent to complete before returning. Default: false.",
|
|
636
|
+
}),
|
|
637
|
+
),
|
|
638
|
+
verbose: Type.Optional(
|
|
639
|
+
Type.Boolean({
|
|
640
|
+
description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
|
|
641
|
+
}),
|
|
642
|
+
),
|
|
643
|
+
}),
|
|
644
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
|
|
645
|
+
const record = manager.getRecord(params.agent_id);
|
|
646
|
+
if (!record) {
|
|
647
|
+
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Wait for completion if requested
|
|
651
|
+
if (params.wait && record.status === "running" && record.promise) {
|
|
652
|
+
await record.promise;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const displayName = getDisplayName(record.type);
|
|
656
|
+
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
657
|
+
|
|
658
|
+
let output =
|
|
659
|
+
`Agent: ${record.id}\n` +
|
|
660
|
+
`Type: ${displayName} | Status: ${record.status} | Tool uses: ${record.toolUses} | Duration: ${duration}\n` +
|
|
661
|
+
`Description: ${record.description}\n\n`;
|
|
662
|
+
|
|
663
|
+
if (record.status === "running") {
|
|
664
|
+
output += "Agent is still running. Use wait: true or check back later.";
|
|
665
|
+
} else if (record.status === "error") {
|
|
666
|
+
output += `Error: ${record.error}`;
|
|
667
|
+
} else {
|
|
668
|
+
output += record.result ?? "No output.";
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Verbose: include full conversation
|
|
672
|
+
if (params.verbose && record.session) {
|
|
673
|
+
const conversation = getAgentConversation(record.session);
|
|
674
|
+
if (conversation) {
|
|
675
|
+
output += `\n\n--- Agent Conversation ---\n${conversation}`;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return textResult(output);
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// ---- steer_subagent tool ----
|
|
684
|
+
|
|
685
|
+
pi.registerTool({
|
|
686
|
+
name: "steer_subagent",
|
|
687
|
+
label: "Steer Agent",
|
|
688
|
+
description:
|
|
689
|
+
"Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
|
|
690
|
+
"and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
|
|
691
|
+
parameters: Type.Object({
|
|
692
|
+
agent_id: Type.String({
|
|
693
|
+
description: "The agent ID to steer (must be currently running).",
|
|
694
|
+
}),
|
|
695
|
+
message: Type.String({
|
|
696
|
+
description: "The steering message to send. This will appear as a user message in the agent's conversation.",
|
|
697
|
+
}),
|
|
698
|
+
}),
|
|
699
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
|
|
700
|
+
const record = manager.getRecord(params.agent_id);
|
|
701
|
+
if (!record) {
|
|
702
|
+
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
703
|
+
}
|
|
704
|
+
if (record.status !== "running") {
|
|
705
|
+
return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`);
|
|
706
|
+
}
|
|
707
|
+
if (!record.session) {
|
|
708
|
+
return textResult(`Agent "${params.agent_id}" has no active session yet. It may still be initializing.`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
await steerAgent(record.session, params.message);
|
|
713
|
+
return textResult(`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.`);
|
|
714
|
+
} catch (err) {
|
|
715
|
+
return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// ---- /agent command ----
|
|
721
|
+
|
|
722
|
+
pi.registerCommand("agent", {
|
|
723
|
+
description: "Spawn a sub-agent: /agent <type> <prompt>",
|
|
724
|
+
handler: async (args, ctx) => {
|
|
725
|
+
const trimmed = args?.trim() ?? "";
|
|
726
|
+
|
|
727
|
+
if (!trimmed) {
|
|
728
|
+
const lines = [
|
|
729
|
+
"Usage: /agent <type> <prompt>",
|
|
730
|
+
"",
|
|
731
|
+
"Agent types:",
|
|
732
|
+
...getAvailableTypes().map(
|
|
733
|
+
(t) => ` ${t.padEnd(20)} ${getConfig(t).description}`,
|
|
734
|
+
),
|
|
735
|
+
"",
|
|
736
|
+
"Examples:",
|
|
737
|
+
" /agent Explore Find all TypeScript files that handle authentication",
|
|
738
|
+
" /agent Plan Design a caching layer for the API",
|
|
739
|
+
" /agent general-purpose Refactor the auth module to use JWT",
|
|
740
|
+
];
|
|
741
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Parse: first word is type, rest is prompt
|
|
746
|
+
const spaceIdx = trimmed.indexOf(" ");
|
|
747
|
+
if (spaceIdx === -1) {
|
|
748
|
+
ctx.ui.notify(
|
|
749
|
+
`Missing prompt. Usage: /agent <type> <prompt>\nTypes: ${getAvailableTypes().join(", ")}`,
|
|
750
|
+
"warning",
|
|
751
|
+
);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const typeName = trimmed.slice(0, spaceIdx);
|
|
756
|
+
const prompt = trimmed.slice(spaceIdx + 1).trim();
|
|
757
|
+
|
|
758
|
+
if (!isValidType(typeName)) {
|
|
759
|
+
ctx.ui.notify(
|
|
760
|
+
`Unknown agent type: "${typeName}"\nValid types: ${getAvailableTypes().join(", ")}`,
|
|
761
|
+
"warning",
|
|
762
|
+
);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (!prompt) {
|
|
767
|
+
ctx.ui.notify("Missing prompt.", "warning");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const displayName = getDisplayName(typeName);
|
|
772
|
+
ctx.ui.notify(`Spawning ${displayName} agent...`, "info");
|
|
773
|
+
|
|
774
|
+
const customConfig = getCustomAgentConfig(typeName);
|
|
775
|
+
const { systemPromptOverride, systemPromptAppend } = resolveCustomPrompt(customConfig);
|
|
776
|
+
|
|
777
|
+
const record = await manager.spawnAndWait(pi, ctx, typeName, prompt, {
|
|
778
|
+
description: prompt.slice(0, 40),
|
|
779
|
+
thinkingLevel: customConfig?.thinking,
|
|
780
|
+
systemPromptOverride,
|
|
781
|
+
systemPromptAppend,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
if (record.status === "error") {
|
|
785
|
+
ctx.ui.notify(`Agent failed: ${record.error}`, "warning");
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
790
|
+
const statusNote = getStatusNote(record.status);
|
|
791
|
+
|
|
792
|
+
// Send the result as a message so it appears in the conversation
|
|
793
|
+
pi.sendMessage(
|
|
794
|
+
{
|
|
795
|
+
customType: "agent-result",
|
|
796
|
+
content: [
|
|
797
|
+
{
|
|
798
|
+
type: "text",
|
|
799
|
+
text:
|
|
800
|
+
`**${displayName}** agent completed in ${duration} (${record.toolUses} tool uses)${statusNote}\n\n` +
|
|
801
|
+
(record.result ?? "No output."),
|
|
802
|
+
},
|
|
803
|
+
],
|
|
804
|
+
display: true,
|
|
805
|
+
},
|
|
806
|
+
{ triggerTurn: false },
|
|
807
|
+
);
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// ---- /agents command ----
|
|
812
|
+
|
|
813
|
+
pi.registerCommand("agents", {
|
|
814
|
+
description: "List all agents with status",
|
|
815
|
+
handler: async (_args, ctx) => {
|
|
816
|
+
const agents = manager.listAgents();
|
|
817
|
+
|
|
818
|
+
if (agents.length === 0) {
|
|
819
|
+
ctx.ui.notify("No agents have been spawned yet.", "info");
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const lines: string[] = [];
|
|
824
|
+
const counts: Record<string, number> = {};
|
|
825
|
+
for (const a of agents) counts[a.status] = (counts[a.status] ?? 0) + 1;
|
|
826
|
+
|
|
827
|
+
lines.push(
|
|
828
|
+
`${agents.length} agent(s): ${counts.running ?? 0} running, ${(counts.completed ?? 0) + (counts.steered ?? 0)} completed, ${counts.stopped ?? 0} stopped, ${counts.aborted ?? 0} aborted, ${counts.error ?? 0} errored`,
|
|
829
|
+
);
|
|
830
|
+
lines.push("");
|
|
831
|
+
|
|
832
|
+
for (let i = 0; i < agents.length; i++) {
|
|
833
|
+
const a = agents[i];
|
|
834
|
+
const connector = i === agents.length - 1 ? "└─" : "├─";
|
|
835
|
+
const displayName = getDisplayName(a.type);
|
|
836
|
+
const duration = formatDuration(a.startedAt, a.completedAt);
|
|
837
|
+
|
|
838
|
+
lines.push(
|
|
839
|
+
`${connector} ${displayName} (${a.description}) · ${a.toolUses} tool uses · ${a.status} · ${duration}`,
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
if (a.status === "error" && a.error) {
|
|
843
|
+
const indent = i === agents.length - 1 ? " " : "│ ";
|
|
844
|
+
lines.push(`${indent} ⎿ Error: ${a.error.slice(0, 100)}`);
|
|
845
|
+
}
|
|
846
|
+
if (a.session) {
|
|
847
|
+
const indent = i === agents.length - 1 ? " " : "│ ";
|
|
848
|
+
lines.push(`${indent} ⎿ ID: ${a.id} (resumable)`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
853
|
+
},
|
|
854
|
+
});
|
|
855
|
+
}
|