@gotgenes/pi-subagents 4.1.0 → 4.1.1
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 +8 -0
- package/docs/plans/0054-decompose-index-into-modules.md +302 -0
- package/docs/retro/0053-extract-model-resolution-from-execute.md +30 -0
- package/package.json +1 -1
- package/src/index.ts +88 -1442
- package/src/notification.ts +188 -0
- package/src/renderer.ts +67 -0
- package/src/tools/agent-tool.ts +634 -0
- package/src/tools/get-result-tool.ts +99 -0
- package/src/tools/helpers.ts +21 -0
- package/src/tools/steer-tool.ts +83 -0
- package/src/ui/agent-menu.ts +685 -0
package/src/index.ts
CHANGED
|
@@ -10,245 +10,33 @@
|
|
|
10
10
|
* /agents — Interactive agent management menu
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
14
13
|
import { join } from "node:path";
|
|
15
|
-
import { defineTool, type ExtensionAPI,
|
|
16
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
17
|
-
import { Type } from "@sinclair/typebox";
|
|
14
|
+
import { defineTool, type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
18
15
|
import { AgentManager } from "./agent-manager.js";
|
|
19
|
-
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns,
|
|
20
|
-
import {
|
|
16
|
+
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
17
|
+
import { getAgentConfig, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, } from "./agent-types.js";
|
|
21
18
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
19
|
+
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
20
|
+
import { buildEventData, createNotificationSystem } from "./notification.js";
|
|
21
|
+
import { createNotificationRenderer } from "./renderer.js";
|
|
25
22
|
import { publishSubagentsService, unpublishSubagentsService } from "./service.js";
|
|
26
23
|
import { createSubagentsService } from "./service-adapter.js";
|
|
27
|
-
import { applyAndEmitLoaded,
|
|
28
|
-
import {
|
|
24
|
+
import { applyAndEmitLoaded, saveAndEmitChanged } from "./settings.js";
|
|
25
|
+
import { createAgentTool } from "./tools/agent-tool.js";
|
|
26
|
+
import { createGetResultTool } from "./tools/get-result-tool.js";
|
|
27
|
+
import { getModelLabelFromConfig } from "./tools/helpers.js";
|
|
28
|
+
import { createSteerTool } from "./tools/steer-tool.js";
|
|
29
|
+
import { type NotificationDetails } from "./types.js";
|
|
30
|
+
import { createAgentsMenuHandler } from "./ui/agent-menu.js";
|
|
29
31
|
import {
|
|
30
32
|
type AgentActivity,
|
|
31
|
-
type AgentDetails,
|
|
32
33
|
AgentWidget,
|
|
33
|
-
buildInvocationTags,
|
|
34
|
-
describeActivity,
|
|
35
|
-
formatDuration,
|
|
36
|
-
formatMs,
|
|
37
|
-
formatTokens,
|
|
38
|
-
formatTurns,
|
|
39
|
-
getDisplayName,
|
|
40
|
-
getPromptModeLabel,
|
|
41
|
-
SPINNER,
|
|
42
34
|
type UICtx,
|
|
43
35
|
} from "./ui/agent-widget.js";
|
|
44
|
-
import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
|
|
45
|
-
|
|
46
|
-
// ---- Shared helpers ----
|
|
47
|
-
|
|
48
|
-
/** Tool execute return value for a text response. */
|
|
49
|
-
function textResult(msg: string, details?: AgentDetails) {
|
|
50
|
-
return { content: [{ type: "text" as const, text: msg }], details: details as any };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Format an agent's lifetime token total, or "" when zero. */
|
|
54
|
-
function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
|
|
55
|
-
const t = getLifetimeTotal(o.lifetimeUsage);
|
|
56
|
-
return t > 0 ? formatTokens(t) : "";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
61
|
-
* Used by both foreground and background paths to avoid duplication.
|
|
62
|
-
*/
|
|
63
|
-
function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
64
|
-
const state: AgentActivity = {
|
|
65
|
-
activeTools: new Map(),
|
|
66
|
-
toolUses: 0,
|
|
67
|
-
turnCount: 1,
|
|
68
|
-
maxTurns,
|
|
69
|
-
responseText: "",
|
|
70
|
-
session: undefined,
|
|
71
|
-
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const callbacks = {
|
|
75
|
-
onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
|
|
76
|
-
if (activity.type === "start") {
|
|
77
|
-
state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName);
|
|
78
|
-
} else {
|
|
79
|
-
for (const [key, name] of state.activeTools) {
|
|
80
|
-
if (name === activity.toolName) { state.activeTools.delete(key); break; }
|
|
81
|
-
}
|
|
82
|
-
state.toolUses++;
|
|
83
|
-
}
|
|
84
|
-
onStreamUpdate?.();
|
|
85
|
-
},
|
|
86
|
-
onTextDelta: (_delta: string, fullText: string) => {
|
|
87
|
-
state.responseText = fullText;
|
|
88
|
-
onStreamUpdate?.();
|
|
89
|
-
},
|
|
90
|
-
onTurnEnd: (turnCount: number) => {
|
|
91
|
-
state.turnCount = turnCount;
|
|
92
|
-
onStreamUpdate?.();
|
|
93
|
-
},
|
|
94
|
-
onSessionCreated: (session: any) => {
|
|
95
|
-
state.session = session;
|
|
96
|
-
},
|
|
97
|
-
onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => {
|
|
98
|
-
addUsage(state.lifetimeUsage, usage);
|
|
99
|
-
onStreamUpdate?.();
|
|
100
|
-
},
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
return { state, callbacks };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Human-readable status label for agent completion. */
|
|
107
|
-
function getStatusLabel(status: string, error?: string): string {
|
|
108
|
-
switch (status) {
|
|
109
|
-
case "error": return `Error: ${error ?? "unknown"}`;
|
|
110
|
-
case "aborted": return "Aborted (max turns exceeded)";
|
|
111
|
-
case "steered": return "Wrapped up (turn limit)";
|
|
112
|
-
case "stopped": return "Stopped";
|
|
113
|
-
default: return "Done";
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Parenthetical status note for completed agent result text. */
|
|
118
|
-
function getStatusNote(status: string): string {
|
|
119
|
-
switch (status) {
|
|
120
|
-
case "aborted": return " (aborted — max turns exceeded, output may be incomplete)";
|
|
121
|
-
case "steered": return " (wrapped up — reached turn limit)";
|
|
122
|
-
case "stopped": return " (stopped by user)";
|
|
123
|
-
default: return "";
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Escape XML special characters to prevent injection in structured notifications. */
|
|
128
|
-
function escapeXml(s: string): string {
|
|
129
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Format a structured task notification matching Claude Code's <task-notification> XML. */
|
|
133
|
-
function formatTaskNotification(record: AgentRecord, resultMaxLen: number): string {
|
|
134
|
-
const status = getStatusLabel(record.status, record.error);
|
|
135
|
-
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
136
|
-
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
137
|
-
const contextPercent = getSessionContextPercent(record.session);
|
|
138
|
-
const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
|
|
139
|
-
const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
|
|
140
|
-
|
|
141
|
-
const resultPreview = record.result
|
|
142
|
-
? record.result.length > resultMaxLen
|
|
143
|
-
? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
|
|
144
|
-
: record.result
|
|
145
|
-
: "No output.";
|
|
146
|
-
|
|
147
|
-
return [
|
|
148
|
-
`<task-notification>`,
|
|
149
|
-
`<task-id>${record.id}</task-id>`,
|
|
150
|
-
record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
|
|
151
|
-
record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
|
|
152
|
-
`<status>${escapeXml(status)}</status>`,
|
|
153
|
-
`<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
|
|
154
|
-
`<result>${escapeXml(resultPreview)}</result>`,
|
|
155
|
-
`<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
|
|
156
|
-
`</task-notification>`,
|
|
157
|
-
].filter(Boolean).join('\n');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Build AgentDetails from a base + record-specific fields. */
|
|
161
|
-
function buildDetails(
|
|
162
|
-
base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
|
|
163
|
-
record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any; lifetimeUsage: LifetimeUsage },
|
|
164
|
-
activity?: AgentActivity,
|
|
165
|
-
overrides?: Partial<AgentDetails>,
|
|
166
|
-
): AgentDetails {
|
|
167
|
-
return {
|
|
168
|
-
...base,
|
|
169
|
-
toolUses: record.toolUses,
|
|
170
|
-
tokens: formatLifetimeTokens(record),
|
|
171
|
-
turnCount: activity?.turnCount,
|
|
172
|
-
maxTurns: activity?.maxTurns,
|
|
173
|
-
durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
|
|
174
|
-
status: record.status as AgentDetails["status"],
|
|
175
|
-
agentId: record.id,
|
|
176
|
-
error: record.error,
|
|
177
|
-
...overrides,
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Build notification details for the custom message renderer. */
|
|
182
|
-
function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
|
|
183
|
-
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
id: record.id,
|
|
187
|
-
description: record.description,
|
|
188
|
-
status: record.status,
|
|
189
|
-
toolUses: record.toolUses,
|
|
190
|
-
turnCount: activity?.turnCount ?? 0,
|
|
191
|
-
maxTurns: activity?.maxTurns,
|
|
192
|
-
totalTokens,
|
|
193
|
-
durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
|
|
194
|
-
outputFile: record.outputFile,
|
|
195
|
-
error: record.error,
|
|
196
|
-
resultPreview: record.result
|
|
197
|
-
? record.result.length > resultMaxLen
|
|
198
|
-
? record.result.slice(0, resultMaxLen) + "…"
|
|
199
|
-
: record.result
|
|
200
|
-
: "No output.",
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
36
|
|
|
204
37
|
export default function (pi: ExtensionAPI) {
|
|
205
38
|
// ---- Register custom notification renderer ----
|
|
206
|
-
pi.registerMessageRenderer<NotificationDetails>(
|
|
207
|
-
"subagent-notification",
|
|
208
|
-
(message, { expanded }, theme) => {
|
|
209
|
-
const d = message.details;
|
|
210
|
-
if (!d) return undefined;
|
|
211
|
-
|
|
212
|
-
function renderOne(d: NotificationDetails): string {
|
|
213
|
-
const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
|
|
214
|
-
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
215
|
-
const statusText = isError ? d.status
|
|
216
|
-
: d.status === "steered" ? "completed (steered)"
|
|
217
|
-
: "completed";
|
|
218
|
-
|
|
219
|
-
// Line 1: icon + agent description + status
|
|
220
|
-
let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
|
|
221
|
-
|
|
222
|
-
// Line 2: stats
|
|
223
|
-
const parts: string[] = [];
|
|
224
|
-
if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
|
|
225
|
-
if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
226
|
-
if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
|
|
227
|
-
if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
|
|
228
|
-
if (parts.length) {
|
|
229
|
-
line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Line 3: result preview (collapsed) or full (expanded)
|
|
233
|
-
if (expanded) {
|
|
234
|
-
const lines = d.resultPreview.split("\n").slice(0, 30);
|
|
235
|
-
for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
|
|
236
|
-
} else {
|
|
237
|
-
const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
|
|
238
|
-
line += "\n " + theme.fg("dim", `⎿ ${preview}`);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Line 4: output file link (if present)
|
|
242
|
-
if (d.outputFile) {
|
|
243
|
-
line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return line;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
return new Text(renderOne(d), 0, 0);
|
|
250
|
-
}
|
|
251
|
-
);
|
|
39
|
+
pi.registerMessageRenderer<NotificationDetails>("subagent-notification", createNotificationRenderer());
|
|
252
40
|
|
|
253
41
|
/** Reload agents from .pi/agents/*.md and merge with defaults (called on init and each Agent invocation). */
|
|
254
42
|
const reloadCustomAgents = () => {
|
|
@@ -259,79 +47,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
259
47
|
// Initial load
|
|
260
48
|
reloadCustomAgents();
|
|
261
49
|
|
|
262
|
-
// ---- Agent activity tracking
|
|
50
|
+
// ---- Agent activity tracking ----
|
|
263
51
|
const agentActivity = new Map<string, AgentActivity>();
|
|
264
52
|
|
|
265
|
-
// ----
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
pendingNudges.delete(key);
|
|
275
|
-
try { send(); } catch { /* ignore stale completion side-effect errors */ }
|
|
276
|
-
}, delay));
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function cancelNudge(key: string) {
|
|
280
|
-
const timer = pendingNudges.get(key);
|
|
281
|
-
if (timer != null) {
|
|
282
|
-
clearTimeout(timer);
|
|
283
|
-
pendingNudges.delete(key);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ---- Individual nudge helper (async join mode) ----
|
|
288
|
-
function emitIndividualNudge(record: AgentRecord) {
|
|
289
|
-
if (record.resultConsumed) return; // re-check at send time
|
|
290
|
-
|
|
291
|
-
const notification = formatTaskNotification(record, 500);
|
|
292
|
-
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
|
|
293
|
-
|
|
294
|
-
pi.sendMessage<NotificationDetails>({
|
|
295
|
-
customType: "subagent-notification",
|
|
296
|
-
content: notification + footer,
|
|
297
|
-
display: true,
|
|
298
|
-
details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
|
|
299
|
-
}, { deliverAs: "followUp", triggerTurn: true });
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function sendIndividualNudge(record: AgentRecord) {
|
|
303
|
-
agentActivity.delete(record.id);
|
|
304
|
-
widget.markFinished(record.id);
|
|
305
|
-
scheduleNudge(record.id, () => emitIndividualNudge(record));
|
|
306
|
-
widget.update();
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
310
|
-
function buildEventData(record: AgentRecord) {
|
|
311
|
-
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|
|
312
|
-
// All three fields are lifetime-accumulated (Σ over every assistant message_end),
|
|
313
|
-
// so they survive compaction together — input + output ≤ total always.
|
|
314
|
-
// tokens is omitted when nothing was ever produced (e.g. agent errored before
|
|
315
|
-
// any message_end fired), preserving prior payload shape.
|
|
316
|
-
const u = record.lifetimeUsage;
|
|
317
|
-
const total = getLifetimeTotal(u);
|
|
318
|
-
const tokens = total > 0
|
|
319
|
-
? { input: u.input, output: u.output, total }
|
|
320
|
-
: undefined;
|
|
321
|
-
return {
|
|
322
|
-
id: record.id,
|
|
323
|
-
type: record.type,
|
|
324
|
-
description: record.description,
|
|
325
|
-
result: record.result,
|
|
326
|
-
error: record.error,
|
|
327
|
-
status: record.status,
|
|
328
|
-
toolUses: record.toolUses,
|
|
329
|
-
durationMs,
|
|
330
|
-
tokens,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
53
|
+
// ---- Notification system ----
|
|
54
|
+
// Widget assigned after AgentManager construction; arrow closures capture by reference.
|
|
55
|
+
let widget: AgentWidget;
|
|
56
|
+
const notifications = createNotificationSystem({
|
|
57
|
+
sendMessage: (msg, opts) => pi.sendMessage(msg as any, opts as any),
|
|
58
|
+
agentActivity,
|
|
59
|
+
markFinished: (id) => widget.markFinished(id),
|
|
60
|
+
updateWidget: () => widget.update(),
|
|
61
|
+
});
|
|
333
62
|
|
|
334
|
-
// Background completion: emit lifecycle event and
|
|
63
|
+
// Background completion: emit lifecycle event and delegate to notification system
|
|
335
64
|
const manager = new AgentManager((record) => {
|
|
336
65
|
// Emit lifecycle event based on terminal status
|
|
337
66
|
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
@@ -351,14 +80,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
351
80
|
|
|
352
81
|
// Skip notification if result was already consumed via get_subagent_result
|
|
353
82
|
if (record.resultConsumed) {
|
|
354
|
-
|
|
355
|
-
widget.markFinished(record.id);
|
|
356
|
-
widget.update();
|
|
83
|
+
notifications.cleanupCompleted(record.id);
|
|
357
84
|
return;
|
|
358
85
|
}
|
|
359
86
|
|
|
360
|
-
|
|
361
|
-
widget.update();
|
|
87
|
+
notifications.sendCompletion(record);
|
|
362
88
|
}, undefined, (record) => {
|
|
363
89
|
// Emit started event when agent transitions to running (including from queue)
|
|
364
90
|
pi.events.emit("subagents:started", {
|
|
@@ -404,13 +130,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
404
130
|
unpublishSubagentsService();
|
|
405
131
|
currentCtx = undefined;
|
|
406
132
|
manager.abortAll();
|
|
407
|
-
|
|
408
|
-
pendingNudges.clear();
|
|
133
|
+
notifications.dispose();
|
|
409
134
|
manager.dispose();
|
|
410
135
|
});
|
|
411
136
|
|
|
412
137
|
// Live widget: show running agents above editor
|
|
413
|
-
|
|
138
|
+
widget = new AgentWidget(manager, agentActivity);
|
|
414
139
|
|
|
415
140
|
// Grab UI context from first tool execution + clear lingering widget on new turn
|
|
416
141
|
pi.on("tool_execution_start", async (_event, ctx) => {
|
|
@@ -443,14 +168,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
443
168
|
].join("\n");
|
|
444
169
|
};
|
|
445
170
|
|
|
446
|
-
/** Derive a short model label from a model string. */
|
|
447
|
-
function getModelLabelFromConfig(model: string): string {
|
|
448
|
-
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
|
|
449
|
-
const name = model.includes("/") ? model.split("/").pop()! : model;
|
|
450
|
-
// Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5")
|
|
451
|
-
return name.replace(/-\d{8}$/, "");
|
|
452
|
-
}
|
|
453
|
-
|
|
454
171
|
const typeListText = buildTypeListText();
|
|
455
172
|
|
|
456
173
|
// Apply persisted settings on startup and emit `subagents:settings_loaded`.
|
|
@@ -467,1153 +184,82 @@ export default function (pi: ExtensionAPI) {
|
|
|
467
184
|
|
|
468
185
|
// ---- Agent tool ----
|
|
469
186
|
|
|
470
|
-
pi.registerTool(defineTool({
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
${typeListText}
|
|
479
|
-
|
|
480
|
-
Guidelines:
|
|
481
|
-
- For parallel work, use run_in_background: true on each agent. Foreground calls run sequentially — only one executes at a time.
|
|
482
|
-
- Use Explore for codebase searches and code understanding.
|
|
483
|
-
- Use Plan for architecture and implementation planning.
|
|
484
|
-
- Use general-purpose for complex tasks that need file editing.
|
|
485
|
-
- Provide clear, detailed prompts so the agent can work autonomously.
|
|
486
|
-
- Agent results are returned as text — summarize them for the user.
|
|
487
|
-
- Use run_in_background for work you don't need immediately. You will be notified when it completes.
|
|
488
|
-
- Use resume with an agent ID to continue a previous agent's work.
|
|
489
|
-
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
490
|
-
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
491
|
-
- Use thinking to control extended thinking level.
|
|
492
|
-
- Use inherit_context if the agent needs the parent conversation history.
|
|
493
|
-
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).`,
|
|
494
|
-
parameters: Type.Object({
|
|
495
|
-
prompt: Type.String({
|
|
496
|
-
description: "The task for the agent to perform.",
|
|
497
|
-
}),
|
|
498
|
-
description: Type.String({
|
|
499
|
-
description: "A short (3-5 word) description of the task (shown in UI).",
|
|
500
|
-
}),
|
|
501
|
-
subagent_type: Type.String({
|
|
502
|
-
description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`,
|
|
503
|
-
}),
|
|
504
|
-
model: Type.Optional(
|
|
505
|
-
Type.String({
|
|
506
|
-
description:
|
|
507
|
-
'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
|
|
508
|
-
}),
|
|
509
|
-
),
|
|
510
|
-
thinking: Type.Optional(
|
|
511
|
-
Type.String({
|
|
512
|
-
description: "Thinking level: off, minimal, low, medium, high, xhigh. Overrides agent default.",
|
|
513
|
-
}),
|
|
514
|
-
),
|
|
515
|
-
max_turns: Type.Optional(
|
|
516
|
-
Type.Number({
|
|
517
|
-
description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
|
|
518
|
-
minimum: 1,
|
|
519
|
-
}),
|
|
520
|
-
),
|
|
521
|
-
run_in_background: Type.Optional(
|
|
522
|
-
Type.Boolean({
|
|
523
|
-
description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
|
|
524
|
-
}),
|
|
525
|
-
),
|
|
526
|
-
resume: Type.Optional(
|
|
527
|
-
Type.String({
|
|
528
|
-
description: "Optional agent ID to resume from. Continues from previous context.",
|
|
529
|
-
}),
|
|
530
|
-
),
|
|
531
|
-
isolated: Type.Optional(
|
|
532
|
-
Type.Boolean({
|
|
533
|
-
description: "If true, agent gets no extension/MCP tools — only built-in tools.",
|
|
534
|
-
}),
|
|
535
|
-
),
|
|
536
|
-
inherit_context: Type.Optional(
|
|
537
|
-
Type.Boolean({
|
|
538
|
-
description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
|
|
539
|
-
}),
|
|
540
|
-
),
|
|
541
|
-
isolation: Type.Optional(
|
|
542
|
-
Type.Literal("worktree", {
|
|
543
|
-
description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
|
|
544
|
-
}),
|
|
545
|
-
),
|
|
546
|
-
}),
|
|
547
|
-
|
|
548
|
-
// ---- Custom rendering: Claude Code style ----
|
|
549
|
-
|
|
550
|
-
renderCall(args, theme) {
|
|
551
|
-
const displayName = args.subagent_type ? getDisplayName(args.subagent_type) : "Agent";
|
|
552
|
-
const desc = args.description ?? "";
|
|
553
|
-
return new Text("▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
|
|
554
|
-
},
|
|
555
|
-
|
|
556
|
-
renderResult(result, { expanded, isPartial }, theme) {
|
|
557
|
-
const details = result.details as AgentDetails | undefined;
|
|
558
|
-
if (!details) {
|
|
559
|
-
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
560
|
-
return new Text(text, 0, 0);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// Helper: build "haiku · thinking: high · ⟳5≤30 · 3 tool uses · 33.8k tokens" stats string
|
|
564
|
-
const stats = (d: AgentDetails) => {
|
|
565
|
-
const parts: string[] = [];
|
|
566
|
-
if (d.modelName) parts.push(d.modelName);
|
|
567
|
-
if (d.tags) parts.push(...d.tags);
|
|
568
|
-
if (d.turnCount != null && d.turnCount > 0) {
|
|
569
|
-
parts.push(formatTurns(d.turnCount, d.maxTurns));
|
|
570
|
-
}
|
|
571
|
-
if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
572
|
-
if (d.tokens) parts.push(d.tokens);
|
|
573
|
-
return parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
|
|
574
|
-
};
|
|
575
|
-
|
|
576
|
-
// ---- While running (streaming) ----
|
|
577
|
-
if (isPartial || details.status === "running") {
|
|
578
|
-
const frame = SPINNER[details.spinnerFrame ?? 0];
|
|
579
|
-
const s = stats(details);
|
|
580
|
-
let line = theme.fg("accent", frame) + (s ? " " + s : "");
|
|
581
|
-
line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`);
|
|
582
|
-
return new Text(line, 0, 0);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// ---- Background agent launched ----
|
|
586
|
-
if (details.status === "background") {
|
|
587
|
-
return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0);
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// ---- Completed / Steered ----
|
|
591
|
-
if (details.status === "completed" || details.status === "steered") {
|
|
592
|
-
const duration = formatMs(details.durationMs);
|
|
593
|
-
const isSteered = details.status === "steered";
|
|
594
|
-
const icon = isSteered ? theme.fg("warning", "✓") : theme.fg("success", "✓");
|
|
595
|
-
const s = stats(details);
|
|
596
|
-
let line = icon + (s ? " " + s : "");
|
|
597
|
-
line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration);
|
|
598
|
-
|
|
599
|
-
if (expanded) {
|
|
600
|
-
const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
601
|
-
if (resultText) {
|
|
602
|
-
const lines = resultText.split("\n").slice(0, 50);
|
|
603
|
-
for (const l of lines) {
|
|
604
|
-
line += "\n" + theme.fg("dim", ` ${l}`);
|
|
605
|
-
}
|
|
606
|
-
if (resultText.split("\n").length > 50) {
|
|
607
|
-
line += "\n" + theme.fg("muted", " ... (use get_subagent_result with verbose for full output)");
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
} else {
|
|
611
|
-
const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
|
|
612
|
-
line += "\n" + theme.fg("dim", ` ⎿ ${doneText}`);
|
|
613
|
-
}
|
|
614
|
-
return new Text(line, 0, 0);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// ---- Stopped (user-initiated abort) ----
|
|
618
|
-
if (details.status === "stopped") {
|
|
619
|
-
const s = stats(details);
|
|
620
|
-
let line = theme.fg("dim", "■") + (s ? " " + s : "");
|
|
621
|
-
line += "\n" + theme.fg("dim", " ⎿ Stopped");
|
|
622
|
-
return new Text(line, 0, 0);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// ---- Error / Aborted (hard max_turns) ----
|
|
626
|
-
const s = stats(details);
|
|
627
|
-
let line = theme.fg("error", "✗") + (s ? " " + s : "");
|
|
628
|
-
|
|
629
|
-
if (details.status === "error") {
|
|
630
|
-
line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`);
|
|
631
|
-
} else {
|
|
632
|
-
line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)");
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
return new Text(line, 0, 0);
|
|
187
|
+
pi.registerTool(defineTool(createAgentTool({
|
|
188
|
+
manager: {
|
|
189
|
+
spawn: (ctx, type, prompt, opts) => manager.spawn(pi, ctx as any, type, prompt, opts as any),
|
|
190
|
+
spawnAndWait: (ctx, type, prompt, opts) => manager.spawnAndWait(pi, ctx as any, type, prompt, opts as any),
|
|
191
|
+
resume: (id, prompt, signal) => manager.resume(id, prompt, signal),
|
|
192
|
+
getRecord: (id) => manager.getRecord(id),
|
|
193
|
+
getMaxConcurrent: () => manager.getMaxConcurrent(),
|
|
194
|
+
listAgents: () => manager.listAgents(),
|
|
636
195
|
},
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
widget.setUICtx(ctx.ui as UICtx);
|
|
643
|
-
|
|
644
|
-
// Reload custom agents so new .pi/agents/*.md files are picked up without restart
|
|
645
|
-
reloadCustomAgents();
|
|
646
|
-
|
|
647
|
-
const rawType = params.subagent_type as SubagentType;
|
|
648
|
-
const resolved = resolveType(rawType);
|
|
649
|
-
const subagentType = resolved ?? "general-purpose";
|
|
650
|
-
const fellBack = resolved === undefined;
|
|
651
|
-
|
|
652
|
-
const displayName = getDisplayName(subagentType);
|
|
653
|
-
|
|
654
|
-
// Get agent config (if any)
|
|
655
|
-
const customConfig = getAgentConfig(subagentType);
|
|
656
|
-
|
|
657
|
-
const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
|
|
658
|
-
|
|
659
|
-
// Resolve model from agent config first; tool-call params only fill gaps.
|
|
660
|
-
const resolution = resolveInvocationModel(
|
|
661
|
-
ctx.model,
|
|
662
|
-
resolvedConfig.modelInput,
|
|
663
|
-
resolvedConfig.modelFromParams,
|
|
664
|
-
ctx.modelRegistry,
|
|
665
|
-
);
|
|
666
|
-
if (resolution.error) return textResult(resolution.error);
|
|
667
|
-
const model = resolution.model;
|
|
668
|
-
|
|
669
|
-
const thinking = resolvedConfig.thinking;
|
|
670
|
-
const inheritContext = resolvedConfig.inheritContext;
|
|
671
|
-
const runInBackground = resolvedConfig.runInBackground;
|
|
672
|
-
const isolated = resolvedConfig.isolated;
|
|
673
|
-
const isolation = resolvedConfig.isolation;
|
|
674
|
-
|
|
675
|
-
const parentModelId = ctx.model?.id;
|
|
676
|
-
const effectiveModelId = model?.id;
|
|
677
|
-
const modelName = effectiveModelId && effectiveModelId !== parentModelId
|
|
678
|
-
? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
|
|
679
|
-
: undefined;
|
|
680
|
-
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
|
|
681
|
-
const agentInvocation: AgentInvocation = {
|
|
682
|
-
modelName,
|
|
683
|
-
thinking,
|
|
684
|
-
// Explicit value only — the default fallback would just add noise.
|
|
685
|
-
// Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
|
|
686
|
-
maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
|
|
687
|
-
isolated,
|
|
688
|
-
inheritContext,
|
|
689
|
-
runInBackground,
|
|
690
|
-
isolation,
|
|
691
|
-
};
|
|
692
|
-
// Tool-result render shows the mode label too; viewer's header already does.
|
|
693
|
-
const modeLabel = getPromptModeLabel(subagentType);
|
|
694
|
-
const { tags: invocationTags } = buildInvocationTags(agentInvocation);
|
|
695
|
-
const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
|
|
696
|
-
const detailBase = {
|
|
697
|
-
displayName,
|
|
698
|
-
description: params.description,
|
|
699
|
-
subagentType,
|
|
700
|
-
modelName,
|
|
701
|
-
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
702
|
-
};
|
|
703
|
-
|
|
704
|
-
// Resume existing agent
|
|
705
|
-
if (params.resume) {
|
|
706
|
-
const existing = manager.getRecord(params.resume);
|
|
707
|
-
if (!existing) {
|
|
708
|
-
return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
|
|
709
|
-
}
|
|
710
|
-
if (!existing.session) {
|
|
711
|
-
return textResult(`Agent "${params.resume}" has no active session to resume.`);
|
|
712
|
-
}
|
|
713
|
-
const record = await manager.resume(params.resume, params.prompt, signal);
|
|
714
|
-
if (!record) {
|
|
715
|
-
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
716
|
-
}
|
|
717
|
-
return textResult(
|
|
718
|
-
record.result?.trim() || record.error?.trim() || "No output.",
|
|
719
|
-
buildDetails(detailBase, record),
|
|
720
|
-
);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// Background execution
|
|
724
|
-
if (runInBackground) {
|
|
725
|
-
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
|
|
726
|
-
|
|
727
|
-
// Wrap onSessionCreated to wire output file streaming.
|
|
728
|
-
// The callback lazily reads record.outputFile (set right after spawn)
|
|
729
|
-
// rather than closing over a value that doesn't exist yet.
|
|
730
|
-
let id: string;
|
|
731
|
-
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
732
|
-
bgCallbacks.onSessionCreated = (session: any) => {
|
|
733
|
-
origBgOnSession(session);
|
|
734
|
-
const rec = manager.getRecord(id);
|
|
735
|
-
if (rec?.outputFile) {
|
|
736
|
-
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
|
|
737
|
-
}
|
|
738
|
-
};
|
|
739
|
-
|
|
740
|
-
try {
|
|
741
|
-
id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
742
|
-
description: params.description,
|
|
743
|
-
model,
|
|
744
|
-
maxTurns: effectiveMaxTurns,
|
|
745
|
-
isolated,
|
|
746
|
-
inheritContext,
|
|
747
|
-
thinkingLevel: thinking,
|
|
748
|
-
isBackground: true,
|
|
749
|
-
isolation,
|
|
750
|
-
invocation: agentInvocation,
|
|
751
|
-
...bgCallbacks,
|
|
752
|
-
});
|
|
753
|
-
} catch (err) {
|
|
754
|
-
return textResult(err instanceof Error ? err.message : String(err));
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
// Set output file synchronously after spawn, before the
|
|
758
|
-
// event loop yields — onSessionCreated is async so this is safe.
|
|
759
|
-
const record = manager.getRecord(id);
|
|
760
|
-
if (record) {
|
|
761
|
-
record.toolCallId = toolCallId;
|
|
762
|
-
record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
|
|
763
|
-
writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
agentActivity.set(id, bgState);
|
|
767
|
-
widget.ensureTimer();
|
|
768
|
-
widget.update();
|
|
769
|
-
|
|
770
|
-
// Emit created event
|
|
771
|
-
pi.events.emit("subagents:created", {
|
|
772
|
-
id,
|
|
773
|
-
type: subagentType,
|
|
774
|
-
description: params.description,
|
|
775
|
-
isBackground: true,
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
const isQueued = record?.status === "queued";
|
|
779
|
-
return textResult(
|
|
780
|
-
`Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
781
|
-
`Agent ID: ${id}\n` +
|
|
782
|
-
`Type: ${displayName}\n` +
|
|
783
|
-
`Description: ${params.description}\n` +
|
|
784
|
-
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
785
|
-
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
786
|
-
`\nYou will be notified when this agent completes.\n` +
|
|
787
|
-
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
788
|
-
`Do not duplicate this agent's work.`,
|
|
789
|
-
{ ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
|
|
790
|
-
);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// Foreground (synchronous) execution — stream progress via onUpdate
|
|
794
|
-
let spinnerFrame = 0;
|
|
795
|
-
const startedAt = Date.now();
|
|
796
|
-
let fgId: string | undefined;
|
|
797
|
-
|
|
798
|
-
const streamUpdate = () => {
|
|
799
|
-
const details: AgentDetails = {
|
|
800
|
-
...detailBase,
|
|
801
|
-
toolUses: fgState.toolUses,
|
|
802
|
-
tokens: formatLifetimeTokens(fgState),
|
|
803
|
-
turnCount: fgState.turnCount,
|
|
804
|
-
maxTurns: fgState.maxTurns,
|
|
805
|
-
durationMs: Date.now() - startedAt,
|
|
806
|
-
status: "running",
|
|
807
|
-
activity: describeActivity(fgState.activeTools, fgState.responseText),
|
|
808
|
-
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
809
|
-
};
|
|
810
|
-
onUpdate?.({
|
|
811
|
-
content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
|
|
812
|
-
details: details as any,
|
|
813
|
-
});
|
|
814
|
-
};
|
|
815
|
-
|
|
816
|
-
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
|
|
817
|
-
|
|
818
|
-
// Wire session creation to register in widget
|
|
819
|
-
const origOnSession = fgCallbacks.onSessionCreated;
|
|
820
|
-
fgCallbacks.onSessionCreated = (session: any) => {
|
|
821
|
-
origOnSession(session);
|
|
822
|
-
for (const a of manager.listAgents()) {
|
|
823
|
-
if (a.session === session) {
|
|
824
|
-
fgId = a.id;
|
|
825
|
-
agentActivity.set(a.id, fgState);
|
|
826
|
-
widget.ensureTimer();
|
|
827
|
-
break;
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
};
|
|
831
|
-
|
|
832
|
-
// Animate spinner at ~80ms (smooth rotation through 10 braille frames)
|
|
833
|
-
const spinnerInterval = setInterval(() => {
|
|
834
|
-
spinnerFrame++;
|
|
835
|
-
streamUpdate();
|
|
836
|
-
}, 80);
|
|
837
|
-
|
|
838
|
-
streamUpdate();
|
|
839
|
-
|
|
840
|
-
let record: AgentRecord;
|
|
841
|
-
try {
|
|
842
|
-
record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
|
|
843
|
-
description: params.description,
|
|
844
|
-
model,
|
|
845
|
-
maxTurns: effectiveMaxTurns,
|
|
846
|
-
isolated,
|
|
847
|
-
inheritContext,
|
|
848
|
-
thinkingLevel: thinking,
|
|
849
|
-
isolation,
|
|
850
|
-
invocation: agentInvocation,
|
|
851
|
-
signal,
|
|
852
|
-
...fgCallbacks,
|
|
853
|
-
});
|
|
854
|
-
} catch (err) {
|
|
855
|
-
clearInterval(spinnerInterval);
|
|
856
|
-
return textResult(err instanceof Error ? err.message : String(err));
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
clearInterval(spinnerInterval);
|
|
860
|
-
|
|
861
|
-
// Clean up foreground agent from widget
|
|
862
|
-
if (fgId) {
|
|
863
|
-
agentActivity.delete(fgId);
|
|
864
|
-
widget.markFinished(fgId);
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
// Get final token count
|
|
868
|
-
const tokenText = formatLifetimeTokens(fgState);
|
|
869
|
-
|
|
870
|
-
const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
|
|
871
|
-
|
|
872
|
-
const fallbackNote = fellBack
|
|
873
|
-
? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
|
|
874
|
-
: "";
|
|
875
|
-
|
|
876
|
-
if (record.status === "error") {
|
|
877
|
-
return textResult(`${fallbackNote}Agent failed: ${record.error}`, details);
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
|
|
881
|
-
const statsParts = [`${record.toolUses} tool uses`];
|
|
882
|
-
if (tokenText) statsParts.push(tokenText);
|
|
883
|
-
return textResult(
|
|
884
|
-
`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
|
|
885
|
-
(record.result?.trim() || "No output."),
|
|
886
|
-
details,
|
|
887
|
-
);
|
|
196
|
+
widget: {
|
|
197
|
+
setUICtx: (ctx) => widget.setUICtx(ctx as UICtx),
|
|
198
|
+
ensureTimer: () => widget.ensureTimer(),
|
|
199
|
+
update: () => widget.update(),
|
|
200
|
+
markFinished: (id) => widget.markFinished(id),
|
|
888
201
|
},
|
|
889
|
-
|
|
202
|
+
agentActivity,
|
|
203
|
+
emitEvent: (name, data) => pi.events.emit(name, data),
|
|
204
|
+
reloadCustomAgents,
|
|
205
|
+
typeListText,
|
|
206
|
+
availableTypesText: getAvailableTypes().join(", "),
|
|
207
|
+
agentDir: getAgentDir(),
|
|
208
|
+
}) as any));
|
|
890
209
|
|
|
891
210
|
// ---- get_subagent_result tool ----
|
|
892
211
|
|
|
893
|
-
pi.registerTool(defineTool({
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
parameters: Type.Object({
|
|
899
|
-
agent_id: Type.String({
|
|
900
|
-
description: "The agent ID to check.",
|
|
901
|
-
}),
|
|
902
|
-
wait: Type.Optional(
|
|
903
|
-
Type.Boolean({
|
|
904
|
-
description: "If true, wait for the agent to complete before returning. Default: false.",
|
|
905
|
-
}),
|
|
906
|
-
),
|
|
907
|
-
verbose: Type.Optional(
|
|
908
|
-
Type.Boolean({
|
|
909
|
-
description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
|
|
910
|
-
}),
|
|
911
|
-
),
|
|
912
|
-
}),
|
|
913
|
-
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
|
|
914
|
-
const record = manager.getRecord(params.agent_id);
|
|
915
|
-
if (!record) {
|
|
916
|
-
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
// Wait for completion if requested.
|
|
920
|
-
// Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
|
|
921
|
-
// (attached earlier at spawn time) and always runs before this await resumes.
|
|
922
|
-
// Setting the flag here prevents a redundant follow-up notification.
|
|
923
|
-
if (params.wait && record.status === "running" && record.promise) {
|
|
924
|
-
record.resultConsumed = true;
|
|
925
|
-
cancelNudge(params.agent_id);
|
|
926
|
-
await record.promise;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
const displayName = getDisplayName(record.type);
|
|
930
|
-
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
931
|
-
const tokens = formatLifetimeTokens(record);
|
|
932
|
-
const contextPercent = getSessionContextPercent(record.session);
|
|
933
|
-
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
934
|
-
if (tokens) statsParts.push(tokens);
|
|
935
|
-
if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
|
|
936
|
-
if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
937
|
-
statsParts.push(`Duration: ${duration}`);
|
|
938
|
-
|
|
939
|
-
let output =
|
|
940
|
-
`Agent: ${record.id}\n` +
|
|
941
|
-
`Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
|
|
942
|
-
`Description: ${record.description}\n\n`;
|
|
943
|
-
|
|
944
|
-
if (record.status === "running") {
|
|
945
|
-
output += "Agent is still running. Use wait: true or check back later.";
|
|
946
|
-
} else if (record.status === "error") {
|
|
947
|
-
output += `Error: ${record.error}`;
|
|
948
|
-
} else {
|
|
949
|
-
output += record.result?.trim() || "No output.";
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// Mark result as consumed — suppresses the completion notification
|
|
953
|
-
if (record.status !== "running" && record.status !== "queued") {
|
|
954
|
-
record.resultConsumed = true;
|
|
955
|
-
cancelNudge(params.agent_id);
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
// Verbose: include full conversation
|
|
959
|
-
if (params.verbose && record.session) {
|
|
960
|
-
const conversation = getAgentConversation(record.session);
|
|
961
|
-
if (conversation) {
|
|
962
|
-
output += `\n\n--- Agent Conversation ---\n${conversation}`;
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
return textResult(output);
|
|
967
|
-
},
|
|
968
|
-
}));
|
|
212
|
+
pi.registerTool(defineTool(createGetResultTool({
|
|
213
|
+
getRecord: (id) => manager.getRecord(id),
|
|
214
|
+
cancelNudge: (key) => notifications.cancelNudge(key),
|
|
215
|
+
getConversation: (session) => getAgentConversation(session as any),
|
|
216
|
+
})));
|
|
969
217
|
|
|
970
218
|
// ---- steer_subagent tool ----
|
|
971
219
|
|
|
972
|
-
pi.registerTool(defineTool({
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
"and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
|
|
978
|
-
parameters: Type.Object({
|
|
979
|
-
agent_id: Type.String({
|
|
980
|
-
description: "The agent ID to steer (must be currently running).",
|
|
981
|
-
}),
|
|
982
|
-
message: Type.String({
|
|
983
|
-
description: "The steering message to send. This will appear as a user message in the agent's conversation.",
|
|
984
|
-
}),
|
|
985
|
-
}),
|
|
986
|
-
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
|
|
987
|
-
const record = manager.getRecord(params.agent_id);
|
|
988
|
-
if (!record) {
|
|
989
|
-
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
990
|
-
}
|
|
991
|
-
if (record.status !== "running") {
|
|
992
|
-
return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`);
|
|
993
|
-
}
|
|
994
|
-
if (!record.session) {
|
|
995
|
-
// Session not ready yet — queue the steer for delivery once initialized
|
|
996
|
-
if (!record.pendingSteers) record.pendingSteers = [];
|
|
997
|
-
record.pendingSteers.push(params.message);
|
|
998
|
-
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
999
|
-
return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
try {
|
|
1003
|
-
await steerAgent(record.session, params.message);
|
|
1004
|
-
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
1005
|
-
const tokens = formatLifetimeTokens(record);
|
|
1006
|
-
const contextPercent = getSessionContextPercent(record.session);
|
|
1007
|
-
const stateParts: string[] = [];
|
|
1008
|
-
if (tokens) stateParts.push(tokens);
|
|
1009
|
-
stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
|
|
1010
|
-
if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
|
|
1011
|
-
if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
|
|
1012
|
-
return textResult(
|
|
1013
|
-
`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
|
|
1014
|
-
`Current state: ${stateParts.join(" · ")}`,
|
|
1015
|
-
);
|
|
1016
|
-
} catch (err) {
|
|
1017
|
-
return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
|
|
1018
|
-
}
|
|
1019
|
-
},
|
|
1020
|
-
}));
|
|
220
|
+
pi.registerTool(defineTool(createSteerTool({
|
|
221
|
+
getRecord: (id) => manager.getRecord(id),
|
|
222
|
+
emitEvent: (name, data) => pi.events.emit(name, data),
|
|
223
|
+
steerAgent: (session, message) => steerAgent(session as any, message),
|
|
224
|
+
})));
|
|
1021
225
|
|
|
1022
226
|
// ---- /agents interactive menu ----
|
|
1023
227
|
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
if (registry) {
|
|
1041
|
-
const resolved = resolveModel(cfg.model, registry);
|
|
1042
|
-
if (typeof resolved === "string") return "inherit"; // model not available
|
|
1043
|
-
}
|
|
1044
|
-
return getModelLabelFromConfig(cfg.model);
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
async function showAgentsMenu(ctx: ExtensionCommandContext) {
|
|
1048
|
-
reloadCustomAgents();
|
|
1049
|
-
const allNames = getAllTypes();
|
|
1050
|
-
|
|
1051
|
-
// Build select options
|
|
1052
|
-
const options: string[] = [];
|
|
1053
|
-
|
|
1054
|
-
// Running agents entry (only if there are active agents)
|
|
1055
|
-
const agents = manager.listAgents();
|
|
1056
|
-
if (agents.length > 0) {
|
|
1057
|
-
const running = agents.filter(a => a.status === "running" || a.status === "queued").length;
|
|
1058
|
-
const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
|
|
1059
|
-
options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
// Agent types list
|
|
1063
|
-
if (allNames.length > 0) {
|
|
1064
|
-
options.push(`Agent types (${allNames.length})`);
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
// Actions
|
|
1068
|
-
options.push("Create new agent");
|
|
1069
|
-
options.push("Settings");
|
|
1070
|
-
|
|
1071
|
-
const noAgentsMsg = allNames.length === 0 && agents.length === 0
|
|
1072
|
-
? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
|
|
1073
|
-
"Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
|
|
1074
|
-
"Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
|
|
1075
|
-
: "";
|
|
1076
|
-
|
|
1077
|
-
if (noAgentsMsg) {
|
|
1078
|
-
ctx.ui.notify(noAgentsMsg, "info");
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
const choice = await ctx.ui.select("Agents", options);
|
|
1082
|
-
if (!choice) return;
|
|
1083
|
-
|
|
1084
|
-
if (choice.startsWith("Running agents (")) {
|
|
1085
|
-
await showRunningAgents(ctx);
|
|
1086
|
-
await showAgentsMenu(ctx);
|
|
1087
|
-
} else if (choice.startsWith("Agent types (")) {
|
|
1088
|
-
await showAllAgentsList(ctx);
|
|
1089
|
-
await showAgentsMenu(ctx);
|
|
1090
|
-
} else if (choice === "Create new agent") {
|
|
1091
|
-
await showCreateWizard(ctx);
|
|
1092
|
-
} else if (choice === "Settings") {
|
|
1093
|
-
await showSettings(ctx);
|
|
1094
|
-
await showAgentsMenu(ctx);
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
async function showAllAgentsList(ctx: ExtensionCommandContext) {
|
|
1099
|
-
const allNames = getAllTypes();
|
|
1100
|
-
if (allNames.length === 0) {
|
|
1101
|
-
ctx.ui.notify("No agents.", "info");
|
|
1102
|
-
return;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
// Source indicators: defaults unmarked, custom agents get • (project) or ◦ (global)
|
|
1106
|
-
// Disabled agents get ✕ prefix
|
|
1107
|
-
const sourceIndicator = (cfg: AgentConfig | undefined) => {
|
|
1108
|
-
const disabled = cfg?.enabled === false;
|
|
1109
|
-
if (cfg?.source === "project") return disabled ? "✕• " : "• ";
|
|
1110
|
-
if (cfg?.source === "global") return disabled ? "✕◦ " : "◦ ";
|
|
1111
|
-
if (disabled) return "✕ ";
|
|
1112
|
-
return " ";
|
|
1113
|
-
};
|
|
1114
|
-
|
|
1115
|
-
const entries = allNames.map(name => {
|
|
1116
|
-
const cfg = getAgentConfig(name);
|
|
1117
|
-
const disabled = cfg?.enabled === false;
|
|
1118
|
-
const model = getModelLabel(name, ctx.modelRegistry);
|
|
1119
|
-
const indicator = sourceIndicator(cfg);
|
|
1120
|
-
const prefix = `${indicator}${name} · ${model}`;
|
|
1121
|
-
const desc = disabled ? "(disabled)" : (cfg?.description ?? name);
|
|
1122
|
-
return { name, prefix, desc };
|
|
1123
|
-
});
|
|
1124
|
-
const maxPrefix = Math.max(...entries.map(e => e.prefix.length));
|
|
1125
|
-
|
|
1126
|
-
const hasCustom = allNames.some(n => { const c = getAgentConfig(n); return c && !c.isDefault && c.enabled !== false; });
|
|
1127
|
-
const hasDisabled = allNames.some(n => getAgentConfig(n)?.enabled === false);
|
|
1128
|
-
const legendParts: string[] = [];
|
|
1129
|
-
if (hasCustom) legendParts.push("• = project ◦ = global");
|
|
1130
|
-
if (hasDisabled) legendParts.push("✕ = disabled");
|
|
1131
|
-
const legend = legendParts.length ? "\n" + legendParts.join(" ") : "";
|
|
1132
|
-
|
|
1133
|
-
const options = entries.map(({ prefix, desc }) =>
|
|
1134
|
-
`${prefix.padEnd(maxPrefix)} — ${desc}`,
|
|
1135
|
-
);
|
|
1136
|
-
if (legend) options.push(legend);
|
|
1137
|
-
|
|
1138
|
-
const choice = await ctx.ui.select("Agent types", options);
|
|
1139
|
-
if (!choice) return;
|
|
1140
|
-
|
|
1141
|
-
const agentName = choice.split(" · ")[0].replace(/^[•◦✕\s]+/, "").trim();
|
|
1142
|
-
if (getAgentConfig(agentName)) {
|
|
1143
|
-
await showAgentDetail(ctx, agentName);
|
|
1144
|
-
await showAllAgentsList(ctx);
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
async function showRunningAgents(ctx: ExtensionCommandContext) {
|
|
1149
|
-
const agents = manager.listAgents();
|
|
1150
|
-
if (agents.length === 0) {
|
|
1151
|
-
ctx.ui.notify("No agents.", "info");
|
|
1152
|
-
return;
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
const options = agents.map(a => {
|
|
1156
|
-
const dn = getDisplayName(a.type);
|
|
1157
|
-
const dur = formatDuration(a.startedAt, a.completedAt);
|
|
1158
|
-
return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
|
|
1159
|
-
});
|
|
1160
|
-
|
|
1161
|
-
const choice = await ctx.ui.select("Running agents", options);
|
|
1162
|
-
if (!choice) return;
|
|
1163
|
-
|
|
1164
|
-
// Find the selected agent by matching the option index
|
|
1165
|
-
const idx = options.indexOf(choice);
|
|
1166
|
-
if (idx < 0) return;
|
|
1167
|
-
const record = agents[idx];
|
|
1168
|
-
|
|
1169
|
-
await viewAgentConversation(ctx, record);
|
|
1170
|
-
// Back-navigation: re-show the list
|
|
1171
|
-
await showRunningAgents(ctx);
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) {
|
|
1175
|
-
if (!record.session) {
|
|
1176
|
-
ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
|
|
1177
|
-
return;
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
|
|
1181
|
-
const session = record.session;
|
|
1182
|
-
const activity = agentActivity.get(record.id);
|
|
1183
|
-
|
|
1184
|
-
await ctx.ui.custom<undefined>(
|
|
1185
|
-
(tui, theme, _keybindings, done) => {
|
|
1186
|
-
return new ConversationViewer(tui, session, record, activity, theme, done);
|
|
1187
|
-
},
|
|
1188
|
-
{
|
|
1189
|
-
overlay: true,
|
|
1190
|
-
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
1191
|
-
},
|
|
1192
|
-
);
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
|
|
1196
|
-
const cfg = getAgentConfig(name);
|
|
1197
|
-
if (!cfg) {
|
|
1198
|
-
ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
|
|
1199
|
-
return;
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
const file = findAgentFile(name);
|
|
1203
|
-
const isDefault = cfg.isDefault === true;
|
|
1204
|
-
const disabled = cfg.enabled === false;
|
|
1205
|
-
|
|
1206
|
-
let menuOptions: string[];
|
|
1207
|
-
if (disabled && file) {
|
|
1208
|
-
// Disabled agent with a file — offer Enable
|
|
1209
|
-
menuOptions = isDefault
|
|
1210
|
-
? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
|
|
1211
|
-
: ["Enable", "Edit", "Delete", "Back"];
|
|
1212
|
-
} else if (isDefault && !file) {
|
|
1213
|
-
// Default agent with no .md override
|
|
1214
|
-
menuOptions = ["Eject (export as .md)", "Disable", "Back"];
|
|
1215
|
-
} else if (isDefault && file) {
|
|
1216
|
-
// Default agent with .md override (ejected)
|
|
1217
|
-
menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"];
|
|
1218
|
-
} else {
|
|
1219
|
-
// User-defined agent
|
|
1220
|
-
menuOptions = ["Edit", "Disable", "Delete", "Back"];
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
const choice = await ctx.ui.select(name, menuOptions);
|
|
1224
|
-
if (!choice || choice === "Back") return;
|
|
1225
|
-
|
|
1226
|
-
if (choice === "Edit" && file) {
|
|
1227
|
-
const content = readFileSync(file.path, "utf-8");
|
|
1228
|
-
const edited = await ctx.ui.editor(`Edit ${name}`, content);
|
|
1229
|
-
if (edited !== undefined && edited !== content) {
|
|
1230
|
-
const { writeFileSync } = await import("node:fs");
|
|
1231
|
-
writeFileSync(file.path, edited, "utf-8");
|
|
1232
|
-
reloadCustomAgents();
|
|
1233
|
-
ctx.ui.notify(`Updated ${file.path}`, "info");
|
|
1234
|
-
}
|
|
1235
|
-
} else if (choice === "Delete") {
|
|
1236
|
-
if (file) {
|
|
1237
|
-
const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
|
|
1238
|
-
if (confirmed) {
|
|
1239
|
-
unlinkSync(file.path);
|
|
1240
|
-
reloadCustomAgents();
|
|
1241
|
-
ctx.ui.notify(`Deleted ${file.path}`, "info");
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
} else if (choice === "Reset to default" && file) {
|
|
1245
|
-
const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${file.path} and restore embedded default?`);
|
|
1246
|
-
if (confirmed) {
|
|
1247
|
-
unlinkSync(file.path);
|
|
1248
|
-
reloadCustomAgents();
|
|
1249
|
-
ctx.ui.notify(`Restored default ${name}`, "info");
|
|
1250
|
-
}
|
|
1251
|
-
} else if (choice.startsWith("Eject")) {
|
|
1252
|
-
await ejectAgent(ctx, name, cfg);
|
|
1253
|
-
} else if (choice === "Disable") {
|
|
1254
|
-
await disableAgent(ctx, name);
|
|
1255
|
-
} else if (choice === "Enable") {
|
|
1256
|
-
await enableAgent(ctx, name);
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
/** Eject a default agent: write its embedded config as a .md file. */
|
|
1261
|
-
async function ejectAgent(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) {
|
|
1262
|
-
const location = await ctx.ui.select("Choose location", [
|
|
1263
|
-
"Project (.pi/agents/)",
|
|
1264
|
-
`Personal (${personalAgentsDir()})`,
|
|
1265
|
-
]);
|
|
1266
|
-
if (!location) return;
|
|
1267
|
-
|
|
1268
|
-
const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
|
|
1269
|
-
mkdirSync(targetDir, { recursive: true });
|
|
1270
|
-
|
|
1271
|
-
const targetPath = join(targetDir, `${name}.md`);
|
|
1272
|
-
if (existsSync(targetPath)) {
|
|
1273
|
-
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
1274
|
-
if (!overwrite) return;
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
// Build the .md file content
|
|
1278
|
-
const fmFields: string[] = [];
|
|
1279
|
-
fmFields.push(`description: ${cfg.description}`);
|
|
1280
|
-
if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
|
|
1281
|
-
fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
|
|
1282
|
-
if (cfg.model) fmFields.push(`model: ${cfg.model}`);
|
|
1283
|
-
if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
|
|
1284
|
-
if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
|
|
1285
|
-
fmFields.push(`prompt_mode: ${cfg.promptMode}`);
|
|
1286
|
-
if (cfg.extensions === false) fmFields.push("extensions: false");
|
|
1287
|
-
else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
|
|
1288
|
-
if (cfg.skills === false) fmFields.push("skills: false");
|
|
1289
|
-
else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
1290
|
-
if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
1291
|
-
if (cfg.inheritContext) fmFields.push("inherit_context: true");
|
|
1292
|
-
if (cfg.runInBackground) fmFields.push("run_in_background: true");
|
|
1293
|
-
if (cfg.isolated) fmFields.push("isolated: true");
|
|
1294
|
-
if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
|
|
1295
|
-
if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
|
|
1296
|
-
|
|
1297
|
-
const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
|
|
1298
|
-
|
|
1299
|
-
const { writeFileSync } = await import("node:fs");
|
|
1300
|
-
writeFileSync(targetPath, content, "utf-8");
|
|
1301
|
-
reloadCustomAgents();
|
|
1302
|
-
ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
/** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
|
|
1306
|
-
async function disableAgent(ctx: ExtensionCommandContext, name: string) {
|
|
1307
|
-
const file = findAgentFile(name);
|
|
1308
|
-
if (file) {
|
|
1309
|
-
// Existing file — set enabled: false in frontmatter (idempotent)
|
|
1310
|
-
const content = readFileSync(file.path, "utf-8");
|
|
1311
|
-
if (content.includes("\nenabled: false\n")) {
|
|
1312
|
-
ctx.ui.notify(`${name} is already disabled.`, "info");
|
|
1313
|
-
return;
|
|
228
|
+
const agentsMenuHandler = createAgentsMenuHandler({
|
|
229
|
+
manager: {
|
|
230
|
+
listAgents: () => manager.listAgents(),
|
|
231
|
+
getRecord: (id) => manager.getRecord(id),
|
|
232
|
+
spawnAndWait: (piArg, ctx, type, prompt, opts) => manager.spawnAndWait((piArg ?? pi) as any, ctx as any, type, prompt, opts as any),
|
|
233
|
+
getMaxConcurrent: () => manager.getMaxConcurrent(),
|
|
234
|
+
setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
|
|
235
|
+
},
|
|
236
|
+
reloadCustomAgents,
|
|
237
|
+
agentActivity,
|
|
238
|
+
getModelLabel: (type, registry) => {
|
|
239
|
+
const cfg = getAgentConfig(type);
|
|
240
|
+
if (!cfg?.model) return 'inherit';
|
|
241
|
+
if (registry) {
|
|
242
|
+
const resolved = resolveModel(cfg.model, registry as any);
|
|
243
|
+
if (typeof resolved === 'string') return 'inherit';
|
|
1314
244
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
reloadCustomAgents();
|
|
1319
|
-
ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
|
|
1320
|
-
return;
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
// No file (built-in default) — create a stub
|
|
1324
|
-
const location = await ctx.ui.select("Choose location", [
|
|
1325
|
-
"Project (.pi/agents/)",
|
|
1326
|
-
`Personal (${personalAgentsDir()})`,
|
|
1327
|
-
]);
|
|
1328
|
-
if (!location) return;
|
|
1329
|
-
|
|
1330
|
-
const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
|
|
1331
|
-
mkdirSync(targetDir, { recursive: true });
|
|
1332
|
-
|
|
1333
|
-
const targetPath = join(targetDir, `${name}.md`);
|
|
1334
|
-
const { writeFileSync } = await import("node:fs");
|
|
1335
|
-
writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
|
|
1336
|
-
reloadCustomAgents();
|
|
1337
|
-
ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
/** Enable a disabled agent by removing enabled: false from its frontmatter. */
|
|
1341
|
-
async function enableAgent(ctx: ExtensionCommandContext, name: string) {
|
|
1342
|
-
const file = findAgentFile(name);
|
|
1343
|
-
if (!file) return;
|
|
1344
|
-
|
|
1345
|
-
const content = readFileSync(file.path, "utf-8");
|
|
1346
|
-
const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
|
|
1347
|
-
const { writeFileSync } = await import("node:fs");
|
|
1348
|
-
|
|
1349
|
-
// If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
|
|
1350
|
-
if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
|
|
1351
|
-
unlinkSync(file.path);
|
|
1352
|
-
reloadCustomAgents();
|
|
1353
|
-
ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
|
|
1354
|
-
} else {
|
|
1355
|
-
writeFileSync(file.path, updated, "utf-8");
|
|
1356
|
-
reloadCustomAgents();
|
|
1357
|
-
ctx.ui.notify(`Enabled ${name} (${file.path})`, "info");
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
async function showCreateWizard(ctx: ExtensionCommandContext) {
|
|
1362
|
-
const location = await ctx.ui.select("Choose location", [
|
|
1363
|
-
"Project (.pi/agents/)",
|
|
1364
|
-
`Personal (${personalAgentsDir()})`,
|
|
1365
|
-
]);
|
|
1366
|
-
if (!location) return;
|
|
1367
|
-
|
|
1368
|
-
const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
|
|
1369
|
-
|
|
1370
|
-
const method = await ctx.ui.select("Creation method", [
|
|
1371
|
-
"Generate with Claude (recommended)",
|
|
1372
|
-
"Manual configuration",
|
|
1373
|
-
]);
|
|
1374
|
-
if (!method) return;
|
|
1375
|
-
|
|
1376
|
-
if (method.startsWith("Generate")) {
|
|
1377
|
-
await showGenerateWizard(ctx, targetDir);
|
|
1378
|
-
} else {
|
|
1379
|
-
await showManualWizard(ctx, targetDir);
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
async function showGenerateWizard(ctx: ExtensionCommandContext, targetDir: string) {
|
|
1384
|
-
const description = await ctx.ui.input("Describe what this agent should do");
|
|
1385
|
-
if (!description) return;
|
|
1386
|
-
|
|
1387
|
-
const name = await ctx.ui.input("Agent name (filename, no spaces)");
|
|
1388
|
-
if (!name) return;
|
|
1389
|
-
|
|
1390
|
-
mkdirSync(targetDir, { recursive: true });
|
|
1391
|
-
|
|
1392
|
-
const targetPath = join(targetDir, `${name}.md`);
|
|
1393
|
-
if (existsSync(targetPath)) {
|
|
1394
|
-
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
1395
|
-
if (!overwrite) return;
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
ctx.ui.notify("Generating agent definition...", "info");
|
|
1399
|
-
|
|
1400
|
-
const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}"
|
|
1401
|
-
|
|
1402
|
-
Write a markdown file to: ${targetPath}
|
|
1403
|
-
|
|
1404
|
-
The file format is a markdown file with YAML frontmatter and a system prompt body:
|
|
1405
|
-
|
|
1406
|
-
\`\`\`markdown
|
|
1407
|
-
---
|
|
1408
|
-
description: <one-line description shown in UI>
|
|
1409
|
-
tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
|
|
1410
|
-
model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
|
|
1411
|
-
thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
|
|
1412
|
-
max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
|
|
1413
|
-
prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
|
|
1414
|
-
extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
|
|
1415
|
-
skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
|
|
1416
|
-
disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
|
|
1417
|
-
inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
|
|
1418
|
-
run_in_background: <true to run in background by default. Default: false>
|
|
1419
|
-
isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
|
|
1420
|
-
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
|
|
1421
|
-
isolation: <"worktree" to run in isolated git worktree. Omit for normal>
|
|
1422
|
-
---
|
|
1423
|
-
|
|
1424
|
-
<system prompt body — instructions for the agent>
|
|
1425
|
-
\`\`\`
|
|
1426
|
-
|
|
1427
|
-
Guidelines for choosing settings:
|
|
1428
|
-
- For read-only tasks (review, analysis): tools: read, bash, grep, find, ls
|
|
1429
|
-
- For code modification tasks: include edit, write
|
|
1430
|
-
- Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
|
|
1431
|
-
- Use prompt_mode: replace for fully custom agents with their own personality/instructions
|
|
1432
|
-
- Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
|
|
1433
|
-
- Set isolated: true if the agent should NOT have access to MCP servers or other extensions
|
|
1434
|
-
- Only include frontmatter fields that differ from defaults — omit fields where the default is fine
|
|
1435
|
-
|
|
1436
|
-
Write the file using the write tool. Only write the file, nothing else.`;
|
|
1437
|
-
|
|
1438
|
-
const record = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
|
|
1439
|
-
description: `Generate ${name} agent`,
|
|
1440
|
-
maxTurns: 5,
|
|
1441
|
-
});
|
|
1442
|
-
|
|
1443
|
-
if (record.status === "error") {
|
|
1444
|
-
ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
|
|
1445
|
-
return;
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
reloadCustomAgents();
|
|
1449
|
-
|
|
1450
|
-
if (existsSync(targetPath)) {
|
|
1451
|
-
ctx.ui.notify(`Created ${targetPath}`, "info");
|
|
1452
|
-
} else {
|
|
1453
|
-
ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning");
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
async function showManualWizard(ctx: ExtensionCommandContext, targetDir: string) {
|
|
1458
|
-
// 1. Name
|
|
1459
|
-
const name = await ctx.ui.input("Agent name (filename, no spaces)");
|
|
1460
|
-
if (!name) return;
|
|
1461
|
-
|
|
1462
|
-
// 2. Description
|
|
1463
|
-
const description = await ctx.ui.input("Description (one line)");
|
|
1464
|
-
if (!description) return;
|
|
1465
|
-
|
|
1466
|
-
// 3. Tools
|
|
1467
|
-
const toolChoice = await ctx.ui.select("Tools", ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."]);
|
|
1468
|
-
if (!toolChoice) return;
|
|
1469
|
-
|
|
1470
|
-
let tools: string;
|
|
1471
|
-
if (toolChoice === "all") {
|
|
1472
|
-
tools = BUILTIN_TOOL_NAMES.join(", ");
|
|
1473
|
-
} else if (toolChoice === "none") {
|
|
1474
|
-
tools = "none";
|
|
1475
|
-
} else if (toolChoice.startsWith("read-only")) {
|
|
1476
|
-
tools = "read, bash, grep, find, ls";
|
|
1477
|
-
} else {
|
|
1478
|
-
const customTools = await ctx.ui.input("Tools (comma-separated)", BUILTIN_TOOL_NAMES.join(", "));
|
|
1479
|
-
if (!customTools) return;
|
|
1480
|
-
tools = customTools;
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
// 4. Model
|
|
1484
|
-
const modelChoice = await ctx.ui.select("Model", [
|
|
1485
|
-
"inherit (parent model)",
|
|
1486
|
-
"haiku",
|
|
1487
|
-
"sonnet",
|
|
1488
|
-
"opus",
|
|
1489
|
-
"custom...",
|
|
1490
|
-
]);
|
|
1491
|
-
if (!modelChoice) return;
|
|
1492
|
-
|
|
1493
|
-
let modelLine = "";
|
|
1494
|
-
if (modelChoice === "haiku") modelLine = "\nmodel: anthropic/claude-haiku-4-5-20251001";
|
|
1495
|
-
else if (modelChoice === "sonnet") modelLine = "\nmodel: anthropic/claude-sonnet-4-6";
|
|
1496
|
-
else if (modelChoice === "opus") modelLine = "\nmodel: anthropic/claude-opus-4-6";
|
|
1497
|
-
else if (modelChoice === "custom...") {
|
|
1498
|
-
const customModel = await ctx.ui.input("Model (provider/modelId)");
|
|
1499
|
-
if (customModel) modelLine = `\nmodel: ${customModel}`;
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
// 5. Thinking
|
|
1503
|
-
const thinkingChoice = await ctx.ui.select("Thinking level", [
|
|
1504
|
-
"inherit",
|
|
1505
|
-
"off",
|
|
1506
|
-
"minimal",
|
|
1507
|
-
"low",
|
|
1508
|
-
"medium",
|
|
1509
|
-
"high",
|
|
1510
|
-
"xhigh",
|
|
1511
|
-
]);
|
|
1512
|
-
if (!thinkingChoice) return;
|
|
1513
|
-
|
|
1514
|
-
let thinkingLine = "";
|
|
1515
|
-
if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
|
|
1516
|
-
|
|
1517
|
-
// 6. System prompt
|
|
1518
|
-
const systemPrompt = await ctx.ui.editor("System prompt", "");
|
|
1519
|
-
if (systemPrompt === undefined) return;
|
|
1520
|
-
|
|
1521
|
-
// Build the file
|
|
1522
|
-
const content = `---
|
|
1523
|
-
description: ${description}
|
|
1524
|
-
tools: ${tools}${modelLine}${thinkingLine}
|
|
1525
|
-
prompt_mode: replace
|
|
1526
|
-
---
|
|
1527
|
-
|
|
1528
|
-
${systemPrompt}
|
|
1529
|
-
`;
|
|
1530
|
-
|
|
1531
|
-
mkdirSync(targetDir, { recursive: true });
|
|
1532
|
-
const targetPath = join(targetDir, `${name}.md`);
|
|
1533
|
-
|
|
1534
|
-
if (existsSync(targetPath)) {
|
|
1535
|
-
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
1536
|
-
if (!overwrite) return;
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
const { writeFileSync } = await import("node:fs");
|
|
1540
|
-
writeFileSync(targetPath, content, "utf-8");
|
|
1541
|
-
reloadCustomAgents();
|
|
1542
|
-
ctx.ui.notify(`Created ${targetPath}`, "info");
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
function snapshotSettings(): SubagentsSettings {
|
|
1546
|
-
return {
|
|
245
|
+
return getModelLabelFromConfig(cfg.model);
|
|
246
|
+
},
|
|
247
|
+
snapshotSettings: () => ({
|
|
1547
248
|
maxConcurrent: manager.getMaxConcurrent(),
|
|
1548
|
-
// 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
|
|
1549
|
-
// normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
|
|
1550
249
|
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
1551
250
|
graceTurns: getGraceTurns(),
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
async function showSettings(ctx: ExtensionCommandContext) {
|
|
1556
|
-
const choice = await ctx.ui.select("Settings", [
|
|
1557
|
-
`Max concurrency (current: ${manager.getMaxConcurrent()})`,
|
|
1558
|
-
`Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`,
|
|
1559
|
-
`Grace turns (current: ${getGraceTurns()})`,
|
|
1560
|
-
]);
|
|
1561
|
-
if (!choice) return;
|
|
1562
|
-
|
|
1563
|
-
if (choice.startsWith("Max concurrency")) {
|
|
1564
|
-
const val = await ctx.ui.input("Max concurrent background agents", String(manager.getMaxConcurrent()));
|
|
1565
|
-
if (val) {
|
|
1566
|
-
const n = parseInt(val, 10);
|
|
1567
|
-
if (n >= 1) {
|
|
1568
|
-
manager.setMaxConcurrent(n);
|
|
1569
|
-
notifyApplied(ctx, `Max concurrency set to ${n}`);
|
|
1570
|
-
} else {
|
|
1571
|
-
ctx.ui.notify("Must be a positive integer.", "warning");
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
} else if (choice.startsWith("Default max turns")) {
|
|
1575
|
-
const val = await ctx.ui.input("Default max turns before wrap-up (0 = unlimited)", String(getDefaultMaxTurns() ?? 0));
|
|
1576
|
-
if (val) {
|
|
1577
|
-
const n = parseInt(val, 10);
|
|
1578
|
-
if (n === 0) {
|
|
1579
|
-
setDefaultMaxTurns(undefined);
|
|
1580
|
-
notifyApplied(ctx, "Default max turns set to unlimited");
|
|
1581
|
-
} else if (n >= 1) {
|
|
1582
|
-
setDefaultMaxTurns(n);
|
|
1583
|
-
notifyApplied(ctx, `Default max turns set to ${n}`);
|
|
1584
|
-
} else {
|
|
1585
|
-
ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
} else if (choice.startsWith("Grace turns")) {
|
|
1589
|
-
const val = await ctx.ui.input("Grace turns after wrap-up steer", String(getGraceTurns()));
|
|
1590
|
-
if (val) {
|
|
1591
|
-
const n = parseInt(val, 10);
|
|
1592
|
-
if (n >= 1) {
|
|
1593
|
-
setGraceTurns(n);
|
|
1594
|
-
notifyApplied(ctx, `Grace turns set to ${n}`);
|
|
1595
|
-
} else {
|
|
1596
|
-
ctx.ui.notify("Must be a positive integer.", "warning");
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
// Persist the current snapshot, emit `subagents:settings_changed`, and surface
|
|
1603
|
-
// the right toast. Successful saves show info; persistence failures downgrade
|
|
1604
|
-
// to warning so users aren't silently reverted on restart. Event fires regardless
|
|
1605
|
-
// of outcome so listeners see the in-memory change.
|
|
1606
|
-
function notifyApplied(ctx: ExtensionCommandContext, successMsg: string) {
|
|
1607
|
-
const { message, level } = saveAndEmitChanged(
|
|
1608
|
-
snapshotSettings(),
|
|
251
|
+
}),
|
|
252
|
+
saveSettings: (settings, successMsg) => saveAndEmitChanged(
|
|
253
|
+
settings,
|
|
1609
254
|
successMsg,
|
|
1610
255
|
(event, payload) => pi.events.emit(event, payload),
|
|
1611
|
-
)
|
|
1612
|
-
|
|
1613
|
-
|
|
256
|
+
),
|
|
257
|
+
emitEvent: (name, data) => pi.events.emit(name, data),
|
|
258
|
+
personalAgentsDir: join(getAgentDir(), 'agents'),
|
|
259
|
+
});
|
|
1614
260
|
|
|
1615
|
-
pi.registerCommand(
|
|
1616
|
-
description:
|
|
1617
|
-
handler: async (_args, ctx) => { await
|
|
261
|
+
pi.registerCommand('agents', {
|
|
262
|
+
description: 'Manage agents',
|
|
263
|
+
handler: async (_args, ctx) => { await agentsMenuHandler(ctx as any); },
|
|
1618
264
|
});
|
|
1619
265
|
}
|