@tintinweb/pi-subagents 0.4.9 → 0.4.11
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/.github/workflows/ci.yml +21 -0
- package/CHANGELOG.md +18 -0
- package/README.md +11 -11
- package/biome.json +26 -0
- 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 +80 -26
- package/dist/agent-types.d.ts +10 -0
- package/dist/agent-types.js +23 -1
- package/dist/cross-extension-rpc.d.ts +30 -0
- package/dist/cross-extension-rpc.js +33 -0
- package/dist/custom-agents.js +36 -8
- package/dist/index.js +335 -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 +2 -2
- 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 +7 -2
- package/src/agent-manager.ts +7 -5
- package/src/agent-runner.ts +24 -19
- package/src/agent-types.ts +5 -5
- package/src/custom-agents.ts +4 -4
- package/src/index.ts +54 -33
- package/src/memory.ts +2 -2
- package/src/output-file.ts +1 -1
- package/src/skill-loader.ts +1 -1
- package/src/types.ts +3 -1
- package/src/ui/agent-widget.ts +18 -2
- package/src/ui/conversation-viewer.ts +4 -4
- package/src/worktree.ts +2 -2
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" });
|
|
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" });
|
|
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,36 @@ 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 } = 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
|
+
unsubPingRpc();
|
|
399
|
+
currentCtx = undefined;
|
|
199
400
|
delete globalThis[MANAGER_KEY];
|
|
200
|
-
|
|
401
|
+
manager.abortAll();
|
|
402
|
+
for (const timer of pendingNudges.values())
|
|
403
|
+
clearTimeout(timer);
|
|
404
|
+
pendingNudges.clear();
|
|
201
405
|
manager.dispose();
|
|
202
406
|
});
|
|
203
407
|
// Live widget: show running agents above editor
|
|
@@ -306,6 +510,7 @@ Guidelines:
|
|
|
306
510
|
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
307
511
|
- Use thinking to control extended thinking level.
|
|
308
512
|
- Use inherit_context if the agent needs the parent conversation history.
|
|
513
|
+
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).
|
|
309
514
|
- 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
515
|
parameters: Type.Object({
|
|
311
516
|
prompt: Type.String({
|
|
@@ -324,7 +529,7 @@ Guidelines:
|
|
|
324
529
|
description: "Thinking level: off, minimal, low, medium, high, xhigh. Overrides agent default.",
|
|
325
530
|
})),
|
|
326
531
|
max_turns: Type.Optional(Type.Number({
|
|
327
|
-
description: "Maximum number of agentic turns before stopping.",
|
|
532
|
+
description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
|
|
328
533
|
minimum: 1,
|
|
329
534
|
})),
|
|
330
535
|
run_in_background: Type.Optional(Type.Boolean({
|
|
@@ -339,6 +544,9 @@ Guidelines:
|
|
|
339
544
|
inherit_context: Type.Optional(Type.Boolean({
|
|
340
545
|
description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
|
|
341
546
|
})),
|
|
547
|
+
isolation: Type.Optional(Type.Literal("worktree", {
|
|
548
|
+
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.',
|
|
549
|
+
})),
|
|
342
550
|
join_mode: Type.Optional(Type.Union([
|
|
343
551
|
Type.Literal("async"),
|
|
344
552
|
Type.Literal("group"),
|
|
@@ -356,13 +564,16 @@ Guidelines:
|
|
|
356
564
|
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
357
565
|
return new Text(text, 0, 0);
|
|
358
566
|
}
|
|
359
|
-
// Helper: build "haiku · thinking: high · 3 tool uses · 33.8k tokens" stats string
|
|
567
|
+
// Helper: build "haiku · thinking: high · ⟳5≤30 · 3 tool uses · 33.8k tokens" stats string
|
|
360
568
|
const stats = (d) => {
|
|
361
569
|
const parts = [];
|
|
362
570
|
if (d.modelName)
|
|
363
571
|
parts.push(d.modelName);
|
|
364
572
|
if (d.tags)
|
|
365
573
|
parts.push(...d.tags);
|
|
574
|
+
if (d.turnCount != null && d.turnCount > 0) {
|
|
575
|
+
parts.push(formatTurns(d.turnCount, d.maxTurns));
|
|
576
|
+
}
|
|
366
577
|
if (d.toolUses > 0)
|
|
367
578
|
parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
368
579
|
if (d.tokens)
|
|
@@ -426,7 +637,7 @@ Guidelines:
|
|
|
426
637
|
return new Text(line, 0, 0);
|
|
427
638
|
},
|
|
428
639
|
// ---- Execute ----
|
|
429
|
-
execute: async (
|
|
640
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
430
641
|
// Ensure we have UI context for widget rendering
|
|
431
642
|
widget.setUICtx(ctx.ui);
|
|
432
643
|
// Reload custom agents so new .pi/agents/*.md files are picked up without restart
|
|
@@ -458,6 +669,7 @@ Guidelines:
|
|
|
458
669
|
const inheritContext = params.inherit_context ?? customConfig?.inheritContext ?? false;
|
|
459
670
|
const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
|
|
460
671
|
const isolated = params.isolated ?? customConfig?.isolated ?? false;
|
|
672
|
+
const isolation = params.isolation ?? customConfig?.isolation;
|
|
461
673
|
// Build display tags for non-default config
|
|
462
674
|
const parentModelId = ctx.model?.id;
|
|
463
675
|
const effectiveModelId = model?.id;
|
|
@@ -472,6 +684,9 @@ Guidelines:
|
|
|
472
684
|
agentTags.push(`thinking: ${thinking}`);
|
|
473
685
|
if (isolated)
|
|
474
686
|
agentTags.push("isolated");
|
|
687
|
+
if (isolation === "worktree")
|
|
688
|
+
agentTags.push("worktree");
|
|
689
|
+
const effectiveMaxTurns = params.max_turns ?? customConfig?.maxTurns ?? getDefaultMaxTurns();
|
|
475
690
|
// Shared base fields for all AgentDetails in this call
|
|
476
691
|
const detailBase = {
|
|
477
692
|
displayName,
|
|
@@ -497,8 +712,20 @@ Guidelines:
|
|
|
497
712
|
}
|
|
498
713
|
// Background execution
|
|
499
714
|
if (runInBackground) {
|
|
500
|
-
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker();
|
|
501
|
-
|
|
715
|
+
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
|
|
716
|
+
// Wrap onSessionCreated to wire output file streaming.
|
|
717
|
+
// The callback lazily reads record.outputFile (set right after spawn)
|
|
718
|
+
// rather than closing over a value that doesn't exist yet.
|
|
719
|
+
let id;
|
|
720
|
+
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
721
|
+
bgCallbacks.onSessionCreated = (session) => {
|
|
722
|
+
origBgOnSession(session);
|
|
723
|
+
const rec = manager.getRecord(id);
|
|
724
|
+
if (rec?.outputFile) {
|
|
725
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
502
729
|
description: params.description,
|
|
503
730
|
model,
|
|
504
731
|
maxTurns: params.max_turns,
|
|
@@ -506,13 +733,19 @@ Guidelines:
|
|
|
506
733
|
inheritContext,
|
|
507
734
|
thinkingLevel: thinking,
|
|
508
735
|
isBackground: true,
|
|
736
|
+
isolation,
|
|
509
737
|
...bgCallbacks,
|
|
510
738
|
});
|
|
511
|
-
//
|
|
739
|
+
// Set output file + join mode synchronously after spawn, before the
|
|
740
|
+
// event loop yields — onSessionCreated is async so this is safe.
|
|
512
741
|
const joinMode = params.join_mode ?? defaultJoinMode;
|
|
513
742
|
const record = manager.getRecord(id);
|
|
514
|
-
if (record)
|
|
743
|
+
if (record) {
|
|
515
744
|
record.joinMode = joinMode;
|
|
745
|
+
record.toolCallId = toolCallId;
|
|
746
|
+
record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
|
|
747
|
+
writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
|
|
748
|
+
}
|
|
516
749
|
if (joinMode === 'async') {
|
|
517
750
|
// Explicit async — not part of any batch
|
|
518
751
|
}
|
|
@@ -528,11 +761,19 @@ Guidelines:
|
|
|
528
761
|
agentActivity.set(id, bgState);
|
|
529
762
|
widget.ensureTimer();
|
|
530
763
|
widget.update();
|
|
764
|
+
// Emit created event
|
|
765
|
+
pi.events.emit("subagents:created", {
|
|
766
|
+
id,
|
|
767
|
+
type: subagentType,
|
|
768
|
+
description: params.description,
|
|
769
|
+
isBackground: true,
|
|
770
|
+
});
|
|
531
771
|
const isQueued = record?.status === "queued";
|
|
532
772
|
return textResult(`Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
533
773
|
`Agent ID: ${id}\n` +
|
|
534
774
|
`Type: ${displayName}\n` +
|
|
535
775
|
`Description: ${params.description}\n` +
|
|
776
|
+
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
536
777
|
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
537
778
|
`\nYou will be notified when this agent completes.\n` +
|
|
538
779
|
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
@@ -547,6 +788,8 @@ Guidelines:
|
|
|
547
788
|
...detailBase,
|
|
548
789
|
toolUses: fgState.toolUses,
|
|
549
790
|
tokens: fgState.tokens,
|
|
791
|
+
turnCount: fgState.turnCount,
|
|
792
|
+
maxTurns: fgState.maxTurns,
|
|
550
793
|
durationMs: Date.now() - startedAt,
|
|
551
794
|
status: "running",
|
|
552
795
|
activity: describeActivity(fgState.activeTools, fgState.responseText),
|
|
@@ -557,7 +800,7 @@ Guidelines:
|
|
|
557
800
|
details: details,
|
|
558
801
|
});
|
|
559
802
|
};
|
|
560
|
-
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(streamUpdate);
|
|
803
|
+
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
|
|
561
804
|
// Wire session creation to register in widget
|
|
562
805
|
const origOnSession = fgCallbacks.onSessionCreated;
|
|
563
806
|
fgCallbacks.onSessionCreated = (session) => {
|
|
@@ -584,6 +827,7 @@ Guidelines:
|
|
|
584
827
|
isolated,
|
|
585
828
|
inheritContext,
|
|
586
829
|
thinkingLevel: thinking,
|
|
830
|
+
isolation,
|
|
587
831
|
...fgCallbacks,
|
|
588
832
|
});
|
|
589
833
|
clearInterval(spinnerInterval);
|
|
@@ -594,7 +838,7 @@ Guidelines:
|
|
|
594
838
|
}
|
|
595
839
|
// Get final token count
|
|
596
840
|
const tokenText = safeFormatTokens(fgState.session);
|
|
597
|
-
const details = buildDetails(detailBase, record, { tokens: tokenText });
|
|
841
|
+
const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
|
|
598
842
|
const fallbackNote = fellBack
|
|
599
843
|
? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
|
|
600
844
|
: "";
|
|
@@ -630,8 +874,13 @@ Guidelines:
|
|
|
630
874
|
if (!record) {
|
|
631
875
|
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
632
876
|
}
|
|
633
|
-
// Wait for completion if requested
|
|
877
|
+
// Wait for completion if requested.
|
|
878
|
+
// Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
|
|
879
|
+
// (attached earlier at spawn time) and always runs before this await resumes.
|
|
880
|
+
// Setting the flag here prevents a redundant follow-up notification.
|
|
634
881
|
if (params.wait && record.status === "running" && record.promise) {
|
|
882
|
+
record.resultConsumed = true;
|
|
883
|
+
cancelNudge(params.agent_id);
|
|
635
884
|
await record.promise;
|
|
636
885
|
}
|
|
637
886
|
const displayName = getDisplayName(record.type);
|
|
@@ -653,6 +902,7 @@ Guidelines:
|
|
|
653
902
|
// Mark result as consumed — suppresses the completion notification
|
|
654
903
|
if (record.status !== "running" && record.status !== "queued") {
|
|
655
904
|
record.resultConsumed = true;
|
|
905
|
+
cancelNudge(params.agent_id);
|
|
656
906
|
}
|
|
657
907
|
// Verbose: include full conversation
|
|
658
908
|
if (params.verbose && record.session) {
|
|
@@ -687,10 +937,16 @@ Guidelines:
|
|
|
687
937
|
return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`);
|
|
688
938
|
}
|
|
689
939
|
if (!record.session) {
|
|
690
|
-
|
|
940
|
+
// Session not ready yet — queue the steer for delivery once initialized
|
|
941
|
+
if (!record.pendingSteers)
|
|
942
|
+
record.pendingSteers = [];
|
|
943
|
+
record.pendingSteers.push(params.message);
|
|
944
|
+
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
945
|
+
return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
|
|
691
946
|
}
|
|
692
947
|
try {
|
|
693
948
|
await steerAgent(record.session, params.message);
|
|
949
|
+
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
694
950
|
return textResult(`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.`);
|
|
695
951
|
}
|
|
696
952
|
catch (err) {
|
|
@@ -961,12 +1217,18 @@ Guidelines:
|
|
|
961
1217
|
fmFields.push("skills: false");
|
|
962
1218
|
else if (Array.isArray(cfg.skills))
|
|
963
1219
|
fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
1220
|
+
if (cfg.disallowedTools?.length)
|
|
1221
|
+
fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
964
1222
|
if (cfg.inheritContext)
|
|
965
1223
|
fmFields.push("inherit_context: true");
|
|
966
1224
|
if (cfg.runInBackground)
|
|
967
1225
|
fmFields.push("run_in_background: true");
|
|
968
1226
|
if (cfg.isolated)
|
|
969
1227
|
fmFields.push("isolated: true");
|
|
1228
|
+
if (cfg.memory)
|
|
1229
|
+
fmFields.push(`memory: ${cfg.memory}`);
|
|
1230
|
+
if (cfg.isolation)
|
|
1231
|
+
fmFields.push(`isolation: ${cfg.isolation}`);
|
|
970
1232
|
const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
|
|
971
1233
|
const { writeFileSync } = await import("node:fs");
|
|
972
1234
|
writeFileSync(targetPath, content, "utf-8");
|
|
@@ -1073,13 +1335,16 @@ description: <one-line description shown in UI>
|
|
|
1073
1335
|
tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
|
|
1074
1336
|
model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
|
|
1075
1337
|
thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
|
|
1076
|
-
max_turns: <optional max agentic turns
|
|
1338
|
+
max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
|
|
1077
1339
|
prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
|
|
1078
1340
|
extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
|
|
1079
|
-
skills: <true (inherit all), false (none). Default: true>
|
|
1341
|
+
skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
|
|
1342
|
+
disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
|
|
1080
1343
|
inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
|
|
1081
1344
|
run_in_background: <true to run in background by default. Default: false>
|
|
1082
1345
|
isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
|
|
1346
|
+
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
|
|
1347
|
+
isolation: <"worktree" to run in isolated git worktree. Omit for normal>
|
|
1083
1348
|
---
|
|
1084
1349
|
|
|
1085
1350
|
<system prompt body — instructions for the agent>
|
|
@@ -1205,7 +1470,7 @@ ${systemPrompt}
|
|
|
1205
1470
|
async function showSettings(ctx) {
|
|
1206
1471
|
const choice = await ctx.ui.select("Settings", [
|
|
1207
1472
|
`Max concurrency (current: ${manager.getMaxConcurrent()})`,
|
|
1208
|
-
`Default max turns (current: ${getDefaultMaxTurns()})`,
|
|
1473
|
+
`Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`,
|
|
1209
1474
|
`Grace turns (current: ${getGraceTurns()})`,
|
|
1210
1475
|
`Join mode (current: ${getDefaultJoinMode()})`,
|
|
1211
1476
|
]);
|
|
@@ -1225,15 +1490,19 @@ ${systemPrompt}
|
|
|
1225
1490
|
}
|
|
1226
1491
|
}
|
|
1227
1492
|
else if (choice.startsWith("Default max turns")) {
|
|
1228
|
-
const val = await ctx.ui.input("Default max turns before wrap-up", String(getDefaultMaxTurns()));
|
|
1493
|
+
const val = await ctx.ui.input("Default max turns before wrap-up (0 = unlimited)", String(getDefaultMaxTurns() ?? 0));
|
|
1229
1494
|
if (val) {
|
|
1230
1495
|
const n = parseInt(val, 10);
|
|
1231
|
-
if (n
|
|
1496
|
+
if (n === 0) {
|
|
1497
|
+
setDefaultMaxTurns(undefined);
|
|
1498
|
+
ctx.ui.notify("Default max turns set to unlimited", "info");
|
|
1499
|
+
}
|
|
1500
|
+
else if (n >= 1) {
|
|
1232
1501
|
setDefaultMaxTurns(n);
|
|
1233
1502
|
ctx.ui.notify(`Default max turns set to ${n}`, "info");
|
|
1234
1503
|
}
|
|
1235
1504
|
else {
|
|
1236
|
-
ctx.ui.notify("Must be a positive integer.", "warning");
|
|
1505
|
+
ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
|
|
1237
1506
|
}
|
|
1238
1507
|
}
|
|
1239
1508
|
}
|