@narumitw/pi-subagents 0.13.1 → 0.14.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.
@@ -0,0 +1,838 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as path from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { StringEnum } from "@earendil-works/pi-ai";
5
+ import { Type } from "typebox";
6
+ import { discoverAgents, type AgentScope } from "./agents.js";
7
+ import { buildContextSnapshot, type ContextMode, redactPrivateText } from "./context.js";
8
+ import { assertSubagentDepthAllowed } from "./execution.js";
9
+ import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
10
+ import {
11
+ ORCHESTRATION_MARKER_PREFIX,
12
+ RootOrchestrationState,
13
+ type OrchestrationRecoveryTicket,
14
+ } from "./orchestration.js";
15
+ import { AgentPersistence } from "./persistence.js";
16
+ import {
17
+ AgentRegistry,
18
+ type AgentTurnCompletion,
19
+ type ManagedAgent,
20
+ } from "./registry.js";
21
+ import { readSubagentSettings } from "./settings.js";
22
+ import { SubprocessTransport } from "./subprocess-transport.js";
23
+ import {
24
+ type ChildSessionFactory,
25
+ InProcessTransport,
26
+ type ParentRuntimeSnapshot,
27
+ } from "./in-process-transport.js";
28
+ import { WorkspaceManager } from "./workspace.js";
29
+
30
+ const ContextModeSchema = Type.Union([
31
+ StringEnum(["none", "all", "summary"] as const),
32
+ Type.Number({ minimum: 1, description: "Include the most recent N user turns." }),
33
+ ]);
34
+ const ScopeSchema = StringEnum(["user", "project", "both"] as const);
35
+ const MAX_TOOL_MESSAGE_BYTES = 2 * 1024;
36
+ const MAX_COMPLETION_ERROR_BYTES = 512;
37
+
38
+ export interface StatefulSubagentDependencies {
39
+ createInProcessSession?: ChildSessionFactory;
40
+ }
41
+
42
+ export function registerStatefulSubagents(
43
+ pi: ExtensionAPI,
44
+ dependencies: StatefulSubagentDependencies = {},
45
+ ): void {
46
+ const settings = readSubagentSettings()?.stateful ?? {};
47
+ if (settings.enabled === false) return;
48
+
49
+ let registry: AgentRegistry | undefined;
50
+ let persistence: AgentPersistence | undefined;
51
+ let sweepTimer: NodeJS.Timeout | undefined;
52
+ let runtimeGeneration = 0;
53
+ const workspaceManager = new WorkspaceManager();
54
+ const isolatedAgents = new Map<string, string>();
55
+ const seenMessageIds = new Set<string>();
56
+ const parentRuntime: ParentRuntimeSnapshot = { model: undefined, thinkingLevel: "off" };
57
+ const orchestration = new RootOrchestrationState();
58
+ const cancelledRecoveryNonces = new Set<string>();
59
+ const completionWaiters = new Map<string, number>();
60
+
61
+ const requireRegistry = () => {
62
+ if (!registry) throw new Error("Stateful subagents are not initialized for this session");
63
+ return registry;
64
+ };
65
+
66
+ pi.on("session_start", async (_event, ctx) => {
67
+ const generation = ++runtimeGeneration;
68
+ rememberCancelledRecovery(orchestration.supersedePending(), cancelledRecoveryNonces);
69
+ orchestration.reset();
70
+ parentRuntime.model = ctx.model;
71
+ parentRuntime.thinkingLevel = normalizeRuntimeThinkingLevel(pi.getThinkingLevel());
72
+ const owner = ctx.sessionManager.getSessionId?.() ?? ctx.sessionManager.getSessionFile?.() ?? `ephemeral:${ctx.cwd}`;
73
+ const sessionPersistence = new AgentPersistence(owner, {
74
+ retentionDays: settings.retentionDays,
75
+ maxStoredAgents: settings.maxStoredAgents,
76
+ });
77
+ persistence = sessionPersistence;
78
+ const transport =
79
+ resolveStatefulTransportKind(settings.transport) === "in-process"
80
+ ? new InProcessTransport({
81
+ modelRegistry: ctx.modelRegistry,
82
+ getParentRuntime: () => ({ ...parentRuntime }),
83
+ createSession: dependencies.createInProcessSession,
84
+ })
85
+ : new SubprocessTransport(ctx);
86
+ registry = new AgentRegistry(transport, {
87
+ maxAgents: settings.maxAgents,
88
+ maxActiveTurns: settings.maxActiveTurns,
89
+ maxDepth: settings.maxDepth,
90
+ maxChildrenPerAgent: settings.maxChildrenPerAgent,
91
+ maxMailboxMessages: settings.maxMailboxMessages,
92
+ maxMailboxMessageBytes: settings.maxMailboxMessageBytes,
93
+ idleTtlMs: settings.idleTtlMs,
94
+ onChange: async (agents) => {
95
+ await sessionPersistence.save(agents);
96
+ if (generation !== runtimeGeneration) return;
97
+ for (const agent of agents) {
98
+ for (const message of agent.mailbox) {
99
+ if (seenMessageIds.has(message.id)) continue;
100
+ seenMessageIds.add(message.id);
101
+ pi.appendEntry("pi-subagent-message", {
102
+ senderId: message.senderId,
103
+ recipientId: message.recipientId,
104
+ content: redactPrivateText(message.content).slice(0, 160),
105
+ });
106
+ }
107
+ }
108
+ },
109
+ onTurnComplete: (completion) => {
110
+ if (generation !== runtimeGeneration) return;
111
+ orchestration.complete(completion.agent.id);
112
+ if (!completionWaiters.has(completion.agent.id)) {
113
+ sendDetachedCompletion(pi, completion);
114
+ }
115
+ },
116
+ });
117
+ const restored = sessionPersistence
118
+ .load()
119
+ .filter(
120
+ (agent) =>
121
+ (agent.agentScope !== "project" && agent.agentScope !== "both") ||
122
+ ctx.isProjectTrusted(),
123
+ );
124
+ for (const agent of restored) {
125
+ for (const message of agent.mailbox) seenMessageIds.add(message.id);
126
+ }
127
+ registry.restore(restored);
128
+ const sweepEveryMs = Math.max(1_000, Math.min(settings.idleTtlMs ?? 60 * 60 * 1000, 60_000));
129
+ sweepTimer = setInterval(() => {
130
+ void registry?.sweepExpired().catch((error: unknown) => {
131
+ if (!ctx.hasUI) return;
132
+ const reason = error instanceof Error ? error.message : String(error);
133
+ ctx.ui.notify(`Subagent expiry cleanup failed: ${reason}`, "warning");
134
+ });
135
+ }, sweepEveryMs);
136
+ sweepTimer.unref();
137
+ });
138
+
139
+ pi.on("input", (event) => {
140
+ if (event.source === "extension") {
141
+ const nonce = extractOrchestrationNonce(event.text);
142
+ if (nonce && cancelledRecoveryNonces.delete(nonce)) return { action: "handled" as const };
143
+ return;
144
+ }
145
+ rememberCancelledRecovery(orchestration.supersedePending(), cancelledRecoveryNonces);
146
+ });
147
+
148
+ pi.on("before_agent_start", () => {
149
+ orchestration.beginTurn();
150
+ });
151
+
152
+ pi.on("before_provider_request", () => {
153
+ orchestration.observeAvailable();
154
+ });
155
+
156
+ pi.on("agent_end", (_event, ctx) => {
157
+ const ticket = orchestration.endTurn();
158
+ if (ticket && !hasPendingRootMessages(ctx)) queueOrchestrationFollowUp(pi, ctx, ticket);
159
+ });
160
+
161
+ const settledEvents = pi as unknown as {
162
+ on(
163
+ event: "agent_settled",
164
+ handler: (event: unknown, ctx: ExtensionContext) => void,
165
+ ): void;
166
+ };
167
+ settledEvents.on("agent_settled", (_event, ctx) => {
168
+ dispatchOrchestrationRecovery(pi, ctx, orchestration);
169
+ });
170
+
171
+ pi.on("model_select", (event) => {
172
+ parentRuntime.model = event.model;
173
+ });
174
+
175
+ pi.on("thinking_level_select", (event) => {
176
+ parentRuntime.thinkingLevel = normalizeRuntimeThinkingLevel(event.level);
177
+ });
178
+
179
+ pi.on("session_shutdown", async (_event, ctx) => {
180
+ runtimeGeneration++;
181
+ rememberCancelledRecovery(orchestration.supersedePending(), cancelledRecoveryNonces);
182
+ orchestration.reset();
183
+ if (sweepTimer) clearInterval(sweepTimer);
184
+ sweepTimer = undefined;
185
+ for (const agentId of isolatedAgents.keys()) {
186
+ await registry?.closeTree(agentId).catch(() => undefined);
187
+ }
188
+ isolatedAgents.clear();
189
+ seenMessageIds.clear();
190
+ completionWaiters.clear();
191
+ let cleanupError: unknown;
192
+ try {
193
+ await workspaceManager.cleanupAll();
194
+ } catch (error) {
195
+ cleanupError = error;
196
+ }
197
+ await registry?.shutdown();
198
+ registry = undefined;
199
+ persistence = undefined;
200
+ if (cleanupError && ctx.hasUI) {
201
+ const reason = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
202
+ ctx.ui.notify(
203
+ `Some isolated subagent workspaces could not be removed: ${reason}`,
204
+ "warning",
205
+ );
206
+ }
207
+ });
208
+
209
+ pi.registerTool({
210
+ name: "subagent_spawn",
211
+ label: "Spawn Subagent",
212
+ description: "Start an addressable background subagent, return immediately with an agentId, and receive its completion asynchronously.",
213
+ promptSnippet: "Start a reusable detached subagent; completion is delivered asynchronously",
214
+ promptGuidelines: [
215
+ "Do not delegate simple or critical-path work that the main agent can perform directly.",
216
+ "A single detached subagent is appropriate only for a concrete isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation.",
217
+ "Use one blocking subagent parallel call for multiple independent one-shot tasks; do not use repeated detached spawns when no reuse or overlap is needed.",
218
+ "After spawning, continue useful non-overlapping local work when available; otherwise call subagent_wait rather than yielding while delegated work remains unresolved.",
219
+ "Consume available completion messages and synthesize their results before finishing; interrupt or close agents that are no longer needed.",
220
+ "Detached completion is delivered automatically. Do not poll, wait forever, or spawn additional agents without a distinct need.",
221
+ ],
222
+ parameters: Type.Object({
223
+ agent: Type.String({ minLength: 1 }),
224
+ task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
225
+ cwd: Type.Optional(Type.String()),
226
+ agentScope: Type.Optional(ScopeSchema),
227
+ confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
228
+ context: Type.Optional(ContextModeSchema),
229
+ contextEntryIds: Type.Optional(
230
+ Type.Array(Type.String(), { description: "Optional selected session entry IDs." }),
231
+ ),
232
+ parentId: Type.Optional(Type.String({ description: "Optional parent agent ID." })),
233
+ allowConcurrentWrites: Type.Optional(
234
+ Type.Boolean({ description: "Override the shared-workspace write conflict guard." }),
235
+ ),
236
+ workspaceMode: Type.Optional(
237
+ StringEnum(["shared", "worktree"] as const, {
238
+ description: "Use the shared workspace or an opt-in disposable Git worktree.",
239
+ }),
240
+ ),
241
+ }),
242
+ async execute(_id, params, _signal, _update, ctx) {
243
+ const scope = (params.agentScope ?? "user") as AgentScope;
244
+ assertSubagentDepthAllowed();
245
+ const cwd = params.cwd ?? ctx.cwd;
246
+ await confirmProjectAgent(
247
+ params.agent,
248
+ scope,
249
+ params.confirmProjectAgents ?? true,
250
+ ctx,
251
+ cwd,
252
+ );
253
+ const resolvedAgent = discoverAgents(cwd, scope, readSubagentSettings()).agents.find(
254
+ (agent) => agent.name === params.agent,
255
+ );
256
+ if (params.workspaceMode === "worktree" && resolvedAgent?.source === "project") {
257
+ throw new Error("Project-local subagent definitions cannot run in a detached worktree");
258
+ }
259
+ const mode = resolveSpawnContextMode(params.context, params.contextEntryIds);
260
+ const snapshot = buildContextSnapshot(
261
+ ctx.sessionManager.getBranch(),
262
+ mode,
263
+ DEFAULT_MAX_CONTEXT_BYTES,
264
+ params.contextEntryIds,
265
+ );
266
+ const requestedCwd = cwd;
267
+ if ((params.workspaceMode ?? "shared") === "shared" && !params.allowConcurrentWrites) {
268
+ assertNoSharedWriteConflict(
269
+ requireRegistry(),
270
+ params.agent,
271
+ requestedCwd,
272
+ scope,
273
+ );
274
+ }
275
+ const workspaceOwner = `pending-${randomUUID()}`;
276
+ const workspace =
277
+ params.workspaceMode === "worktree"
278
+ ? await workspaceManager.create(workspaceOwner, requestedCwd)
279
+ : undefined;
280
+ let agent: ManagedAgent;
281
+ try {
282
+ agent = await requireRegistry().spawn({
283
+ agent: params.agent,
284
+ task: params.task,
285
+ cwd: workspace?.path ?? requestedCwd,
286
+ agentScope: scope,
287
+ parentId: params.parentId,
288
+ context: snapshot.text || undefined,
289
+ contextSourceIds: snapshot.sourceIds,
290
+ contextTruncated: snapshot.truncated,
291
+ });
292
+ } catch (error) {
293
+ if (workspace) await workspaceManager.cleanup(workspaceOwner);
294
+ throw error;
295
+ }
296
+ if (workspace) isolatedAgents.set(agent.id, workspaceOwner);
297
+ trackSpawnedAgent(orchestration, agent);
298
+ return result(
299
+ agent,
300
+ `Spawned ${agent.agent} as ${agent.id}. Continue coordinating this agent; do useful non-overlapping work or call subagent_wait, then synthesize its result before finishing.`,
301
+ );
302
+ },
303
+ });
304
+
305
+ pi.registerTool({
306
+ name: "subagent_send",
307
+ label: "Send Subagent Follow-up",
308
+ description: "Send a follow-up task to an idle, completed, interrupted, or failed subagent.",
309
+ parameters: Type.Object({
310
+ agentId: Type.String(),
311
+ task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
312
+ allowConcurrentWrites: Type.Optional(
313
+ Type.Boolean({ description: "Override the shared-workspace write conflict guard." }),
314
+ ),
315
+ }),
316
+ async execute(_id, params, _signal, _update, ctx) {
317
+ const existing = requireRegistry().get(params.agentId);
318
+ if (!existing) throw new Error(`Unknown subagent: ${params.agentId}`);
319
+ await confirmProjectAgent(
320
+ existing.agent,
321
+ existing.agentScope ?? "user",
322
+ false,
323
+ ctx,
324
+ existing.cwd,
325
+ );
326
+ assertFollowUpWriteAllowed(
327
+ requireRegistry(),
328
+ existing,
329
+ params.allowConcurrentWrites ?? false,
330
+ isolatedAgents.has(existing.id),
331
+ );
332
+ const agent = await requireRegistry().followUp(params.agentId, params.task);
333
+ trackSpawnedAgent(orchestration, agent);
334
+ return result(agent, `Started follow-up for ${agent.id}.`);
335
+ },
336
+ });
337
+
338
+ pi.registerTool({
339
+ name: "subagent_message",
340
+ label: "Message Subagent",
341
+ description: "Queue a bounded mailbox message without starting a turn.",
342
+ parameters: Type.Object({
343
+ agentId: Type.String(),
344
+ message: Type.String({ minLength: 1, maxLength: 16 * 1024 }),
345
+ senderId: Type.Optional(Type.String()),
346
+ deduplicationKey: Type.Optional(Type.String({ maxLength: 256 })),
347
+ }),
348
+ async execute(_id, params) {
349
+ const message = await requireRegistry().sendMessage(
350
+ params.agentId,
351
+ params.message,
352
+ params.senderId,
353
+ params.deduplicationKey,
354
+ );
355
+ return {
356
+ content: [{ type: "text", text: `Queued ${message.id} for ${message.recipientId}.` }],
357
+ details: { message },
358
+ };
359
+ },
360
+ });
361
+
362
+ pi.registerTool({
363
+ name: "subagent_messages",
364
+ label: "Read Subagent Messages",
365
+ description: "Read unread mailbox messages and optionally acknowledge them.",
366
+ parameters: Type.Object({
367
+ agentId: Type.String(),
368
+ acknowledge: Type.Optional(Type.Boolean({ default: true })),
369
+ limit: Type.Optional(Type.Number({ minimum: 1, maximum: 20, default: 20 })),
370
+ }),
371
+ async execute(_id, params) {
372
+ const messages = await requireRegistry().readMessages(
373
+ params.agentId,
374
+ params.acknowledge,
375
+ params.limit,
376
+ );
377
+ const summaries = messages.map((message) => ({
378
+ ...message,
379
+ content: truncateUtf8(message.content, MAX_TOOL_MESSAGE_BYTES).text,
380
+ }));
381
+ const text = summaries.length
382
+ ? summaries
383
+ .map(
384
+ (message) => `${message.id} from ${message.senderId}: ${message.content}`,
385
+ )
386
+ .join("\n")
387
+ : "No unread messages.";
388
+ return {
389
+ content: [{ type: "text", text: truncateUtf8(text, DEFAULT_MAX_CONTEXT_BYTES).text }],
390
+ details: { messages: summaries },
391
+ };
392
+ },
393
+ });
394
+
395
+ pi.registerTool({
396
+ name: "subagent_wait",
397
+ label: "Wait for Subagent",
398
+ description: "Wait for a stateful subagent turn without terminating it when the wait times out.",
399
+ parameters: Type.Object({
400
+ agentId: Type.String(),
401
+ timeoutMs: Type.Optional(Type.Number({ minimum: 1, maximum: 3_600_000, default: 30_000 })),
402
+ }),
403
+ async execute(_id, params, signal) {
404
+ const existing = requireRegistry().get(params.agentId);
405
+ const registered = existing?.state === "starting" || existing?.state === "running";
406
+ if (registered) {
407
+ completionWaiters.set(params.agentId, (completionWaiters.get(params.agentId) ?? 0) + 1);
408
+ }
409
+ try {
410
+ const waited = await requireRegistry().wait(params.agentId, params.timeoutMs, signal);
411
+ if (!waited.timedOut) orchestration.observe(waited.agent.id);
412
+ return result(
413
+ waited.agent,
414
+ waited.timedOut
415
+ ? `Wait timed out; ${waited.agent.id} is ${waited.agent.state}.`
416
+ : formatFinal(waited.agent),
417
+ );
418
+ } finally {
419
+ if (registered) decrementWaiter(completionWaiters, params.agentId);
420
+ }
421
+ },
422
+ });
423
+
424
+ pi.registerTool({
425
+ name: "subagent_list",
426
+ label: "List Subagents",
427
+ description: "List stateful subagents and lifecycle states.",
428
+ parameters: Type.Object({ includeClosed: Type.Optional(Type.Boolean({ default: false })) }),
429
+ async execute(_id, params) {
430
+ const agents = requireRegistry().list(params.includeClosed);
431
+ return {
432
+ content: [
433
+ {
434
+ type: "text",
435
+ text: agents.length
436
+ ? agents.map(formatLine).join("\n")
437
+ : "No stateful subagents.",
438
+ },
439
+ ],
440
+ details: { agents: agents.map(summarizeAgent) },
441
+ };
442
+ },
443
+ });
444
+
445
+ pi.registerTool({
446
+ name: "subagent_interrupt",
447
+ label: "Interrupt Subagent",
448
+ description: "Interrupt the current turn while retaining the subagent for follow-up work.",
449
+ parameters: Type.Object({
450
+ agentId: Type.String(),
451
+ subtree: Type.Optional(Type.Boolean({ default: false })),
452
+ }),
453
+ async execute(_id, params) {
454
+ if (params.subtree) {
455
+ const agents = await requireRegistry().interruptTree(params.agentId);
456
+ return {
457
+ content: [{ type: "text", text: `Interrupted ${agents.length} active agent(s).` }],
458
+ details: {
459
+ agent: summarizeAgent(requireRegistry().get(params.agentId)!),
460
+ agents: agents.map(summarizeAgent),
461
+ },
462
+ };
463
+ }
464
+ const agent = await requireRegistry().interrupt(params.agentId);
465
+ return result(agent, `Interrupted ${agent.id}; it remains reusable.`);
466
+ },
467
+ });
468
+
469
+ pi.registerTool({
470
+ name: "subagent_close",
471
+ label: "Close Subagent",
472
+ description: "Close a stateful subagent and remove it from retained persistence.",
473
+ parameters: Type.Object({
474
+ agentId: Type.String(),
475
+ subtree: Type.Optional(Type.Boolean({ default: false })),
476
+ }),
477
+ async execute(_id, params) {
478
+ const existing = requireRegistry().get(params.agentId);
479
+ if (existing?.state === "closed" && !params.subtree) {
480
+ orchestration.resolve(existing.id);
481
+ const pendingOwner = isolatedAgents.get(existing.id);
482
+ if (pendingOwner) await workspaceManager.cleanup(pendingOwner);
483
+ isolatedAgents.delete(existing.id);
484
+ return result(existing, `Closed ${existing.id}.`);
485
+ }
486
+ if (params.subtree) {
487
+ let agents: ManagedAgent[];
488
+ try {
489
+ agents = await requireRegistry().closeTree(params.agentId);
490
+ } finally {
491
+ await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
492
+ }
493
+ for (const closed of agents) orchestration.resolve(closed.id);
494
+ return {
495
+ content: [{ type: "text", text: `Closed ${agents.length} agent(s).` }],
496
+ details: {
497
+ agent: summarizeAgent(requireRegistry().get(params.agentId)!),
498
+ agents: agents.map(summarizeAgent),
499
+ },
500
+ };
501
+ }
502
+ let agent: ManagedAgent;
503
+ try {
504
+ agent = await requireRegistry().close(params.agentId);
505
+ } finally {
506
+ await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
507
+ }
508
+ orchestration.resolve(agent.id);
509
+ return result(agent, `Closed ${agent.id}.`);
510
+ },
511
+ });
512
+
513
+ pi.registerCommand("subagents:agents", {
514
+ description: "Inspect or clear stateful subagents",
515
+ getArgumentCompletions(prefix: string) {
516
+ return ["list", "clear"]
517
+ .filter((value) => value.startsWith(prefix))
518
+ .map((value) => ({ value, label: value }));
519
+ },
520
+ async handler(args, ctx) {
521
+ if (args.trim() === "clear") {
522
+ try {
523
+ await requireRegistry().closeAll();
524
+ } finally {
525
+ await workspaceManager.cleanupAll();
526
+ isolatedAgents.clear();
527
+ }
528
+ seenMessageIds.clear();
529
+ orchestration.reset();
530
+ await persistence?.delete();
531
+ ctx.ui.notify("Cleared stateful subagents.", "info");
532
+ return;
533
+ }
534
+ const agents = requireRegistry().list(true);
535
+ ctx.ui.notify(
536
+ agents.length ? agents.map(formatLine).join("\n") : "No stateful subagents.",
537
+ "info",
538
+ );
539
+ },
540
+ });
541
+ }
542
+
543
+ export function assertNoSharedWriteConflict(
544
+ registry: AgentRegistry,
545
+ agentName: string,
546
+ cwd: string,
547
+ scope: AgentScope,
548
+ ): void {
549
+ const agents = discoverAgents(cwd, scope, readSubagentSettings()).agents;
550
+ const requested = agents.find((agent) => agent.name === agentName);
551
+ if (!isWriteCapable(requested?.tools)) return;
552
+ for (const active of registry.list()) {
553
+ if (
554
+ !isSameCwd(active.cwd, cwd) ||
555
+ (active.state !== "running" && active.state !== "starting")
556
+ ) {
557
+ continue;
558
+ }
559
+ const activeConfig = agents.find((agent) => agent.name === active.agent);
560
+ if (isWriteCapable(activeConfig?.tools)) {
561
+ throw new Error(
562
+ `Write-capable subagent ${active.id} is already active in shared workspace ${cwd}. ` +
563
+ "For independent one-shot work, use subagent parallel mode. Otherwise wait or close the active agent; set allowConcurrentWrites only when overlapping writes are knowingly safe, or use workspaceMode worktree when repository isolation is needed.",
564
+ );
565
+ }
566
+ }
567
+ }
568
+
569
+ export function assertFollowUpWriteAllowed(
570
+ registry: AgentRegistry,
571
+ agent: ManagedAgent,
572
+ allowConcurrentWrites: boolean,
573
+ isolatedWorkspace: boolean,
574
+ ): void {
575
+ if (allowConcurrentWrites || isolatedWorkspace) return;
576
+ assertNoSharedWriteConflict(
577
+ registry,
578
+ agent.agent,
579
+ agent.cwd,
580
+ agent.agentScope ?? "user",
581
+ );
582
+ }
583
+
584
+ export function isWriteCapable(tools: string[] | undefined): boolean {
585
+ if (!tools) return true;
586
+ return tools.some((tool) => ["bash", "write", "edit"].includes(tool));
587
+ }
588
+
589
+ function decrementWaiter(waiters: Map<string, number>, agentId: string): void {
590
+ const count = waiters.get(agentId);
591
+ if (count === undefined || count <= 1) waiters.delete(agentId);
592
+ else waiters.set(agentId, count - 1);
593
+ }
594
+
595
+ async function confirmProjectAgent(
596
+ name: string,
597
+ scope: AgentScope,
598
+ confirm: boolean,
599
+ ctx: ExtensionContext,
600
+ cwd: string,
601
+ ): Promise<void> {
602
+ if (scope !== "project" && scope !== "both") return;
603
+ const discovery = discoverAgents(cwd, scope, readSubagentSettings());
604
+ const agent = discovery.agents.find((candidate) => candidate.name === name);
605
+ if (agent?.source !== "project") return;
606
+ if (!isSameCwd(cwd, ctx.cwd)) {
607
+ throw new Error("Project-local subagent definitions cannot run with an overridden cwd");
608
+ }
609
+ if (!ctx.isProjectTrusted()) {
610
+ throw new Error("Project-local subagent definitions require a trusted project");
611
+ }
612
+ if (confirm && ctx.hasUI) {
613
+ const approved = await ctx.ui.confirm("Run project-local agent?", `Agent: ${name}\nSource: ${agent.filePath}`);
614
+ if (!approved) throw new Error("Project-local subagent was not approved");
615
+ }
616
+ }
617
+
618
+ function isSameCwd(left: string, right: string): boolean {
619
+ return path.resolve(left) === path.resolve(right);
620
+ }
621
+
622
+ function normalizeContextMode(
623
+ value: "none" | "all" | "summary" | number | undefined,
624
+ ): ContextMode {
625
+ if (value === undefined) return "none";
626
+ if (value === "none" || value === "all" || value === "summary") return value;
627
+ return Math.max(1, Math.floor(value));
628
+ }
629
+
630
+ export function resolveSpawnContextMode(
631
+ value: "none" | "all" | "summary" | number | undefined,
632
+ contextEntryIds: readonly string[] | undefined,
633
+ ): ContextMode {
634
+ if (value === undefined && contextEntryIds !== undefined) return "all";
635
+ return normalizeContextMode(value);
636
+ }
637
+
638
+ function formatLine(agent: ManagedAgent): string {
639
+ const elapsedSeconds = Math.max(0, Math.floor((Date.now() - agent.updatedAt) / 1000));
640
+ const actions =
641
+ agent.state === "running" || agent.state === "starting"
642
+ ? "wait, interrupt, close"
643
+ : agent.state === "closed"
644
+ ? "inspect"
645
+ : "send, close";
646
+ const task = agent.currentTask ? ` — ${agent.currentTask.slice(0, 80)}` : "";
647
+ const unread = agent.mailbox.filter((message) => !message.readAt).length;
648
+ const indent = " ".repeat(agent.depth);
649
+ return `${indent}${agent.id} ${agent.agent} ${agent.state} ${elapsedSeconds}s unread:${unread} [${actions}]${task}`;
650
+ }
651
+
652
+ function formatFinal(agent: ManagedAgent): string {
653
+ const last = agent.history.at(-1);
654
+ return last?.output || agent.error || `${agent.id} is ${agent.state}.`;
655
+ }
656
+
657
+ function summarizeAgent(agent: ManagedAgent) {
658
+ return {
659
+ id: agent.id,
660
+ agent: agent.agent,
661
+ parentId: agent.parentId,
662
+ rootId: agent.rootId,
663
+ depth: agent.depth,
664
+ children: [...agent.children],
665
+ state: agent.state,
666
+ createdAt: agent.createdAt,
667
+ updatedAt: agent.updatedAt,
668
+ cwd: agent.cwd,
669
+ currentTask: agent.currentTask
670
+ ? truncateUtf8(agent.currentTask, MAX_TOOL_MESSAGE_BYTES).text
671
+ : undefined,
672
+ historyCount: agent.history.length,
673
+ unreadMessages: agent.mailbox.filter((message) => !message.readAt).length,
674
+ error: agent.error ? truncateUtf8(agent.error, MAX_TOOL_MESSAGE_BYTES).text : undefined,
675
+ policy: agent.policy,
676
+ };
677
+ }
678
+
679
+ function trackSpawnedAgent(
680
+ orchestration: RootOrchestrationState,
681
+ agent: ManagedAgent,
682
+ ): void {
683
+ orchestration.spawn(agent.id);
684
+ if (agent.state === "closed") orchestration.resolve(agent.id);
685
+ else if (agent.state !== "starting" && agent.state !== "running") {
686
+ orchestration.complete(agent.id);
687
+ }
688
+ }
689
+
690
+ function rememberCancelledRecovery(
691
+ ticket: OrchestrationRecoveryTicket | undefined,
692
+ cancelledNonces: Set<string>,
693
+ ): void {
694
+ if (!ticket) return;
695
+ cancelledNonces.add(ticket.nonce);
696
+ if (cancelledNonces.size <= 64) return;
697
+ const oldest = cancelledNonces.values().next().value;
698
+ if (oldest) cancelledNonces.delete(oldest);
699
+ }
700
+
701
+ function extractOrchestrationNonce(text: string): string | undefined {
702
+ const marker = `<!-- ${ORCHESTRATION_MARKER_PREFIX}`;
703
+ const start = text.lastIndexOf(marker);
704
+ if (start < 0) return undefined;
705
+ const valueStart = start + marker.length;
706
+ const end = text.indexOf(" -->", valueStart);
707
+ return end < 0 ? undefined : text.slice(valueStart, end);
708
+ }
709
+
710
+ function queueOrchestrationFollowUp(
711
+ pi: ExtensionAPI,
712
+ ctx: ExtensionContext,
713
+ ticket: OrchestrationRecoveryTicket,
714
+ ): void {
715
+ try {
716
+ pi.sendUserMessage(ticket.prompt, { deliverAs: "followUp" });
717
+ } catch (error) {
718
+ if (ctx.hasUI) {
719
+ const reason = error instanceof Error ? error.message : String(error);
720
+ ctx.ui.notify(`Subagent coordination follow-up failed: ${reason}`, "warning");
721
+ }
722
+ }
723
+ }
724
+
725
+ function dispatchOrchestrationRecovery(
726
+ pi: ExtensionAPI,
727
+ ctx: ExtensionContext,
728
+ orchestration: RootOrchestrationState,
729
+ ): boolean {
730
+ const ticket = orchestration.pendingTicket();
731
+ if (!ticket || !orchestration.isCurrent(ticket)) return false;
732
+ if (hasPendingRootMessages(ctx)) return false;
733
+ try {
734
+ pi.sendUserMessage(ticket.prompt);
735
+ orchestration.markDelivered(ticket);
736
+ return true;
737
+ } catch (error) {
738
+ if (ctx.hasUI) {
739
+ const reason = error instanceof Error ? error.message : String(error);
740
+ ctx.ui.notify(`Subagent coordination prompt failed: ${reason}`, "warning");
741
+ }
742
+ return false;
743
+ }
744
+ }
745
+
746
+ function hasPendingRootMessages(ctx: ExtensionContext): boolean {
747
+ try {
748
+ return ctx.hasPendingMessages();
749
+ } catch {
750
+ return true;
751
+ }
752
+ }
753
+
754
+ function sendDetachedCompletion(
755
+ pi: ExtensionAPI,
756
+ completion: AgentTurnCompletion,
757
+ ): void {
758
+ const content = buildDetachedCompletionMessage(completion);
759
+ pi.sendMessage(
760
+ {
761
+ customType: "pi-subagent-completion",
762
+ content,
763
+ display: true,
764
+ details: {
765
+ agentId: completion.agent.id,
766
+ agent: completion.agent.agent,
767
+ state: completion.agent.state,
768
+ },
769
+ },
770
+ { deliverAs: "steer", triggerTurn: false },
771
+ );
772
+ }
773
+
774
+ export function buildDetachedCompletionMessage(completion: AgentTurnCompletion): string {
775
+ const task = sanitizeCompletionLine(completion.task, 256) || "(unknown task)";
776
+ const agentName = sanitizeCompletionLine(completion.agent.agent, 128) || "(unknown agent)";
777
+ const output = redactPrivateText(completion.output);
778
+ const error = completion.error
779
+ ? truncateUtf8(redactPrivateText(completion.error), MAX_COMPLETION_ERROR_BYTES).text
780
+ : "";
781
+ return truncateUtf8(
782
+ [
783
+ "Message Type: SUBAGENT_COMPLETION",
784
+ `Agent ID: ${completion.agent.id}`,
785
+ `Agent: ${agentName}`,
786
+ `Task: ${task}`,
787
+ `State: ${completion.agent.state}`,
788
+ ...(error.trim() ? ["Error:", error] : []),
789
+ "Payload:",
790
+ output.trim() ? output : "(no output)",
791
+ ].join("\n"),
792
+ MAX_TOOL_MESSAGE_BYTES,
793
+ ).text;
794
+ }
795
+
796
+ function sanitizeCompletionLine(value: string, maxBytes: number): string {
797
+ return truncateUtf8(redactPrivateText(value), maxBytes).text
798
+ .replace(/[\u0000-\u001f\u007f]+/g, " ")
799
+ .replace(/\s+/g, " ")
800
+ .trim();
801
+ }
802
+
803
+ async function cleanupClosedWorkspaces(
804
+ registry: AgentRegistry,
805
+ isolatedAgents: Map<string, string>,
806
+ workspaceManager: WorkspaceManager,
807
+ ): Promise<void> {
808
+ for (const [agentId, owner] of [...isolatedAgents]) {
809
+ if (registry.get(agentId)?.state !== "closed") continue;
810
+ await workspaceManager.cleanup(owner);
811
+ isolatedAgents.delete(agentId);
812
+ }
813
+ }
814
+
815
+ function result(agent: ManagedAgent, text: string) {
816
+ return {
817
+ content: [{ type: "text" as const, text }],
818
+ details: { agent: summarizeAgent(agent) },
819
+ };
820
+ }
821
+
822
+ export function resolveStatefulTransportKind(
823
+ value: "subprocess" | "in-process" | undefined,
824
+ ): "subprocess" | "in-process" {
825
+ return value ?? "subprocess";
826
+ }
827
+
828
+ function normalizeRuntimeThinkingLevel(value: string): ParentRuntimeSnapshot["thinkingLevel"] {
829
+ if (value === "off" || value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh") {
830
+ return value;
831
+ }
832
+ return value === "max" ? "xhigh" : "off";
833
+ }
834
+
835
+ export {
836
+ buildStatefulTurnPrompt,
837
+ resolveStatefulTurnTimeout,
838
+ } from "./stateful-prompt.js";