@narumitw/pi-subagents 0.25.0 → 0.26.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/README.md +91 -51
- package/package.json +1 -1
- package/src/agents.ts +3 -0
- package/src/config-ui.ts +566 -205
- package/src/in-process-transport.ts +15 -10
- package/src/persistence.ts +4 -1
- package/src/registry.ts +33 -15
- package/src/settings.ts +113 -0
- package/src/stateful.ts +401 -106
- package/src/subagents.ts +18 -18
- package/src/subprocess-transport.ts +10 -9
package/src/stateful.ts
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
3
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
4
|
+
import {
|
|
5
|
+
defineTool,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
type ExtensionContext,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
9
|
import { Type } from "typebox";
|
|
6
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type AgentScope,
|
|
12
|
+
type CompletionDelivery,
|
|
13
|
+
discoverAgents,
|
|
14
|
+
isThinkingLevel,
|
|
15
|
+
THINKING_LEVELS,
|
|
16
|
+
} from "./agents.js";
|
|
7
17
|
import { buildContextSnapshot, type ContextMode, redactPrivateText } from "./context.js";
|
|
8
18
|
import { assertSubagentDepthAllowed } from "./execution.js";
|
|
9
|
-
import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
|
|
10
|
-
import { AgentPersistence } from "./persistence.js";
|
|
11
|
-
import { AgentRegistry, type AgentTurnCompletion, type ManagedAgent } from "./registry.js";
|
|
12
|
-
import { readSubagentSettings } from "./settings.js";
|
|
13
|
-
import { SubprocessTransport } from "./subprocess-transport.js";
|
|
14
19
|
import {
|
|
15
20
|
type ChildSessionFactory,
|
|
16
21
|
InProcessTransport,
|
|
17
22
|
type ParentRuntimeSnapshot,
|
|
18
23
|
} from "./in-process-transport.js";
|
|
24
|
+
import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
|
|
25
|
+
import { AgentPersistence } from "./persistence.js";
|
|
26
|
+
import { AgentRegistry, type AgentTurnCompletion, type ManagedAgent } from "./registry.js";
|
|
27
|
+
import { readSubagentSettings } from "./settings.js";
|
|
28
|
+
import { SubprocessTransport } from "./subprocess-transport.js";
|
|
19
29
|
import { WorkspaceManager } from "./workspace.js";
|
|
20
30
|
|
|
21
31
|
const ContextModeSchema = Type.Union([
|
|
@@ -27,29 +37,121 @@ const ScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
|
27
37
|
'Per-invocation custom agent scope for this spawn. Default: "user". Use "project" for project-local agents or "both" for user and project agents; the selected scope is retained for follow-ups.',
|
|
28
38
|
default: "user",
|
|
29
39
|
});
|
|
40
|
+
const StatefulThinkingLevelSchema = StringEnum(THINKING_LEVELS, {
|
|
41
|
+
description:
|
|
42
|
+
"Optional requested Pi thinking level selected for this task difficulty; retained for every turn of the spawned agent.",
|
|
43
|
+
});
|
|
30
44
|
const MAX_TOOL_MESSAGE_BYTES = 2 * 1024;
|
|
31
45
|
const MAX_COMPLETION_ERROR_BYTES = 512;
|
|
46
|
+
const MAX_COMPLETIONS_PER_MESSAGE = 16;
|
|
47
|
+
const COMPLETION_BATCH_DELAY_MS = 10;
|
|
48
|
+
|
|
49
|
+
function createSpawnPromptGuidelines(completionDelivery: CompletionDelivery): string[] {
|
|
50
|
+
const deliveryGuidance =
|
|
51
|
+
completionDelivery === "auto-resume"
|
|
52
|
+
? "With subagent_spawn completion delivery set to auto-resume, prefer one subagent_spawn for broad asynchronous research or review that covers related branches even when the final answer depends on its result; do not choose blocking parallel fan-out merely to keep delegation in the same turn."
|
|
53
|
+
: "With subagent_spawn completion delivery set to next-turn (the default), prefer one subagent_spawn for broad asynchronous research or review only when the current response does not depend on its result; use the blocking subagent when the final answer depends on the detached result.";
|
|
54
|
+
const noLocalWorkGuidance =
|
|
55
|
+
completionDelivery === "auto-resume"
|
|
56
|
+
? "After subagent_spawn returns, do useful non-overlapping local work immediately. If none remains, briefly tell the user what subagent_spawn launched and end the response; auto-resume will request a synthesis turn after completion."
|
|
57
|
+
: "After subagent_spawn returns, do useful non-overlapping local work immediately. If none remains, briefly tell the user what subagent_spawn launched and end the response only when the current response does not depend on its result; next-turn delivery will not wake an idle root.";
|
|
58
|
+
return [
|
|
59
|
+
"Do not use subagent_spawn for simple or critical-path work that the main agent can perform directly.",
|
|
60
|
+
"Set subagent_spawn thinkingLevel to the lowest sufficient thinking level for the delegated task: use off or minimal for extraction, formatting, or mechanical work; low for straightforward bounded work; medium for ordinary multi-step research or implementation; high for complex debugging, design, review, or cross-file analysis; xhigh for highly ambiguous, cross-system, or high-risk analysis; and max only for the hardest tasks when quality clearly outweighs latency and cost. Omit subagent_spawn thinkingLevel only to preserve the agent or child default.",
|
|
61
|
+
deliveryGuidance,
|
|
62
|
+
"Use a single subagent_spawn only for a concrete bounded subtask that can run independently and has an isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation.",
|
|
63
|
+
"Use the blocking subagent instead of subagent_spawn when synchronous output is required before the main agent can continue and waiting is intentional; queued steering cannot be processed until that blocking call returns.",
|
|
64
|
+
"When subagent_spawn fits the completion-delivery policy, do not choose a blocking parallel subagent merely to keep delegation in the same turn.",
|
|
65
|
+
"Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
|
|
66
|
+
noLocalWorkGuidance,
|
|
67
|
+
"Consume and synthesize available subagent_spawn completion messages; interrupt or close agents that are no longer needed.",
|
|
68
|
+
"subagent_spawn completion is delivered automatically. Do not poll with subagent_list or subagent_messages, repeatedly check progress, or duplicate the delegated work.",
|
|
69
|
+
];
|
|
70
|
+
}
|
|
32
71
|
|
|
33
72
|
export interface StatefulSubagentDependencies {
|
|
34
73
|
createInProcessSession?: ChildSessionFactory;
|
|
74
|
+
workspaceManager?: WorkspaceManager;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface StatefulSubagentRuntimeStatus {
|
|
78
|
+
enabled: boolean;
|
|
79
|
+
initialized: boolean;
|
|
80
|
+
transport: "subprocess" | "in-process";
|
|
81
|
+
completionDelivery: CompletionDelivery;
|
|
82
|
+
activeAgents: number;
|
|
83
|
+
retainedAgents: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface StatefulSubagentController {
|
|
87
|
+
getCompletionDelivery(): CompletionDelivery;
|
|
88
|
+
setCompletionDelivery(value: CompletionDelivery): void;
|
|
89
|
+
getRuntimeStatus(): StatefulSubagentRuntimeStatus;
|
|
90
|
+
listAgents(includeClosed?: boolean): ManagedAgent[];
|
|
91
|
+
clearAgents(): Promise<number>;
|
|
35
92
|
}
|
|
36
93
|
|
|
37
94
|
export function registerStatefulSubagents(
|
|
38
95
|
pi: ExtensionAPI,
|
|
39
96
|
dependencies: StatefulSubagentDependencies = {},
|
|
40
|
-
):
|
|
97
|
+
): StatefulSubagentController {
|
|
41
98
|
const settings = readSubagentSettings()?.stateful ?? {};
|
|
42
|
-
|
|
43
|
-
|
|
99
|
+
const enabled = settings.enabled !== false;
|
|
100
|
+
const transportKind = resolveStatefulTransportKind(settings.transport);
|
|
101
|
+
let completionDelivery = resolveCompletionDelivery(settings.completionDelivery);
|
|
102
|
+
let completionBroker: CompletionDeliveryBroker | undefined;
|
|
103
|
+
let refreshSpawnToolRegistration: (() => void) | undefined;
|
|
44
104
|
let registry: AgentRegistry | undefined;
|
|
45
105
|
let persistence: AgentPersistence | undefined;
|
|
46
106
|
let sweepTimer: NodeJS.Timeout | undefined;
|
|
47
107
|
let runtimeGeneration = 0;
|
|
48
|
-
const workspaceManager = new WorkspaceManager();
|
|
108
|
+
const workspaceManager = dependencies.workspaceManager ?? new WorkspaceManager();
|
|
49
109
|
const isolatedAgents = new Map<string, string>();
|
|
50
110
|
const seenMessageIds = new Set<string>();
|
|
51
111
|
const parentRuntime: ParentRuntimeSnapshot = { model: undefined, thinkingLevel: "off" };
|
|
52
|
-
|
|
112
|
+
|
|
113
|
+
const clearAgents = async (): Promise<number> => {
|
|
114
|
+
const currentRegistry = registry;
|
|
115
|
+
if (!currentRegistry) return 0;
|
|
116
|
+
const count = currentRegistry.list().length;
|
|
117
|
+
try {
|
|
118
|
+
await currentRegistry.closeAll();
|
|
119
|
+
} finally {
|
|
120
|
+
await workspaceManager.cleanupAll();
|
|
121
|
+
isolatedAgents.clear();
|
|
122
|
+
}
|
|
123
|
+
seenMessageIds.clear();
|
|
124
|
+
await persistence?.delete();
|
|
125
|
+
return count;
|
|
126
|
+
};
|
|
127
|
+
const controller: StatefulSubagentController = {
|
|
128
|
+
getCompletionDelivery() {
|
|
129
|
+
return completionDelivery;
|
|
130
|
+
},
|
|
131
|
+
setCompletionDelivery(value) {
|
|
132
|
+
completionDelivery = value;
|
|
133
|
+
completionBroker?.setDelivery(value);
|
|
134
|
+
refreshSpawnToolRegistration?.();
|
|
135
|
+
},
|
|
136
|
+
getRuntimeStatus() {
|
|
137
|
+
const agents = registry?.list(true) ?? [];
|
|
138
|
+
return {
|
|
139
|
+
enabled,
|
|
140
|
+
initialized: registry !== undefined,
|
|
141
|
+
transport: transportKind,
|
|
142
|
+
completionDelivery,
|
|
143
|
+
activeAgents: agents.filter(
|
|
144
|
+
(agent) => agent.state === "starting" || agent.state === "running",
|
|
145
|
+
).length,
|
|
146
|
+
retainedAgents: agents.filter((agent) => agent.state !== "closed").length,
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
listAgents(includeClosed = false) {
|
|
150
|
+
return registry?.list(includeClosed) ?? [];
|
|
151
|
+
},
|
|
152
|
+
clearAgents,
|
|
153
|
+
};
|
|
154
|
+
if (!enabled) return controller;
|
|
53
155
|
|
|
54
156
|
const requireRegistry = () => {
|
|
55
157
|
if (!registry) throw new Error("Stateful subagents are not initialized for this session");
|
|
@@ -58,6 +160,8 @@ export function registerStatefulSubagents(
|
|
|
58
160
|
|
|
59
161
|
pi.on("session_start", async (_event, ctx) => {
|
|
60
162
|
const generation = ++runtimeGeneration;
|
|
163
|
+
completionBroker?.close();
|
|
164
|
+
completionBroker = undefined;
|
|
61
165
|
parentRuntime.model = ctx.model;
|
|
62
166
|
parentRuntime.thinkingLevel = normalizeRuntimeThinkingLevel(pi.getThinkingLevel());
|
|
63
167
|
const owner =
|
|
@@ -69,14 +173,21 @@ export function registerStatefulSubagents(
|
|
|
69
173
|
maxStoredAgents: settings.maxStoredAgents,
|
|
70
174
|
});
|
|
71
175
|
persistence = sessionPersistence;
|
|
176
|
+
completionBroker = new CompletionDeliveryBroker(pi, ctx, completionDelivery, {
|
|
177
|
+
onDeliveryError: (error) => {
|
|
178
|
+
if (!ctx.hasUI) return;
|
|
179
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
180
|
+
ctx.ui.notify(`Subagent completion delivery failed: ${reason}`, "warning");
|
|
181
|
+
},
|
|
182
|
+
});
|
|
72
183
|
const transport =
|
|
73
|
-
|
|
184
|
+
transportKind === "in-process"
|
|
74
185
|
? new InProcessTransport({
|
|
75
186
|
modelRegistry: ctx.modelRegistry,
|
|
76
187
|
getParentRuntime: () => ({ ...parentRuntime }),
|
|
77
188
|
createSession: dependencies.createInProcessSession,
|
|
78
189
|
})
|
|
79
|
-
: new SubprocessTransport(
|
|
190
|
+
: new SubprocessTransport();
|
|
80
191
|
registry = new AgentRegistry(transport, {
|
|
81
192
|
maxAgents: settings.maxAgents,
|
|
82
193
|
maxActiveTurns: settings.maxActiveTurns,
|
|
@@ -102,9 +213,7 @@ export function registerStatefulSubagents(
|
|
|
102
213
|
},
|
|
103
214
|
onTurnComplete: (completion) => {
|
|
104
215
|
if (generation !== runtimeGeneration) return;
|
|
105
|
-
|
|
106
|
-
sendDetachedCompletion(pi, completion);
|
|
107
|
-
}
|
|
216
|
+
completionBroker?.enqueue(completion);
|
|
108
217
|
},
|
|
109
218
|
});
|
|
110
219
|
const restored = sessionPersistence
|
|
@@ -128,6 +237,14 @@ export function registerStatefulSubagents(
|
|
|
128
237
|
sweepTimer.unref();
|
|
129
238
|
});
|
|
130
239
|
|
|
240
|
+
pi.on("agent_start", () => {
|
|
241
|
+
completionBroker?.onParentTurnStart();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
pi.on("agent_settled", () => {
|
|
245
|
+
completionBroker?.onParentSettled();
|
|
246
|
+
});
|
|
247
|
+
|
|
131
248
|
pi.on("model_select", (event) => {
|
|
132
249
|
parentRuntime.model = event.model;
|
|
133
250
|
});
|
|
@@ -138,6 +255,8 @@ export function registerStatefulSubagents(
|
|
|
138
255
|
|
|
139
256
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
140
257
|
runtimeGeneration++;
|
|
258
|
+
completionBroker?.close();
|
|
259
|
+
completionBroker = undefined;
|
|
141
260
|
if (sweepTimer) clearInterval(sweepTimer);
|
|
142
261
|
sweepTimer = undefined;
|
|
143
262
|
for (const agentId of isolatedAgents.keys()) {
|
|
@@ -145,39 +264,35 @@ export function registerStatefulSubagents(
|
|
|
145
264
|
}
|
|
146
265
|
isolatedAgents.clear();
|
|
147
266
|
seenMessageIds.clear();
|
|
148
|
-
completionWaiters.clear();
|
|
149
267
|
let cleanupError: unknown;
|
|
150
268
|
try {
|
|
151
269
|
await workspaceManager.cleanupAll();
|
|
152
270
|
} catch (error) {
|
|
153
271
|
cleanupError = error;
|
|
154
272
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
273
|
+
try {
|
|
274
|
+
await registry?.shutdown();
|
|
275
|
+
} finally {
|
|
276
|
+
registry = undefined;
|
|
277
|
+
persistence = undefined;
|
|
278
|
+
}
|
|
158
279
|
if (cleanupError && ctx.hasUI) {
|
|
159
280
|
const reason = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
160
281
|
ctx.ui.notify(`Some isolated subagent workspaces could not be removed: ${reason}`, "warning");
|
|
161
282
|
}
|
|
162
283
|
});
|
|
163
284
|
|
|
164
|
-
|
|
285
|
+
const spawnTool = defineTool({
|
|
165
286
|
name: "subagent_spawn",
|
|
166
287
|
label: "Spawn Subagent",
|
|
167
288
|
description:
|
|
168
|
-
"Start an addressable background subagent, return immediately with an agentId, and receive its completion asynchronously.",
|
|
289
|
+
"Start an addressable background subagent with an optional thinking level chosen for the task difficulty, return immediately with an agentId, and receive its completion asynchronously.",
|
|
169
290
|
promptSnippet: "Start a reusable detached subagent; completion is delivered asynchronously",
|
|
170
|
-
promptGuidelines:
|
|
171
|
-
"Do not use subagent_spawn for simple or critical-path work that the main agent can perform directly.",
|
|
172
|
-
"Use a single subagent_spawn only for a concrete bounded subtask that can run independently alongside useful main-agent work and has an isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation.",
|
|
173
|
-
"Use one blocking subagent parallel call for multiple independent one-shot tasks; do not use repeated subagent_spawn calls when no reuse or overlap is needed.",
|
|
174
|
-
"After subagent_spawn returns, do useful non-overlapping local work immediately. Call subagent_wait sparingly, only when the immediate next critical-path step requires the result and is blocked until it arrives; do not wait repeatedly by reflex.",
|
|
175
|
-
"Consume and synthesize available subagent_spawn completion messages; interrupt or close agents that are no longer needed.",
|
|
176
|
-
"subagent_spawn completion is delivered automatically. Do not poll, wait forever, or spawn additional agents without a distinct need.",
|
|
177
|
-
],
|
|
291
|
+
promptGuidelines: createSpawnPromptGuidelines(completionDelivery),
|
|
178
292
|
parameters: Type.Object({
|
|
179
293
|
agent: Type.String({ minLength: 1 }),
|
|
180
294
|
task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
|
|
295
|
+
thinkingLevel: Type.Optional(StatefulThinkingLevelSchema),
|
|
181
296
|
cwd: Type.Optional(Type.String()),
|
|
182
297
|
agentScope: Type.Optional(ScopeSchema),
|
|
183
298
|
confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
|
|
@@ -229,6 +344,7 @@ export function registerStatefulSubagents(
|
|
|
229
344
|
task: params.task,
|
|
230
345
|
cwd: workspace?.path ?? requestedCwd,
|
|
231
346
|
agentScope: scope,
|
|
347
|
+
thinkingLevel: params.thinkingLevel,
|
|
232
348
|
parentId: params.parentId,
|
|
233
349
|
context: snapshot.text || undefined,
|
|
234
350
|
contextSourceIds: snapshot.sourceIds,
|
|
@@ -239,12 +355,21 @@ export function registerStatefulSubagents(
|
|
|
239
355
|
throw error;
|
|
240
356
|
}
|
|
241
357
|
if (workspace) isolatedAgents.set(agent.id, workspaceOwner);
|
|
358
|
+
const deliveryNote =
|
|
359
|
+
completionDelivery === "auto-resume"
|
|
360
|
+
? "If no useful local work remains, briefly tell the user what was launched and end the response; auto-resume will request synthesis after completion."
|
|
361
|
+
: "End the response without the result only when the current response does not depend on it; next-turn delivery will not wake an idle root.";
|
|
242
362
|
return result(
|
|
243
363
|
agent,
|
|
244
|
-
`Spawned ${agent.agent} as ${agent.id}. Do useful non-overlapping work immediately.
|
|
364
|
+
`Spawned ${agent.agent} as ${agent.id}. Do useful non-overlapping work immediately. ${deliveryNote} Do not poll for progress.`,
|
|
245
365
|
);
|
|
246
366
|
},
|
|
247
367
|
});
|
|
368
|
+
refreshSpawnToolRegistration = () => {
|
|
369
|
+
spawnTool.promptGuidelines = createSpawnPromptGuidelines(completionDelivery);
|
|
370
|
+
pi.registerTool(spawnTool);
|
|
371
|
+
};
|
|
372
|
+
refreshSpawnToolRegistration();
|
|
248
373
|
|
|
249
374
|
pi.registerTool({
|
|
250
375
|
name: "subagent_send",
|
|
@@ -333,35 +458,6 @@ export function registerStatefulSubagents(
|
|
|
333
458
|
},
|
|
334
459
|
});
|
|
335
460
|
|
|
336
|
-
pi.registerTool({
|
|
337
|
-
name: "subagent_wait",
|
|
338
|
-
label: "Wait for Subagent",
|
|
339
|
-
description:
|
|
340
|
-
"Wait for a stateful subagent turn without terminating it when the wait times out. Use sparingly, only when the immediate next critical-path step is blocked on this result.",
|
|
341
|
-
parameters: Type.Object({
|
|
342
|
-
agentId: Type.String(),
|
|
343
|
-
timeoutMs: Type.Optional(Type.Number({ minimum: 1, maximum: 3_600_000, default: 30_000 })),
|
|
344
|
-
}),
|
|
345
|
-
async execute(_id, params, signal) {
|
|
346
|
-
const existing = requireRegistry().get(params.agentId);
|
|
347
|
-
const registered = existing?.state === "starting" || existing?.state === "running";
|
|
348
|
-
if (registered) {
|
|
349
|
-
completionWaiters.set(params.agentId, (completionWaiters.get(params.agentId) ?? 0) + 1);
|
|
350
|
-
}
|
|
351
|
-
try {
|
|
352
|
-
const waited = await requireRegistry().wait(params.agentId, params.timeoutMs, signal);
|
|
353
|
-
return result(
|
|
354
|
-
waited.agent,
|
|
355
|
-
waited.timedOut
|
|
356
|
-
? `Wait timed out; ${waited.agent.id} is ${waited.agent.state}.`
|
|
357
|
-
: formatFinal(waited.agent),
|
|
358
|
-
);
|
|
359
|
-
} finally {
|
|
360
|
-
if (registered) decrementWaiter(completionWaiters, params.agentId);
|
|
361
|
-
}
|
|
362
|
-
},
|
|
363
|
-
});
|
|
364
|
-
|
|
365
461
|
pi.registerTool({
|
|
366
462
|
name: "subagent_list",
|
|
367
463
|
label: "List Subagents",
|
|
@@ -392,10 +488,12 @@ export function registerStatefulSubagents(
|
|
|
392
488
|
async execute(_id, params) {
|
|
393
489
|
if (params.subtree) {
|
|
394
490
|
const agents = await requireRegistry().interruptTree(params.agentId);
|
|
491
|
+
const root = requireRegistry().get(params.agentId);
|
|
492
|
+
if (!root) throw new Error(`Unknown subagent: ${params.agentId}`);
|
|
395
493
|
return {
|
|
396
494
|
content: [{ type: "text", text: `Interrupted ${agents.length} active agent(s).` }],
|
|
397
495
|
details: {
|
|
398
|
-
agent: summarizeAgent(
|
|
496
|
+
agent: summarizeAgent(root),
|
|
399
497
|
agents: agents.map(summarizeAgent),
|
|
400
498
|
},
|
|
401
499
|
};
|
|
@@ -428,10 +526,12 @@ export function registerStatefulSubagents(
|
|
|
428
526
|
} finally {
|
|
429
527
|
await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
|
|
430
528
|
}
|
|
529
|
+
const root = requireRegistry().get(params.agentId);
|
|
530
|
+
if (!root) throw new Error(`Unknown subagent: ${params.agentId}`);
|
|
431
531
|
return {
|
|
432
532
|
content: [{ type: "text", text: `Closed ${agents.length} agent(s).` }],
|
|
433
533
|
details: {
|
|
434
|
-
agent: summarizeAgent(
|
|
534
|
+
agent: summarizeAgent(root),
|
|
435
535
|
agents: agents.map(summarizeAgent),
|
|
436
536
|
},
|
|
437
537
|
};
|
|
@@ -447,32 +547,39 @@ export function registerStatefulSubagents(
|
|
|
447
547
|
});
|
|
448
548
|
|
|
449
549
|
pi.registerCommand("subagents:agents", {
|
|
450
|
-
description: "Inspect or clear
|
|
550
|
+
description: "Inspect or clear current-session subagents",
|
|
451
551
|
getArgumentCompletions(prefix: string) {
|
|
452
552
|
return ["list", "clear"]
|
|
453
553
|
.filter((value) => value.startsWith(prefix))
|
|
454
554
|
.map((value) => ({ value, label: value }));
|
|
455
555
|
},
|
|
456
556
|
async handler(args, ctx) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
ctx.ui.notify("Cleared stateful subagents.", "info");
|
|
557
|
+
const subcommand = args.trim().toLowerCase() || "list";
|
|
558
|
+
if (subcommand === "clear") {
|
|
559
|
+
const count = await controller.clearAgents();
|
|
560
|
+
ctx.ui.notify(
|
|
561
|
+
count > 0
|
|
562
|
+
? `Cleared ${count} current-session subagent${count === 1 ? "" : "s"}.`
|
|
563
|
+
: statefulEmptyMessage(controller.getRuntimeStatus()),
|
|
564
|
+
"info",
|
|
565
|
+
);
|
|
467
566
|
return;
|
|
468
567
|
}
|
|
469
|
-
|
|
568
|
+
if (subcommand !== "list") {
|
|
569
|
+
ctx.ui.notify(`Unknown /subagents:agents subcommand: ${subcommand}`, "warning");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const agents = controller.listAgents(true);
|
|
470
573
|
ctx.ui.notify(
|
|
471
|
-
agents.length
|
|
574
|
+
agents.length
|
|
575
|
+
? agents.map(formatLine).join("\n")
|
|
576
|
+
: statefulEmptyMessage(controller.getRuntimeStatus()),
|
|
472
577
|
"info",
|
|
473
578
|
);
|
|
474
579
|
},
|
|
475
580
|
});
|
|
581
|
+
|
|
582
|
+
return controller;
|
|
476
583
|
}
|
|
477
584
|
|
|
478
585
|
export function assertNoSharedWriteConflict(
|
|
@@ -495,7 +602,7 @@ export function assertNoSharedWriteConflict(
|
|
|
495
602
|
if (isWriteCapable(activeConfig?.tools)) {
|
|
496
603
|
throw new Error(
|
|
497
604
|
`Write-capable subagent ${active.id} is already active in shared workspace ${cwd}. ` +
|
|
498
|
-
"
|
|
605
|
+
"Prefer one subagent_spawn covering combined asynchronous work. Use the blocking subagent parallel mode only when concurrent synchronous outputs justify making the main agent unavailable. Otherwise let the active agent finish or close it; set allowConcurrentWrites only when overlapping writes are knowingly safe, or use workspaceMode worktree when repository isolation is needed.",
|
|
499
606
|
);
|
|
500
607
|
}
|
|
501
608
|
}
|
|
@@ -516,12 +623,6 @@ export function isWriteCapable(tools: string[] | undefined): boolean {
|
|
|
516
623
|
return tools.some((tool) => ["bash", "write", "edit"].includes(tool));
|
|
517
624
|
}
|
|
518
625
|
|
|
519
|
-
function decrementWaiter(waiters: Map<string, number>, agentId: string): void {
|
|
520
|
-
const count = waiters.get(agentId);
|
|
521
|
-
if (count === undefined || count <= 1) waiters.delete(agentId);
|
|
522
|
-
else waiters.set(agentId, count - 1);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
626
|
async function confirmProjectAgent(
|
|
526
627
|
name: string,
|
|
527
628
|
scope: AgentScope,
|
|
@@ -566,23 +667,40 @@ export function resolveSpawnContextMode(
|
|
|
566
667
|
return normalizeContextMode(value);
|
|
567
668
|
}
|
|
568
669
|
|
|
569
|
-
function
|
|
670
|
+
function statefulEmptyMessage(status: StatefulSubagentRuntimeStatus): string {
|
|
671
|
+
if (!status.enabled) return "Stateful subagents are disabled in user settings.";
|
|
672
|
+
if (!status.initialized) return "Stateful subagents are not initialized for this session.";
|
|
673
|
+
return "No current-session subagents.";
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export function formatStatefulAgentLine(agent: ManagedAgent): string {
|
|
570
677
|
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - agent.updatedAt) / 1000));
|
|
571
678
|
const actions =
|
|
572
679
|
agent.state === "running" || agent.state === "starting"
|
|
573
|
-
? "
|
|
680
|
+
? "interrupt, close"
|
|
574
681
|
: agent.state === "closed"
|
|
575
682
|
? "inspect"
|
|
576
683
|
: "send, close";
|
|
577
|
-
const task = agent.currentTask ? ` — ${agent.currentTask
|
|
684
|
+
const task = agent.currentTask ? ` — ${sanitizeStatusLine(agent.currentTask, 80)}` : "";
|
|
578
685
|
const unread = agent.mailbox.filter((message) => !message.readAt).length;
|
|
579
686
|
const indent = " ".repeat(agent.depth);
|
|
580
|
-
|
|
687
|
+
const thinking = agent.thinkingLevel ? ` thinking:${agent.thinkingLevel}` : "";
|
|
688
|
+
return `${indent}${sanitizeStatusLine(agent.id, 128)} ${sanitizeStatusLine(agent.agent, 128)} ${agent.state} ${elapsedSeconds}s${thinking} unread:${unread} [${actions}]${task}`;
|
|
581
689
|
}
|
|
582
690
|
|
|
583
|
-
function
|
|
584
|
-
|
|
585
|
-
|
|
691
|
+
function sanitizeStatusLine(value: string, maxLength: number): string {
|
|
692
|
+
return (
|
|
693
|
+
value
|
|
694
|
+
.slice(0, maxLength)
|
|
695
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
|
|
696
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
|
697
|
+
.replace(/\s+/gu, " ")
|
|
698
|
+
.trim()
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function formatLine(agent: ManagedAgent): string {
|
|
703
|
+
return formatStatefulAgentLine(agent);
|
|
586
704
|
}
|
|
587
705
|
|
|
588
706
|
function summarizeAgent(agent: ManagedAgent) {
|
|
@@ -597,6 +715,7 @@ function summarizeAgent(agent: ManagedAgent) {
|
|
|
597
715
|
createdAt: agent.createdAt,
|
|
598
716
|
updatedAt: agent.updatedAt,
|
|
599
717
|
cwd: agent.cwd,
|
|
718
|
+
thinkingLevel: agent.thinkingLevel,
|
|
600
719
|
currentTask: agent.currentTask
|
|
601
720
|
? truncateUtf8(agent.currentTask, MAX_TOOL_MESSAGE_BYTES).text
|
|
602
721
|
: undefined,
|
|
@@ -607,21 +726,188 @@ function summarizeAgent(agent: ManagedAgent) {
|
|
|
607
726
|
};
|
|
608
727
|
}
|
|
609
728
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
729
|
+
interface CompletionMetadata {
|
|
730
|
+
agentId: string;
|
|
731
|
+
agent: string;
|
|
732
|
+
state: string;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
interface CompletionMessage {
|
|
736
|
+
customType: "pi-subagent-completion";
|
|
737
|
+
content: string;
|
|
738
|
+
display: true;
|
|
739
|
+
details:
|
|
740
|
+
| CompletionMetadata
|
|
741
|
+
| {
|
|
742
|
+
completionCount: number;
|
|
743
|
+
completions: CompletionMetadata[];
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
type CompletionContext = Pick<ExtensionContext, "hasPendingMessages" | "isIdle">;
|
|
748
|
+
type CompletionPi = Pick<ExtensionAPI, "sendMessage">;
|
|
749
|
+
|
|
750
|
+
export interface CompletionDeliveryBrokerOptions {
|
|
751
|
+
onDeliveryError?: (error: unknown) => void;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Coalesces detached completions so one bounded notification batch starts at
|
|
756
|
+
* most one root synthesis turn. The broker belongs to one parent session and
|
|
757
|
+
* must be closed when that session is replaced or shut down.
|
|
758
|
+
*/
|
|
759
|
+
export class CompletionDeliveryBroker {
|
|
760
|
+
private pending: AgentTurnCompletion[] = [];
|
|
761
|
+
private flushTimer?: NodeJS.Timeout;
|
|
762
|
+
private wakeInFlight = false;
|
|
763
|
+
private closed = false;
|
|
764
|
+
|
|
765
|
+
constructor(
|
|
766
|
+
private readonly pi: CompletionPi,
|
|
767
|
+
private readonly ctx: CompletionContext,
|
|
768
|
+
private delivery: CompletionDelivery,
|
|
769
|
+
private readonly options: CompletionDeliveryBrokerOptions = {},
|
|
770
|
+
) {}
|
|
771
|
+
|
|
772
|
+
enqueue(completion: AgentTurnCompletion): void {
|
|
773
|
+
if (this.closed) return;
|
|
774
|
+
this.pending.push(completion);
|
|
775
|
+
this.scheduleFlush();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
setDelivery(value: CompletionDelivery): void {
|
|
779
|
+
this.delivery = value;
|
|
780
|
+
this.scheduleFlush();
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
onParentTurnStart(): void {
|
|
784
|
+
this.wakeInFlight = false;
|
|
785
|
+
this.scheduleFlush();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
onParentSettled(): void {
|
|
789
|
+
this.wakeInFlight = false;
|
|
790
|
+
this.scheduleFlush();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
flush(): void {
|
|
794
|
+
if (this.closed || this.pending.length === 0) return;
|
|
795
|
+
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
796
|
+
this.flushTimer = undefined;
|
|
797
|
+
if (this.delivery === "auto-resume" && !this.isRootIdle()) return;
|
|
798
|
+
|
|
799
|
+
const completions = this.pending.splice(0);
|
|
800
|
+
const batches = chunkCompletions(completions);
|
|
801
|
+
let canWake = this.shouldWakeRoot();
|
|
802
|
+
for (let index = 0; index < batches.length; index++) {
|
|
803
|
+
const triggerTurn = canWake && index === batches.length - 1;
|
|
804
|
+
const message = buildCompletionMessage(batches[index]);
|
|
805
|
+
if (triggerTurn) this.wakeInFlight = true;
|
|
806
|
+
try {
|
|
807
|
+
this.pi.sendMessage(message, { deliverAs: "steer", triggerTurn });
|
|
808
|
+
} catch (primaryError) {
|
|
809
|
+
if (triggerTurn) this.wakeInFlight = false;
|
|
810
|
+
canWake = false;
|
|
811
|
+
try {
|
|
812
|
+
this.pi.sendMessage(message, { deliverAs: "nextTurn", triggerTurn: false });
|
|
813
|
+
} catch (fallbackError) {
|
|
814
|
+
this.pending = [...batches.slice(index).flat(), ...this.pending];
|
|
815
|
+
try {
|
|
816
|
+
this.options.onDeliveryError?.(
|
|
817
|
+
new AggregateError(
|
|
818
|
+
[primaryError, fallbackError],
|
|
819
|
+
"Detached subagent completion delivery failed",
|
|
820
|
+
),
|
|
821
|
+
);
|
|
822
|
+
} catch {
|
|
823
|
+
// Delivery retention must survive a failing observer.
|
|
824
|
+
}
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
close(): void {
|
|
832
|
+
this.closed = true;
|
|
833
|
+
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
834
|
+
this.flushTimer = undefined;
|
|
835
|
+
this.pending = [];
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
private scheduleFlush(): void {
|
|
839
|
+
if (this.closed || this.pending.length === 0 || this.flushTimer) return;
|
|
840
|
+
this.flushTimer = setTimeout(() => {
|
|
841
|
+
this.flushTimer = undefined;
|
|
842
|
+
this.flush();
|
|
843
|
+
}, COMPLETION_BATCH_DELAY_MS);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private isRootIdle(): boolean {
|
|
847
|
+
try {
|
|
848
|
+
return this.ctx.isIdle();
|
|
849
|
+
} catch {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
private shouldWakeRoot(): boolean {
|
|
855
|
+
if (this.delivery !== "auto-resume" || this.wakeInFlight) return false;
|
|
856
|
+
try {
|
|
857
|
+
return !this.ctx.hasPendingMessages();
|
|
858
|
+
} catch {
|
|
859
|
+
return false;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function chunkCompletions(completions: AgentTurnCompletion[]): AgentTurnCompletion[][] {
|
|
865
|
+
const batches: AgentTurnCompletion[][] = [];
|
|
866
|
+
for (let index = 0; index < completions.length; index += MAX_COMPLETIONS_PER_MESSAGE) {
|
|
867
|
+
batches.push(completions.slice(index, index + MAX_COMPLETIONS_PER_MESSAGE));
|
|
868
|
+
}
|
|
869
|
+
return batches;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function buildCompletionMessage(completions: AgentTurnCompletion[]): CompletionMessage {
|
|
873
|
+
if (completions.length === 1) {
|
|
874
|
+
const completion = completions[0];
|
|
875
|
+
return {
|
|
614
876
|
customType: "pi-subagent-completion",
|
|
615
|
-
content,
|
|
877
|
+
content: buildDetachedCompletionMessage(completion),
|
|
616
878
|
display: true,
|
|
617
|
-
details:
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
879
|
+
details: completionMetadata(completion),
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
const content = truncateUtf8(
|
|
883
|
+
[
|
|
884
|
+
"Message Type: SUBAGENT_COMPLETION_BATCH",
|
|
885
|
+
`Completion Count: ${completions.length}`,
|
|
886
|
+
...completions.flatMap((completion, index) => [
|
|
887
|
+
"",
|
|
888
|
+
`--- Completion ${index + 1} of ${completions.length} ---`,
|
|
889
|
+
buildDetachedCompletionMessage(completion),
|
|
890
|
+
]),
|
|
891
|
+
].join("\n"),
|
|
892
|
+
DEFAULT_MAX_CONTEXT_BYTES,
|
|
893
|
+
).text;
|
|
894
|
+
return {
|
|
895
|
+
customType: "pi-subagent-completion",
|
|
896
|
+
content,
|
|
897
|
+
display: true,
|
|
898
|
+
details: {
|
|
899
|
+
completionCount: completions.length,
|
|
900
|
+
completions: completions.map(completionMetadata),
|
|
622
901
|
},
|
|
623
|
-
|
|
624
|
-
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function completionMetadata(completion: AgentTurnCompletion): CompletionMetadata {
|
|
906
|
+
return {
|
|
907
|
+
agentId: completion.agent.id,
|
|
908
|
+
agent: completion.agent.agent,
|
|
909
|
+
state: completion.agent.state,
|
|
910
|
+
};
|
|
625
911
|
}
|
|
626
912
|
|
|
627
913
|
export function buildDetachedCompletionMessage(completion: AgentTurnCompletion): string {
|
|
@@ -647,10 +933,13 @@ export function buildDetachedCompletionMessage(completion: AgentTurnCompletion):
|
|
|
647
933
|
}
|
|
648
934
|
|
|
649
935
|
function sanitizeCompletionLine(value: string, maxBytes: number): string {
|
|
650
|
-
return
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
936
|
+
return (
|
|
937
|
+
truncateUtf8(redactPrivateText(value), maxBytes)
|
|
938
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Strip untrusted terminal controls.
|
|
939
|
+
.text.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
940
|
+
.replace(/\s+/g, " ")
|
|
941
|
+
.trim()
|
|
942
|
+
);
|
|
654
943
|
}
|
|
655
944
|
|
|
656
945
|
async function cleanupClosedWorkspaces(
|
|
@@ -678,6 +967,12 @@ export function resolveStatefulTransportKind(
|
|
|
678
967
|
return value ?? "subprocess";
|
|
679
968
|
}
|
|
680
969
|
|
|
970
|
+
export function resolveCompletionDelivery(
|
|
971
|
+
value: CompletionDelivery | undefined,
|
|
972
|
+
): CompletionDelivery {
|
|
973
|
+
return value ?? "next-turn";
|
|
974
|
+
}
|
|
975
|
+
|
|
681
976
|
function normalizeRuntimeThinkingLevel(value: string): ParentRuntimeSnapshot["thinkingLevel"] {
|
|
682
977
|
return isThinkingLevel(value) ? value : "off";
|
|
683
978
|
}
|