@tintinweb/pi-subagents 0.4.10 → 0.5.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 +14 -4
- package/README.md +23 -8
- package/dist/agent-manager.d.ts +18 -4
- package/dist/agent-manager.js +111 -9
- package/dist/agent-runner.d.ts +10 -6
- package/dist/agent-runner.js +81 -27
- package/dist/agent-types.d.ts +10 -0
- package/dist/agent-types.js +23 -1
- package/dist/cross-extension-rpc.d.ts +46 -0
- package/dist/cross-extension-rpc.js +54 -0
- package/dist/custom-agents.js +36 -8
- package/dist/index.js +336 -66
- package/dist/memory.d.ts +49 -0
- package/dist/memory.js +151 -0
- package/dist/output-file.d.ts +17 -0
- package/dist/output-file.js +66 -0
- package/dist/prompts.d.ts +12 -1
- package/dist/prompts.js +15 -3
- package/dist/skill-loader.d.ts +19 -0
- package/dist/skill-loader.js +67 -0
- package/dist/types.d.ts +45 -1
- package/dist/ui/agent-widget.d.ts +21 -0
- package/dist/ui/agent-widget.js +205 -127
- package/dist/ui/conversation-viewer.d.ts +2 -2
- package/dist/ui/conversation-viewer.js +3 -3
- package/dist/ui/conversation-viewer.test.d.ts +1 -0
- package/dist/ui/conversation-viewer.test.js +254 -0
- package/dist/worktree.d.ts +36 -0
- package/dist/worktree.js +139 -0
- package/package.json +8 -6
- package/src/agent-runner.ts +1 -1
- package/src/cross-extension-rpc.ts +57 -23
- package/src/index.ts +4 -3
- package/src/ui/conversation-viewer.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -9,18 +9,20 @@
|
|
|
9
9
|
* Commands:
|
|
10
10
|
* /agents — Interactive agent management menu
|
|
11
11
|
*/
|
|
12
|
-
import { existsSync, mkdirSync,
|
|
13
|
-
import { join } from "node:path";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
14
13
|
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
15
|
import { Text } from "@mariozechner/pi-tui";
|
|
16
16
|
import { Type } from "@sinclair/typebox";
|
|
17
17
|
import { AgentManager } from "./agent-manager.js";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
18
|
+
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
19
|
+
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
|
|
20
|
+
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
21
21
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
22
|
+
import { GroupJoinManager } from "./group-join.js";
|
|
22
23
|
import { resolveModel } from "./model-resolver.js";
|
|
23
|
-
import {
|
|
24
|
+
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
25
|
+
import { AgentWidget, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
|
|
24
26
|
// ---- Shared helpers ----
|
|
25
27
|
/** Tool execute return value for a text response. */
|
|
26
28
|
function textResult(msg, details) {
|
|
@@ -41,8 +43,8 @@ function safeFormatTokens(session) {
|
|
|
41
43
|
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
42
44
|
* Used by both foreground and background paths to avoid duplication.
|
|
43
45
|
*/
|
|
44
|
-
function createActivityTracker(onStreamUpdate) {
|
|
45
|
-
const state = { activeTools: new Map(), toolUses: 0, tokens: "", responseText: "", session: undefined };
|
|
46
|
+
function createActivityTracker(maxTurns, onStreamUpdate) {
|
|
47
|
+
const state = { activeTools: new Map(), toolUses: 0, turnCount: 1, maxTurns, tokens: "", responseText: "", session: undefined };
|
|
46
48
|
const callbacks = {
|
|
47
49
|
onToolActivity: (activity) => {
|
|
48
50
|
if (activity.type === "start") {
|
|
@@ -64,6 +66,10 @@ function createActivityTracker(onStreamUpdate) {
|
|
|
64
66
|
state.responseText = fullText;
|
|
65
67
|
onStreamUpdate?.();
|
|
66
68
|
},
|
|
69
|
+
onTurnEnd: (turnCount) => {
|
|
70
|
+
state.turnCount = turnCount;
|
|
71
|
+
onStreamUpdate?.();
|
|
72
|
+
},
|
|
67
73
|
onSessionCreated: (session) => {
|
|
68
74
|
state.session = session;
|
|
69
75
|
},
|
|
@@ -89,12 +95,47 @@ function getStatusNote(status) {
|
|
|
89
95
|
default: return "";
|
|
90
96
|
}
|
|
91
97
|
}
|
|
98
|
+
/** Escape XML special characters to prevent injection in structured notifications. */
|
|
99
|
+
function escapeXml(s) {
|
|
100
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
101
|
+
}
|
|
102
|
+
/** Format a structured task notification matching Claude Code's <task-notification> XML. */
|
|
103
|
+
function formatTaskNotification(record, resultMaxLen) {
|
|
104
|
+
const status = getStatusLabel(record.status, record.error);
|
|
105
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
106
|
+
let totalTokens = 0;
|
|
107
|
+
try {
|
|
108
|
+
if (record.session) {
|
|
109
|
+
const stats = record.session.getSessionStats();
|
|
110
|
+
totalTokens = stats.tokens?.total ?? 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch { /* session stats unavailable */ }
|
|
114
|
+
const resultPreview = record.result
|
|
115
|
+
? record.result.length > resultMaxLen
|
|
116
|
+
? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
|
|
117
|
+
: record.result
|
|
118
|
+
: "No output.";
|
|
119
|
+
return [
|
|
120
|
+
`<task-notification>`,
|
|
121
|
+
`<task-id>${record.id}</task-id>`,
|
|
122
|
+
record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
|
|
123
|
+
record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
|
|
124
|
+
`<status>${escapeXml(status)}</status>`,
|
|
125
|
+
`<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
|
|
126
|
+
`<result>${escapeXml(resultPreview)}</result>`,
|
|
127
|
+
`<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses><duration_ms>${durationMs}</duration_ms></usage>`,
|
|
128
|
+
`</task-notification>`,
|
|
129
|
+
].filter(Boolean).join('\n');
|
|
130
|
+
}
|
|
92
131
|
/** Build AgentDetails from a base + record-specific fields. */
|
|
93
|
-
function buildDetails(base, record, overrides) {
|
|
132
|
+
function buildDetails(base, record, activity, overrides) {
|
|
94
133
|
return {
|
|
95
134
|
...base,
|
|
96
135
|
toolUses: record.toolUses,
|
|
97
136
|
tokens: safeFormatTokens(record.session),
|
|
137
|
+
turnCount: activity?.turnCount,
|
|
138
|
+
maxTurns: activity?.maxTurns,
|
|
98
139
|
durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
|
|
99
140
|
status: record.status,
|
|
100
141
|
agentId: record.id,
|
|
@@ -102,7 +143,78 @@ function buildDetails(base, record, overrides) {
|
|
|
102
143
|
...overrides,
|
|
103
144
|
};
|
|
104
145
|
}
|
|
146
|
+
/** Build notification details for the custom message renderer. */
|
|
147
|
+
function buildNotificationDetails(record, resultMaxLen, activity) {
|
|
148
|
+
let totalTokens = 0;
|
|
149
|
+
try {
|
|
150
|
+
if (record.session)
|
|
151
|
+
totalTokens = record.session.getSessionStats().tokens?.total ?? 0;
|
|
152
|
+
}
|
|
153
|
+
catch { }
|
|
154
|
+
return {
|
|
155
|
+
id: record.id,
|
|
156
|
+
description: record.description,
|
|
157
|
+
status: record.status,
|
|
158
|
+
toolUses: record.toolUses,
|
|
159
|
+
turnCount: activity?.turnCount ?? 0,
|
|
160
|
+
maxTurns: activity?.maxTurns,
|
|
161
|
+
totalTokens,
|
|
162
|
+
durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
|
|
163
|
+
outputFile: record.outputFile,
|
|
164
|
+
error: record.error,
|
|
165
|
+
resultPreview: record.result
|
|
166
|
+
? record.result.length > resultMaxLen
|
|
167
|
+
? record.result.slice(0, resultMaxLen) + "…"
|
|
168
|
+
: record.result
|
|
169
|
+
: "No output.",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
105
172
|
export default function (pi) {
|
|
173
|
+
// ---- Register custom notification renderer ----
|
|
174
|
+
pi.registerMessageRenderer("subagent-notification", (message, { expanded }, theme) => {
|
|
175
|
+
const d = message.details;
|
|
176
|
+
if (!d)
|
|
177
|
+
return undefined;
|
|
178
|
+
function renderOne(d) {
|
|
179
|
+
const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
|
|
180
|
+
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
181
|
+
const statusText = isError ? d.status
|
|
182
|
+
: d.status === "steered" ? "completed (steered)"
|
|
183
|
+
: "completed";
|
|
184
|
+
// Line 1: icon + agent description + status
|
|
185
|
+
let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
|
|
186
|
+
// Line 2: stats
|
|
187
|
+
const parts = [];
|
|
188
|
+
if (d.turnCount > 0)
|
|
189
|
+
parts.push(formatTurns(d.turnCount, d.maxTurns));
|
|
190
|
+
if (d.toolUses > 0)
|
|
191
|
+
parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
192
|
+
if (d.totalTokens > 0)
|
|
193
|
+
parts.push(formatTokens(d.totalTokens));
|
|
194
|
+
if (d.durationMs > 0)
|
|
195
|
+
parts.push(formatMs(d.durationMs));
|
|
196
|
+
if (parts.length) {
|
|
197
|
+
line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
|
|
198
|
+
}
|
|
199
|
+
// Line 3: result preview (collapsed) or full (expanded)
|
|
200
|
+
if (expanded) {
|
|
201
|
+
const lines = d.resultPreview.split("\n").slice(0, 30);
|
|
202
|
+
for (const l of lines)
|
|
203
|
+
line += "\n" + theme.fg("dim", ` ${l}`);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
|
|
207
|
+
line += "\n " + theme.fg("dim", `⎿ ${preview}`);
|
|
208
|
+
}
|
|
209
|
+
// Line 4: output file link (if present)
|
|
210
|
+
if (d.outputFile) {
|
|
211
|
+
line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
|
|
212
|
+
}
|
|
213
|
+
return line;
|
|
214
|
+
}
|
|
215
|
+
const all = [d, ...(d.others ?? [])];
|
|
216
|
+
return new Text(all.map(renderOne).join("\n"), 0, 0);
|
|
217
|
+
});
|
|
106
218
|
/** Reload agents from .pi/agents/*.md and merge with defaults (called on init and each Agent invocation). */
|
|
107
219
|
const reloadCustomAgents = () => {
|
|
108
220
|
const userAgents = loadCustomAgents(process.cwd());
|
|
@@ -112,60 +224,120 @@ export default function (pi) {
|
|
|
112
224
|
reloadCustomAgents();
|
|
113
225
|
// ---- Agent activity tracking + widget ----
|
|
114
226
|
const agentActivity = new Map();
|
|
227
|
+
// ---- Cancellable pending notifications ----
|
|
228
|
+
// Holds notifications briefly so get_subagent_result can cancel them
|
|
229
|
+
// before they reach pi.sendMessage (fire-and-forget).
|
|
230
|
+
const pendingNudges = new Map();
|
|
231
|
+
const NUDGE_HOLD_MS = 200;
|
|
232
|
+
function scheduleNudge(key, send, delay = NUDGE_HOLD_MS) {
|
|
233
|
+
cancelNudge(key);
|
|
234
|
+
pendingNudges.set(key, setTimeout(() => {
|
|
235
|
+
pendingNudges.delete(key);
|
|
236
|
+
send();
|
|
237
|
+
}, delay));
|
|
238
|
+
}
|
|
239
|
+
function cancelNudge(key) {
|
|
240
|
+
const timer = pendingNudges.get(key);
|
|
241
|
+
if (timer != null) {
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
pendingNudges.delete(key);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
115
246
|
// ---- Individual nudge helper (async join mode) ----
|
|
247
|
+
function emitIndividualNudge(record) {
|
|
248
|
+
if (record.resultConsumed)
|
|
249
|
+
return; // re-check at send time
|
|
250
|
+
const notification = formatTaskNotification(record, 500);
|
|
251
|
+
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
|
|
252
|
+
pi.sendMessage({
|
|
253
|
+
customType: "subagent-notification",
|
|
254
|
+
content: notification + footer,
|
|
255
|
+
display: true,
|
|
256
|
+
details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
|
|
257
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
258
|
+
}
|
|
116
259
|
function sendIndividualNudge(record) {
|
|
117
|
-
const displayName = getDisplayName(record.type);
|
|
118
|
-
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
119
|
-
const status = getStatusLabel(record.status, record.error);
|
|
120
|
-
const resultPreview = record.result
|
|
121
|
-
? record.result.length > 500
|
|
122
|
-
? record.result.slice(0, 500) + "\n...(truncated, use get_subagent_result for full output)"
|
|
123
|
-
: record.result
|
|
124
|
-
: "No output.";
|
|
125
260
|
agentActivity.delete(record.id);
|
|
126
261
|
widget.markFinished(record.id);
|
|
127
|
-
|
|
128
|
-
const toolStats = tokens ? `Tool uses: ${record.toolUses} | ${tokens}` : `Tool uses: ${record.toolUses}`;
|
|
129
|
-
pi.sendUserMessage(`Background agent completed: ${displayName} (${record.description})\n` +
|
|
130
|
-
`Agent ID: ${record.id} | Status: ${status} | ${toolStats} | Duration: ${duration}\n\n` +
|
|
131
|
-
resultPreview, { deliverAs: "followUp" });
|
|
262
|
+
scheduleNudge(record.id, () => emitIndividualNudge(record));
|
|
132
263
|
widget.update();
|
|
133
264
|
}
|
|
134
|
-
/** Format a single agent's summary for grouped notification. */
|
|
135
|
-
function formatAgentSummary(record) {
|
|
136
|
-
const displayName = getDisplayName(record.type);
|
|
137
|
-
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
138
|
-
const status = getStatusLabel(record.status, record.error);
|
|
139
|
-
const resultPreview = record.result
|
|
140
|
-
? record.result.length > 300
|
|
141
|
-
? record.result.slice(0, 300) + "\n...(truncated)"
|
|
142
|
-
: record.result
|
|
143
|
-
: "No output.";
|
|
144
|
-
const tokens = safeFormatTokens(record.session);
|
|
145
|
-
const toolStats = tokens ? `Tools: ${record.toolUses} | ${tokens}` : `Tools: ${record.toolUses}`;
|
|
146
|
-
return `- ${displayName} (${record.description})\n ID: ${record.id} | Status: ${status} | ${toolStats} | Duration: ${duration}\n ${resultPreview}`;
|
|
147
|
-
}
|
|
148
265
|
// ---- Group join manager ----
|
|
149
266
|
const groupJoin = new GroupJoinManager((records, partial) => {
|
|
150
|
-
// Filter out agents whose results were already consumed via get_subagent_result
|
|
151
|
-
const unconsumed = records.filter(r => !r.resultConsumed);
|
|
152
267
|
for (const r of records) {
|
|
153
268
|
agentActivity.delete(r.id);
|
|
154
269
|
widget.markFinished(r.id);
|
|
155
270
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
271
|
+
const groupKey = `group:${records.map(r => r.id).join(",")}`;
|
|
272
|
+
scheduleNudge(groupKey, () => {
|
|
273
|
+
// Re-check at send time
|
|
274
|
+
const unconsumed = records.filter(r => !r.resultConsumed);
|
|
275
|
+
if (unconsumed.length === 0) {
|
|
276
|
+
widget.update();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
|
|
280
|
+
const label = partial
|
|
281
|
+
? `${unconsumed.length} agent(s) finished (partial — others still running)`
|
|
282
|
+
: `${unconsumed.length} agent(s) finished`;
|
|
283
|
+
const [first, ...rest] = unconsumed;
|
|
284
|
+
const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
|
|
285
|
+
if (rest.length > 0) {
|
|
286
|
+
details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id)));
|
|
287
|
+
}
|
|
288
|
+
pi.sendMessage({
|
|
289
|
+
customType: "subagent-notification",
|
|
290
|
+
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
291
|
+
display: true,
|
|
292
|
+
details,
|
|
293
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
294
|
+
});
|
|
165
295
|
widget.update();
|
|
166
296
|
}, 30_000);
|
|
297
|
+
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
298
|
+
function buildEventData(record) {
|
|
299
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|
|
300
|
+
let tokens;
|
|
301
|
+
try {
|
|
302
|
+
if (record.session) {
|
|
303
|
+
const stats = record.session.getSessionStats();
|
|
304
|
+
tokens = {
|
|
305
|
+
input: stats.tokens?.input ?? 0,
|
|
306
|
+
output: stats.tokens?.output ?? 0,
|
|
307
|
+
total: stats.tokens?.total ?? 0,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch { /* session stats unavailable */ }
|
|
312
|
+
return {
|
|
313
|
+
id: record.id,
|
|
314
|
+
type: record.type,
|
|
315
|
+
description: record.description,
|
|
316
|
+
result: record.result,
|
|
317
|
+
error: record.error,
|
|
318
|
+
status: record.status,
|
|
319
|
+
toolUses: record.toolUses,
|
|
320
|
+
durationMs,
|
|
321
|
+
tokens,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
167
324
|
// Background completion: route through group join or send individual nudge
|
|
168
325
|
const manager = new AgentManager((record) => {
|
|
326
|
+
// Emit lifecycle event based on terminal status
|
|
327
|
+
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
328
|
+
const eventData = buildEventData(record);
|
|
329
|
+
if (isError) {
|
|
330
|
+
pi.events.emit("subagents:failed", eventData);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
pi.events.emit("subagents:completed", eventData);
|
|
334
|
+
}
|
|
335
|
+
// Persist final record for cross-extension history reconstruction
|
|
336
|
+
pi.appendEntry("subagents:record", {
|
|
337
|
+
id: record.id, type: record.type, description: record.description,
|
|
338
|
+
status: record.status, result: record.result, error: record.error,
|
|
339
|
+
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
340
|
+
});
|
|
169
341
|
// Skip notification if result was already consumed via get_subagent_result
|
|
170
342
|
if (record.resultConsumed) {
|
|
171
343
|
agentActivity.delete(record.id);
|
|
@@ -186,6 +358,13 @@ export default function (pi) {
|
|
|
186
358
|
// 'held' → do nothing, group will fire later
|
|
187
359
|
// 'delivered' → group callback already fired
|
|
188
360
|
widget.update();
|
|
361
|
+
}, undefined, (record) => {
|
|
362
|
+
// Emit started event when agent transitions to running (including from queue)
|
|
363
|
+
pi.events.emit("subagents:started", {
|
|
364
|
+
id: record.id,
|
|
365
|
+
type: record.type,
|
|
366
|
+
description: record.description,
|
|
367
|
+
});
|
|
189
368
|
});
|
|
190
369
|
// Expose manager via Symbol.for() global registry for cross-package access.
|
|
191
370
|
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
|
|
@@ -193,11 +372,37 @@ export default function (pi) {
|
|
|
193
372
|
globalThis[MANAGER_KEY] = {
|
|
194
373
|
waitForAll: () => manager.waitForAll(),
|
|
195
374
|
hasRunning: () => manager.hasRunning(),
|
|
375
|
+
spawn: (piRef, ctx, type, prompt, options) => manager.spawn(piRef, ctx, type, prompt, options),
|
|
376
|
+
getRecord: (id) => manager.getRecord(id),
|
|
196
377
|
};
|
|
197
|
-
//
|
|
378
|
+
// --- Cross-extension RPC via pi.events ---
|
|
379
|
+
let currentCtx;
|
|
380
|
+
// Capture ctx from session_start for RPC spawn handler
|
|
381
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
382
|
+
currentCtx = ctx;
|
|
383
|
+
manager.clearCompleted(); // preserve existing behavior
|
|
384
|
+
});
|
|
385
|
+
pi.on("session_switch", () => { manager.clearCompleted(); });
|
|
386
|
+
const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({
|
|
387
|
+
events: pi.events,
|
|
388
|
+
pi,
|
|
389
|
+
getCtx: () => currentCtx,
|
|
390
|
+
manager,
|
|
391
|
+
});
|
|
392
|
+
// Broadcast readiness so extensions loaded after us can discover us
|
|
393
|
+
pi.events.emit("subagents:ready", {});
|
|
394
|
+
// On shutdown, abort all agents immediately and clean up.
|
|
395
|
+
// If the session is going down, there's nothing left to consume agent results.
|
|
198
396
|
pi.on("session_shutdown", async () => {
|
|
397
|
+
unsubSpawnRpc();
|
|
398
|
+
unsubStopRpc();
|
|
399
|
+
unsubPingRpc();
|
|
400
|
+
currentCtx = undefined;
|
|
199
401
|
delete globalThis[MANAGER_KEY];
|
|
200
|
-
|
|
402
|
+
manager.abortAll();
|
|
403
|
+
for (const timer of pendingNudges.values())
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
pendingNudges.clear();
|
|
201
406
|
manager.dispose();
|
|
202
407
|
});
|
|
203
408
|
// Live widget: show running agents above editor
|
|
@@ -306,6 +511,7 @@ Guidelines:
|
|
|
306
511
|
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
307
512
|
- Use thinking to control extended thinking level.
|
|
308
513
|
- Use inherit_context if the agent needs the parent conversation history.
|
|
514
|
+
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).
|
|
309
515
|
- Use join_mode to control how background completion notifications are delivered. By default (smart), 2+ background agents spawned in the same turn are grouped into a single notification. Use "async" for individual notifications or "group" to force grouping.`,
|
|
310
516
|
parameters: Type.Object({
|
|
311
517
|
prompt: Type.String({
|
|
@@ -324,7 +530,7 @@ Guidelines:
|
|
|
324
530
|
description: "Thinking level: off, minimal, low, medium, high, xhigh. Overrides agent default.",
|
|
325
531
|
})),
|
|
326
532
|
max_turns: Type.Optional(Type.Number({
|
|
327
|
-
description: "Maximum number of agentic turns before stopping.",
|
|
533
|
+
description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
|
|
328
534
|
minimum: 1,
|
|
329
535
|
})),
|
|
330
536
|
run_in_background: Type.Optional(Type.Boolean({
|
|
@@ -339,6 +545,9 @@ Guidelines:
|
|
|
339
545
|
inherit_context: Type.Optional(Type.Boolean({
|
|
340
546
|
description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
|
|
341
547
|
})),
|
|
548
|
+
isolation: Type.Optional(Type.Literal("worktree", {
|
|
549
|
+
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.',
|
|
550
|
+
})),
|
|
342
551
|
join_mode: Type.Optional(Type.Union([
|
|
343
552
|
Type.Literal("async"),
|
|
344
553
|
Type.Literal("group"),
|
|
@@ -356,13 +565,16 @@ Guidelines:
|
|
|
356
565
|
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
357
566
|
return new Text(text, 0, 0);
|
|
358
567
|
}
|
|
359
|
-
// Helper: build "haiku · thinking: high · 3 tool uses · 33.8k tokens" stats string
|
|
568
|
+
// Helper: build "haiku · thinking: high · ⟳5≤30 · 3 tool uses · 33.8k tokens" stats string
|
|
360
569
|
const stats = (d) => {
|
|
361
570
|
const parts = [];
|
|
362
571
|
if (d.modelName)
|
|
363
572
|
parts.push(d.modelName);
|
|
364
573
|
if (d.tags)
|
|
365
574
|
parts.push(...d.tags);
|
|
575
|
+
if (d.turnCount != null && d.turnCount > 0) {
|
|
576
|
+
parts.push(formatTurns(d.turnCount, d.maxTurns));
|
|
577
|
+
}
|
|
366
578
|
if (d.toolUses > 0)
|
|
367
579
|
parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
368
580
|
if (d.tokens)
|
|
@@ -426,7 +638,7 @@ Guidelines:
|
|
|
426
638
|
return new Text(line, 0, 0);
|
|
427
639
|
},
|
|
428
640
|
// ---- Execute ----
|
|
429
|
-
execute: async (
|
|
641
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
430
642
|
// Ensure we have UI context for widget rendering
|
|
431
643
|
widget.setUICtx(ctx.ui);
|
|
432
644
|
// Reload custom agents so new .pi/agents/*.md files are picked up without restart
|
|
@@ -458,6 +670,7 @@ Guidelines:
|
|
|
458
670
|
const inheritContext = params.inherit_context ?? customConfig?.inheritContext ?? false;
|
|
459
671
|
const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
|
|
460
672
|
const isolated = params.isolated ?? customConfig?.isolated ?? false;
|
|
673
|
+
const isolation = params.isolation ?? customConfig?.isolation;
|
|
461
674
|
// Build display tags for non-default config
|
|
462
675
|
const parentModelId = ctx.model?.id;
|
|
463
676
|
const effectiveModelId = model?.id;
|
|
@@ -472,6 +685,9 @@ Guidelines:
|
|
|
472
685
|
agentTags.push(`thinking: ${thinking}`);
|
|
473
686
|
if (isolated)
|
|
474
687
|
agentTags.push("isolated");
|
|
688
|
+
if (isolation === "worktree")
|
|
689
|
+
agentTags.push("worktree");
|
|
690
|
+
const effectiveMaxTurns = params.max_turns ?? customConfig?.maxTurns ?? getDefaultMaxTurns();
|
|
475
691
|
// Shared base fields for all AgentDetails in this call
|
|
476
692
|
const detailBase = {
|
|
477
693
|
displayName,
|
|
@@ -497,8 +713,20 @@ Guidelines:
|
|
|
497
713
|
}
|
|
498
714
|
// Background execution
|
|
499
715
|
if (runInBackground) {
|
|
500
|
-
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker();
|
|
501
|
-
|
|
716
|
+
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
|
|
717
|
+
// Wrap onSessionCreated to wire output file streaming.
|
|
718
|
+
// The callback lazily reads record.outputFile (set right after spawn)
|
|
719
|
+
// rather than closing over a value that doesn't exist yet.
|
|
720
|
+
let id;
|
|
721
|
+
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
722
|
+
bgCallbacks.onSessionCreated = (session) => {
|
|
723
|
+
origBgOnSession(session);
|
|
724
|
+
const rec = manager.getRecord(id);
|
|
725
|
+
if (rec?.outputFile) {
|
|
726
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
502
730
|
description: params.description,
|
|
503
731
|
model,
|
|
504
732
|
maxTurns: params.max_turns,
|
|
@@ -506,13 +734,19 @@ Guidelines:
|
|
|
506
734
|
inheritContext,
|
|
507
735
|
thinkingLevel: thinking,
|
|
508
736
|
isBackground: true,
|
|
737
|
+
isolation,
|
|
509
738
|
...bgCallbacks,
|
|
510
739
|
});
|
|
511
|
-
//
|
|
740
|
+
// Set output file + join mode synchronously after spawn, before the
|
|
741
|
+
// event loop yields — onSessionCreated is async so this is safe.
|
|
512
742
|
const joinMode = params.join_mode ?? defaultJoinMode;
|
|
513
743
|
const record = manager.getRecord(id);
|
|
514
|
-
if (record)
|
|
744
|
+
if (record) {
|
|
515
745
|
record.joinMode = joinMode;
|
|
746
|
+
record.toolCallId = toolCallId;
|
|
747
|
+
record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
|
|
748
|
+
writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
|
|
749
|
+
}
|
|
516
750
|
if (joinMode === 'async') {
|
|
517
751
|
// Explicit async — not part of any batch
|
|
518
752
|
}
|
|
@@ -528,11 +762,19 @@ Guidelines:
|
|
|
528
762
|
agentActivity.set(id, bgState);
|
|
529
763
|
widget.ensureTimer();
|
|
530
764
|
widget.update();
|
|
765
|
+
// Emit created event
|
|
766
|
+
pi.events.emit("subagents:created", {
|
|
767
|
+
id,
|
|
768
|
+
type: subagentType,
|
|
769
|
+
description: params.description,
|
|
770
|
+
isBackground: true,
|
|
771
|
+
});
|
|
531
772
|
const isQueued = record?.status === "queued";
|
|
532
773
|
return textResult(`Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
533
774
|
`Agent ID: ${id}\n` +
|
|
534
775
|
`Type: ${displayName}\n` +
|
|
535
776
|
`Description: ${params.description}\n` +
|
|
777
|
+
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
536
778
|
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
537
779
|
`\nYou will be notified when this agent completes.\n` +
|
|
538
780
|
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
@@ -547,6 +789,8 @@ Guidelines:
|
|
|
547
789
|
...detailBase,
|
|
548
790
|
toolUses: fgState.toolUses,
|
|
549
791
|
tokens: fgState.tokens,
|
|
792
|
+
turnCount: fgState.turnCount,
|
|
793
|
+
maxTurns: fgState.maxTurns,
|
|
550
794
|
durationMs: Date.now() - startedAt,
|
|
551
795
|
status: "running",
|
|
552
796
|
activity: describeActivity(fgState.activeTools, fgState.responseText),
|
|
@@ -557,7 +801,7 @@ Guidelines:
|
|
|
557
801
|
details: details,
|
|
558
802
|
});
|
|
559
803
|
};
|
|
560
|
-
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(streamUpdate);
|
|
804
|
+
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
|
|
561
805
|
// Wire session creation to register in widget
|
|
562
806
|
const origOnSession = fgCallbacks.onSessionCreated;
|
|
563
807
|
fgCallbacks.onSessionCreated = (session) => {
|
|
@@ -584,6 +828,7 @@ Guidelines:
|
|
|
584
828
|
isolated,
|
|
585
829
|
inheritContext,
|
|
586
830
|
thinkingLevel: thinking,
|
|
831
|
+
isolation,
|
|
587
832
|
...fgCallbacks,
|
|
588
833
|
});
|
|
589
834
|
clearInterval(spinnerInterval);
|
|
@@ -594,7 +839,7 @@ Guidelines:
|
|
|
594
839
|
}
|
|
595
840
|
// Get final token count
|
|
596
841
|
const tokenText = safeFormatTokens(fgState.session);
|
|
597
|
-
const details = buildDetails(detailBase, record, { tokens: tokenText });
|
|
842
|
+
const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
|
|
598
843
|
const fallbackNote = fellBack
|
|
599
844
|
? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
|
|
600
845
|
: "";
|
|
@@ -630,8 +875,13 @@ Guidelines:
|
|
|
630
875
|
if (!record) {
|
|
631
876
|
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
632
877
|
}
|
|
633
|
-
// Wait for completion if requested
|
|
878
|
+
// Wait for completion if requested.
|
|
879
|
+
// Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
|
|
880
|
+
// (attached earlier at spawn time) and always runs before this await resumes.
|
|
881
|
+
// Setting the flag here prevents a redundant follow-up notification.
|
|
634
882
|
if (params.wait && record.status === "running" && record.promise) {
|
|
883
|
+
record.resultConsumed = true;
|
|
884
|
+
cancelNudge(params.agent_id);
|
|
635
885
|
await record.promise;
|
|
636
886
|
}
|
|
637
887
|
const displayName = getDisplayName(record.type);
|
|
@@ -653,6 +903,7 @@ Guidelines:
|
|
|
653
903
|
// Mark result as consumed — suppresses the completion notification
|
|
654
904
|
if (record.status !== "running" && record.status !== "queued") {
|
|
655
905
|
record.resultConsumed = true;
|
|
906
|
+
cancelNudge(params.agent_id);
|
|
656
907
|
}
|
|
657
908
|
// Verbose: include full conversation
|
|
658
909
|
if (params.verbose && record.session) {
|
|
@@ -687,10 +938,16 @@ Guidelines:
|
|
|
687
938
|
return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`);
|
|
688
939
|
}
|
|
689
940
|
if (!record.session) {
|
|
690
|
-
|
|
941
|
+
// Session not ready yet — queue the steer for delivery once initialized
|
|
942
|
+
if (!record.pendingSteers)
|
|
943
|
+
record.pendingSteers = [];
|
|
944
|
+
record.pendingSteers.push(params.message);
|
|
945
|
+
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
946
|
+
return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
|
|
691
947
|
}
|
|
692
948
|
try {
|
|
693
949
|
await steerAgent(record.session, params.message);
|
|
950
|
+
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
694
951
|
return textResult(`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.`);
|
|
695
952
|
}
|
|
696
953
|
catch (err) {
|
|
@@ -961,12 +1218,18 @@ Guidelines:
|
|
|
961
1218
|
fmFields.push("skills: false");
|
|
962
1219
|
else if (Array.isArray(cfg.skills))
|
|
963
1220
|
fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
1221
|
+
if (cfg.disallowedTools?.length)
|
|
1222
|
+
fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
964
1223
|
if (cfg.inheritContext)
|
|
965
1224
|
fmFields.push("inherit_context: true");
|
|
966
1225
|
if (cfg.runInBackground)
|
|
967
1226
|
fmFields.push("run_in_background: true");
|
|
968
1227
|
if (cfg.isolated)
|
|
969
1228
|
fmFields.push("isolated: true");
|
|
1229
|
+
if (cfg.memory)
|
|
1230
|
+
fmFields.push(`memory: ${cfg.memory}`);
|
|
1231
|
+
if (cfg.isolation)
|
|
1232
|
+
fmFields.push(`isolation: ${cfg.isolation}`);
|
|
970
1233
|
const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
|
|
971
1234
|
const { writeFileSync } = await import("node:fs");
|
|
972
1235
|
writeFileSync(targetPath, content, "utf-8");
|
|
@@ -1073,13 +1336,16 @@ description: <one-line description shown in UI>
|
|
|
1073
1336
|
tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
|
|
1074
1337
|
model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
|
|
1075
1338
|
thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
|
|
1076
|
-
max_turns: <optional max agentic turns
|
|
1339
|
+
max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
|
|
1077
1340
|
prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
|
|
1078
1341
|
extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
|
|
1079
|
-
skills: <true (inherit all), false (none). Default: true>
|
|
1342
|
+
skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
|
|
1343
|
+
disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
|
|
1080
1344
|
inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
|
|
1081
1345
|
run_in_background: <true to run in background by default. Default: false>
|
|
1082
1346
|
isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
|
|
1347
|
+
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
|
|
1348
|
+
isolation: <"worktree" to run in isolated git worktree. Omit for normal>
|
|
1083
1349
|
---
|
|
1084
1350
|
|
|
1085
1351
|
<system prompt body — instructions for the agent>
|
|
@@ -1205,7 +1471,7 @@ ${systemPrompt}
|
|
|
1205
1471
|
async function showSettings(ctx) {
|
|
1206
1472
|
const choice = await ctx.ui.select("Settings", [
|
|
1207
1473
|
`Max concurrency (current: ${manager.getMaxConcurrent()})`,
|
|
1208
|
-
`Default max turns (current: ${getDefaultMaxTurns()})`,
|
|
1474
|
+
`Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`,
|
|
1209
1475
|
`Grace turns (current: ${getGraceTurns()})`,
|
|
1210
1476
|
`Join mode (current: ${getDefaultJoinMode()})`,
|
|
1211
1477
|
]);
|
|
@@ -1225,15 +1491,19 @@ ${systemPrompt}
|
|
|
1225
1491
|
}
|
|
1226
1492
|
}
|
|
1227
1493
|
else if (choice.startsWith("Default max turns")) {
|
|
1228
|
-
const val = await ctx.ui.input("Default max turns before wrap-up", String(getDefaultMaxTurns()));
|
|
1494
|
+
const val = await ctx.ui.input("Default max turns before wrap-up (0 = unlimited)", String(getDefaultMaxTurns() ?? 0));
|
|
1229
1495
|
if (val) {
|
|
1230
1496
|
const n = parseInt(val, 10);
|
|
1231
|
-
if (n
|
|
1497
|
+
if (n === 0) {
|
|
1498
|
+
setDefaultMaxTurns(undefined);
|
|
1499
|
+
ctx.ui.notify("Default max turns set to unlimited", "info");
|
|
1500
|
+
}
|
|
1501
|
+
else if (n >= 1) {
|
|
1232
1502
|
setDefaultMaxTurns(n);
|
|
1233
1503
|
ctx.ui.notify(`Default max turns set to ${n}`, "info");
|
|
1234
1504
|
}
|
|
1235
1505
|
else {
|
|
1236
|
-
ctx.ui.notify("Must be a positive integer.", "warning");
|
|
1506
|
+
ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
|
|
1237
1507
|
}
|
|
1238
1508
|
}
|
|
1239
1509
|
}
|