pi-subagents 0.31.1 → 0.33.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +67 -4
  2. package/README.md +219 -40
  3. package/install.mjs +2 -1
  4. package/package.json +3 -2
  5. package/skills/pi-subagents/SKILL.md +71 -10
  6. package/src/agents/agent-management.ts +179 -2
  7. package/src/agents/agent-memory.ts +254 -0
  8. package/src/agents/agent-serializer.ts +11 -0
  9. package/src/agents/agents.ts +193 -19
  10. package/src/agents/chain-serializer.ts +27 -2
  11. package/src/extension/config.ts +27 -4
  12. package/src/extension/doctor.ts +1 -7
  13. package/src/extension/fanout-child.ts +3 -2
  14. package/src/extension/index.ts +70 -41
  15. package/src/extension/rpc.ts +369 -0
  16. package/src/extension/schemas.ts +54 -10
  17. package/src/extension/tool-description.ts +200 -0
  18. package/src/intercom/intercom-bridge.ts +21 -253
  19. package/src/intercom/native-supervisor-channel.ts +510 -0
  20. package/src/runs/background/async-execution.ts +187 -38
  21. package/src/runs/background/async-job-tracker.ts +88 -2
  22. package/src/runs/background/async-status.ts +67 -10
  23. package/src/runs/background/chain-root-attachment.ts +34 -4
  24. package/src/runs/background/completion-batcher.ts +166 -0
  25. package/src/runs/background/control-channel.ts +156 -1
  26. package/src/runs/background/fleet-view.ts +515 -0
  27. package/src/runs/background/notify.ts +161 -44
  28. package/src/runs/background/result-watcher.ts +1 -2
  29. package/src/runs/background/run-id-resolver.ts +3 -2
  30. package/src/runs/background/run-status.ts +167 -6
  31. package/src/runs/background/scheduled-runs.ts +514 -0
  32. package/src/runs/background/stale-run-reconciler.ts +28 -1
  33. package/src/runs/background/subagent-runner.ts +840 -127
  34. package/src/runs/background/wait.ts +353 -0
  35. package/src/runs/foreground/chain-execution.ts +123 -27
  36. package/src/runs/foreground/execution.ts +174 -27
  37. package/src/runs/foreground/subagent-executor.ts +569 -81
  38. package/src/runs/shared/acceptance.ts +45 -22
  39. package/src/runs/shared/dynamic-fanout.ts +2 -2
  40. package/src/runs/shared/model-fallback.ts +171 -20
  41. package/src/runs/shared/model-scope.ts +128 -0
  42. package/src/runs/shared/nested-events.ts +89 -0
  43. package/src/runs/shared/parallel-utils.ts +50 -1
  44. package/src/runs/shared/pi-args.ts +35 -4
  45. package/src/runs/shared/pi-spawn.ts +52 -20
  46. package/src/runs/shared/single-output.ts +2 -0
  47. package/src/runs/shared/subagent-prompt-runtime.ts +110 -4
  48. package/src/runs/shared/tool-budget.ts +74 -0
  49. package/src/runs/shared/turn-budget.ts +52 -0
  50. package/src/runs/shared/worktree.ts +28 -5
  51. package/src/shared/artifacts.ts +16 -1
  52. package/src/shared/atomic-json.ts +15 -2
  53. package/src/shared/child-transcript.ts +212 -0
  54. package/src/shared/fork-context.ts +133 -22
  55. package/src/shared/settings.ts +3 -1
  56. package/src/shared/types.ts +197 -4
  57. package/src/shared/utils.ts +99 -14
  58. package/src/slash/prompt-workflows.ts +330 -0
  59. package/src/slash/slash-commands.ts +133 -2
  60. package/src/tui/render.ts +32 -12
@@ -12,30 +12,36 @@
12
12
  * { "asyncByDefault": true, "forceTopLevelAsync": true, "maxSubagentDepth": 1, "intercomBridge": { "mode": "always", "instructionFile": "./intercom-bridge.md" }, "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
13
13
  */
14
14
 
15
+ import { randomUUID } from "node:crypto";
15
16
  import * as fs from "node:fs";
16
17
  import * as os from "node:os";
17
18
  import * as path from "node:path";
18
19
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
19
- import { type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
20
+ import { keyText, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
20
21
  import { Box, Container, Spacer, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
21
22
  import { discoverAgents } from "../agents/agents.ts";
22
23
  import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "../shared/artifacts.ts";
23
24
  import { resolveCurrentSessionId } from "../shared/session-identity.ts";
24
25
  import { cleanupOldChainDirs } from "../shared/settings.ts";
25
26
  import { clearLegacyResultAnimationTimer, renderWidget, renderSubagentResult } from "../tui/render.ts";
26
- import { SubagentParams } from "./schemas.ts";
27
+ import { SubagentParams, WaitParams } from "./schemas.ts";
27
28
  import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
28
29
  import { createAsyncJobTracker } from "../runs/background/async-job-tracker.ts";
29
30
  import { createResultWatcher } from "../runs/background/result-watcher.ts";
31
+ import { createScheduledRunManager } from "../runs/background/scheduled-runs.ts";
30
32
  import { registerSlashCommands } from "../slash/slash-commands.ts";
31
33
  import { registerPromptTemplateDelegationBridge } from "../slash/prompt-template-bridge.ts";
32
34
  import { registerSlashSubagentBridge } from "../slash/slash-bridge.ts";
35
+ import { createNativeSupervisorChannel } from "../intercom/native-supervisor-channel.ts";
36
+ import { registerSubagentRpcBridge } from "./rpc.ts";
33
37
  import { clearSlashSnapshots, getSlashRenderableSnapshot, resolveSlashMessageDetails, restoreSlashFinalSnapshots, type SlashMessageDetails } from "../slash/slash-live-state.ts";
34
38
  import { inspectSubagentStatus } from "../runs/background/run-status.ts";
39
+ import { waitForSubagents } from "../runs/background/wait.ts";
35
40
  import registerSubagentNotify, { type SubagentNotifyDetails } from "../runs/background/notify.ts";
36
41
  import { SUBAGENT_CHILD_ENV, SUBAGENT_PARENT_SESSION_ENV } from "../runs/shared/pi-args.ts";
37
42
  import { formatDuration, shortenPath } from "../shared/formatters.ts";
38
43
  import { loadConfig } from "./config.ts";
44
+ import { buildSubagentToolDescription } from "./tool-description.ts";
39
45
  import {
40
46
  type Details,
41
47
  type SubagentState,
@@ -259,6 +265,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
259
265
  baseCwd: "",
260
266
  currentSessionId: null,
261
267
  subagentInProgress: false,
268
+ subagentSpawns: { sessionId: null, count: 0 },
262
269
  asyncJobs: new Map(),
263
270
  foregroundRuns: new Map(),
264
271
  foregroundControls: new Map(),
@@ -276,6 +283,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
276
283
  },
277
284
  };
278
285
 
286
+ const supervisorChannel = createNativeSupervisorChannel(pi, state);
279
287
  const { startResultWatcher, primeExistingResults, stopResultWatcher } = createResultWatcher(
280
288
  pi,
281
289
  state,
@@ -287,6 +295,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
287
295
 
288
296
  const runtimeCleanup = () => {
289
297
  stopResultWatcher();
298
+ scheduledRunManager.stop();
299
+ supervisorChannel.dispose();
290
300
  clearPendingForegroundControlNotices(state);
291
301
  if (state.poller) {
292
302
  clearInterval(state.poller);
@@ -295,17 +305,33 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
295
305
  };
296
306
  globalStore[runtimeCleanupStoreKey] = runtimeCleanup;
297
307
 
298
- const { ensurePoller, handleStarted, handleComplete, resetJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR);
308
+ const { ensurePoller, handleStarted, handleComplete, resetJobs, restoreActiveJobs } = createAsyncJobTracker(pi, state, ASYNC_DIR);
309
+ let executorExecute: ((id: string, params: SubagentParamsLike, signal: AbortSignal, onUpdate: ((r: AgentToolResult<Details>) => void) | undefined, ctx: ExtensionContext) => Promise<AgentToolResult<Details>>) | undefined;
310
+ const scheduledRunManager = createScheduledRunManager({
311
+ config,
312
+ launch: (params, ctx, signal) => {
313
+ if (!executorExecute) {
314
+ return Promise.resolve({
315
+ content: [{ type: "text", text: "Scheduled subagent launch is unavailable (executor not ready)." }],
316
+ isError: true,
317
+ details: { mode: "management" as const, results: [] },
318
+ });
319
+ }
320
+ return executorExecute(randomUUID(), params, signal, undefined, ctx);
321
+ },
322
+ });
299
323
  const executor = createSubagentExecutor({
300
324
  pi,
301
325
  state,
302
326
  config,
303
327
  asyncByDefault,
328
+ handleScheduledRunAction: (params, ctx) => scheduledRunManager.handleToolCall(params, ctx),
304
329
  tempArtifactsDir,
305
330
  getSubagentSessionRoot,
306
331
  expandTilde,
307
332
  discoverAgents,
308
333
  });
334
+ executorExecute = executor.execute;
309
335
 
310
336
  pi.registerMessageRenderer<SlashMessageDetails>(SLASH_RESULT_TYPE, (message, options, theme) => {
311
337
  const details = resolveSlashMessageDetails(message.details);
@@ -345,7 +371,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
345
371
  text += `\n ${theme.fg("dim", `⎿ ${line}`)}`;
346
372
  }
347
373
  if (!options.expanded && trimmedPreview.includes("\n")) {
348
- text += `\n ${theme.fg("dim", "Ctrl+O full notification")}`;
374
+ const expandKey = keyText("app.tools.expand");
375
+ text += `\n ${theme.fg("dim", `${expandKey} full notification`)}`;
349
376
  }
350
377
  if (details.sessionLabel && details.sessionValue) {
351
378
  text += `\n ${theme.fg("muted", `${details.sessionLabel}: ${shortenPath(details.sessionValue)}`)}`;
@@ -410,6 +437,12 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
410
437
  },
411
438
  });
412
439
 
440
+ const rpcBridge = registerSubagentRpcBridge({
441
+ events: pi.events,
442
+ getContext: () => state.lastUiContext,
443
+ execute: (id, params, signal, onUpdate, ctx) => executor.execute(id, params, signal, onUpdate, ctx),
444
+ });
445
+
413
446
  function effectiveParallelTaskCount(tasks: Array<{ count?: unknown }> | undefined): number {
414
447
  if (!tasks || tasks.length === 0) return 0;
415
448
  return tasks.reduce((total, task) => {
@@ -421,40 +454,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
421
454
  const tool: ToolDefinition<typeof SubagentParams, Details> = {
422
455
  name: "subagent",
423
456
  label: "Subagent",
424
- description: `Delegate to subagents or manage agent definitions.
425
-
426
- EXECUTION (use exactly ONE mode):
427
- • Before executing, use { action: "list" } to inspect configured agents/chains. Only execute agents listed as executable/non-disabled.
428
- • SINGLE: { agent, task? } - one task; omit task for self-contained agents
429
- • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
430
- • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
431
- • Optional context: { context: "fresh" | "fork" } (explicit value overrides every child; when omitted, each requested agent uses its own defaultContext, otherwise "fresh"; inspect agent defaults via { action: "list" })
432
- • If { action: "list" } shows proactive skill subagent suggestions, consider a small fresh-context fanout for broad tasks where one of those skills would materially help
433
-
434
- CHAIN TEMPLATE VARIABLES (use in task strings):
435
- • {task} - The original task/request from the user
436
- • {previous} - Text response from the previous step (empty for first step)
437
- • {chain_dir} - Shared directory for chain files (e.g., <tmpdir>/pi-subagents-<scope>/chain-runs/abc123/)
438
-
439
- Example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", task:"Plan based on {previous}"}] }
440
-
441
- MANAGEMENT (use action field, omit agent/task/chain/tasks):
442
- • { action: "list" } - discover executable agents/chains
443
- • { action: "get", agent: "name" } - full detail; packaged agents use dotted runtime names like "package.agent"
444
- • { action: "models", agent?: "name" } - show the runtime-loaded builtin subagent model mapping, optionally filtered to one builtin
445
- • { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, ... } }
446
- • { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", ... } } - merge
447
- • { action: "delete", agent: "code-analysis.custom-agent" }
448
- • Use chainName for chain operations; packaged chains also use dotted runtime names
449
-
450
- CONTROL:
451
- • { action: "status", id: "..." } - inspect an async/background run by id or prefix
452
- • { action: "interrupt", id?: "..." } - soft-interrupt the current child turn and leave the run paused
453
- • { action: "resume", id: "...", message: "...", index?: 0 } - interrupt then follow up with a live async child, or revive a completed async/foreground child from its session
454
- • { action: "append-step", id: "...", chain: [{agent:"agent-c", task:"Use {previous}"}] } - append one step to the tail of a running async chain
455
-
456
- DIAGNOSTICS:
457
- • { action: "doctor" } - read-only report for runtime paths, discovery, sessions, and intercom`,
457
+ description: buildSubagentToolDescription(config),
458
458
  parameters: SubagentParams,
459
459
 
460
460
  execute(id, params, signal, onUpdate, ctx) {
@@ -471,7 +471,7 @@ DIAGNOSTICS:
471
471
  }
472
472
  const isParallel = (args.tasks?.length ?? 0) > 0;
473
473
  const parallelCount = effectiveParallelTaskCount(args.tasks as Array<{ count?: unknown }> | undefined);
474
- const asyncLabel = args.async === true && args.clarify !== true && !isParallel ? theme.fg("warning", " [async]") : "";
474
+ const asyncLabel = args.async === true && args.clarify !== true ? theme.fg("warning", " [async]") : "";
475
475
  if (args.chain?.length)
476
476
  return new Text(
477
477
  `${theme.fg("toolTitle", theme.bold("subagent "))}chain (${args.chain.length})${asyncLabel}`,
@@ -480,7 +480,7 @@ DIAGNOSTICS:
480
480
  );
481
481
  if (isParallel)
482
482
  return new Text(
483
- `${theme.fg("toolTitle", theme.bold("subagent "))}parallel (${parallelCount})`,
483
+ `${theme.fg("toolTitle", theme.bold("subagent "))}parallel (${parallelCount})${asyncLabel}`,
484
484
  0,
485
485
  0,
486
486
  );
@@ -504,6 +504,27 @@ DIAGNOSTICS:
504
504
  };
505
505
 
506
506
  pi.registerTool(tool);
507
+
508
+ const waitTool: ToolDefinition<typeof WaitParams, Details> = {
509
+ name: "wait",
510
+ label: "Wait",
511
+ description: `Block until background (async) subagent runs started in this session finish, then return.
512
+
513
+ Use this after launching async subagents when you have no independent work left and must not end your turn — for example inside a skill that has to run to completion, or any non-interactive run (\`pi -p ...\`) where the whole task is a single turn and ending it would abandon the still-running children.
514
+
515
+ • { } — return as soon as the FIRST active run finishes (default). Ideal for a rolling fleet: launch N, wait, spawn a replacement for the one that finished, wait again — keeping N in flight.
516
+ • { all: true } — block until EVERY active run in this session is finished.
517
+ • { id: "..." } — wait for one specific run (id or prefix) to finish.
518
+ • { timeoutMs: 600000 } — stop waiting after N ms (the runs keep going regardless; default 30 min)
519
+
520
+ wait also returns when a run needs attention (a child that went idle or blocked for a decision), not only on completion — so a stuck child never stalls the loop; the summary names the run(s) to inspect/nudge/resume/interrupt. It wakes the instant a completion or control event arrives (subscribed to Pi's event bus, with a poll fallback that reconciles crashed runners), keeps the turn alive for normal notification delivery, and resolves early if the turn is aborted.`,
521
+ parameters: WaitParams,
522
+ execute(_id, params, signal, _onUpdate, _ctx) {
523
+ return waitForSubagents(params, signal, { state, events: pi.events });
524
+ },
525
+ };
526
+ pi.registerTool(waitTool);
527
+
507
528
  registerSlashCommands(pi, state);
508
529
 
509
530
  const eventUnsubscribeStoreKey = "__piSubagentEventUnsubscribes";
@@ -519,7 +540,7 @@ DIAGNOSTICS:
519
540
  }
520
541
  }
521
542
  }
522
- registerSubagentNotify(pi);
543
+ registerSubagentNotify(pi, state, { batchConfig: config.completionBatch });
523
544
 
524
545
  const existingVisibleControlNotices = globalStore[controlNoticeSeenStoreKey];
525
546
  const visibleControlNotices = existingVisibleControlNotices instanceof Set ? existingVisibleControlNotices as Set<string> : new Set<string>();
@@ -536,6 +557,7 @@ DIAGNOSTICS:
536
557
  pi.events.on(SUBAGENT_ASYNC_STARTED_EVENT, handleStarted),
537
558
  pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, handleComplete),
538
559
  pi.events.on(SUBAGENT_CONTROL_EVENT, controlEventHandler),
560
+ rpcBridge.dispose,
539
561
  ];
540
562
  globalStore[eventUnsubscribeStoreKey] = eventUnsubscribes;
541
563
 
@@ -564,6 +586,7 @@ DIAGNOSTICS:
564
586
  const resetSessionState = (ctx: ExtensionContext) => {
565
587
  state.baseCwd = ctx.cwd;
566
588
  state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
589
+ state.subagentSpawns = { sessionId: state.currentSessionId, count: 0 };
567
590
  // Set PI_SUBAGENT_PARENT_SESSION for permission-system forwarding.
568
591
  // Only set in the root session (the interactive UI session), not in
569
592
  // child subagent processes — children inherit the parent's value
@@ -579,12 +602,16 @@ DIAGNOSTICS:
579
602
  cleanupSessionArtifacts(ctx);
580
603
  clearPendingForegroundControlNotices(state);
581
604
  resetJobs(ctx);
605
+ restoreActiveJobs(ctx);
606
+ scheduledRunManager.bindSession(ctx);
582
607
  restoreSlashFinalSnapshots(ctx.sessionManager.getEntries());
583
608
  primeExistingResults();
584
609
  };
585
610
 
586
611
  pi.on("session_start", (_event, ctx) => {
587
612
  resetSessionState(ctx);
613
+ rpcBridge.emitReady(ctx);
614
+ supervisorChannel.start();
588
615
  });
589
616
 
590
617
  pi.on("session_shutdown", () => {
@@ -600,6 +627,7 @@ DIAGNOSTICS:
600
627
  delete globalStore[eventUnsubscribeStoreKey];
601
628
  }
602
629
  stopResultWatcher();
630
+ scheduledRunManager.stop();
603
631
  if (state.poller) clearInterval(state.poller);
604
632
  state.poller = null;
605
633
  clearPendingForegroundControlNotices(state);
@@ -613,6 +641,7 @@ DIAGNOSTICS:
613
641
  slashBridge.dispose();
614
642
  promptTemplateBridge.cancelAll();
615
643
  promptTemplateBridge.dispose();
644
+ supervisorChannel.dispose();
616
645
  if (globalStore[runtimeCleanupStoreKey] === runtimeCleanup) {
617
646
  delete globalStore[runtimeCleanupStoreKey];
618
647
  }
@@ -0,0 +1,369 @@
1
+ import * as path from "node:path";
2
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { Compile } from "typebox/compile";
5
+ import { resolveAsyncRunLocation } from "../runs/background/async-resume.ts";
6
+ import { deliverTimeoutRequest } from "../runs/background/control-channel.ts";
7
+ import { reconcileAsyncRun } from "../runs/background/stale-run-reconciler.ts";
8
+ import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
9
+ import { type Details, ASYNC_DIR, RESULTS_DIR } from "../shared/types.ts";
10
+ import { readStatus } from "../shared/utils.ts";
11
+ import { SubagentParams } from "./schemas.ts";
12
+
13
+ export const SUBAGENT_RPC_PROTOCOL_VERSION = 1;
14
+ export const SUBAGENT_RPC_REQUEST_EVENT = "subagents:rpc:v1:request";
15
+ export const SUBAGENT_RPC_READY_EVENT = "subagents:rpc:v1:ready";
16
+ export const SUBAGENT_RPC_REPLY_EVENT_PREFIX = "subagents:rpc:v1:reply:";
17
+
18
+ export const SUBAGENT_RPC_METHODS = ["ping", "status", "spawn", "interrupt", "stop"] as const;
19
+ export type SubagentRpcMethod = typeof SUBAGENT_RPC_METHODS[number];
20
+
21
+ export interface SubagentRpcRequestEnvelope {
22
+ version: typeof SUBAGENT_RPC_PROTOCOL_VERSION;
23
+ requestId: string;
24
+ method: SubagentRpcMethod;
25
+ params?: unknown;
26
+ source?: {
27
+ extension?: string;
28
+ [key: string]: unknown;
29
+ };
30
+ }
31
+
32
+ export type SubagentRpcReplyEnvelope<T = unknown> = {
33
+ version: typeof SUBAGENT_RPC_PROTOCOL_VERSION;
34
+ requestId: string;
35
+ method?: SubagentRpcMethod;
36
+ success: true;
37
+ data: T;
38
+ } | {
39
+ version: typeof SUBAGENT_RPC_PROTOCOL_VERSION;
40
+ requestId: string;
41
+ method?: SubagentRpcMethod;
42
+ success: false;
43
+ error: {
44
+ code: SubagentRpcErrorCode;
45
+ message: string;
46
+ };
47
+ };
48
+
49
+ type SubagentRpcErrorCode =
50
+ | "invalid_request"
51
+ | "invalid_params"
52
+ | "unsupported_version"
53
+ | "unsupported_method"
54
+ | "no_active_session"
55
+ | "execution_failed"
56
+ | "not_found"
57
+ | "invalid_state";
58
+
59
+ interface EventBus {
60
+ on(event: string, handler: (data: unknown) => void): (() => void) | void;
61
+ emit(event: string, data: unknown): void;
62
+ }
63
+
64
+ interface RegisterSubagentRpcBridgeOptions {
65
+ events: EventBus;
66
+ getContext: () => ExtensionContext | null;
67
+ execute: (
68
+ id: string,
69
+ params: SubagentParamsLike,
70
+ signal: AbortSignal,
71
+ onUpdate: ((result: AgentToolResult<Details>) => void) | undefined,
72
+ ctx: ExtensionContext,
73
+ ) => Promise<AgentToolResult<Details>>;
74
+ asyncDirRoot?: string;
75
+ resultsDir?: string;
76
+ kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
77
+ now?: () => number;
78
+ }
79
+
80
+ class SubagentRpcError extends Error {
81
+ readonly code: SubagentRpcErrorCode;
82
+
83
+ constructor(code: SubagentRpcErrorCode, message: string) {
84
+ super(message);
85
+ this.name = "SubagentRpcError";
86
+ this.code = code;
87
+ }
88
+ }
89
+
90
+ const subagentParamsValidator = Compile(SubagentParams);
91
+
92
+ export function subagentRpcReplyEvent(requestId: string): string {
93
+ return `${SUBAGENT_RPC_REPLY_EVENT_PREFIX}${requestId}`;
94
+ }
95
+
96
+ function isRecord(value: unknown): value is Record<string, unknown> {
97
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
98
+ }
99
+
100
+ function assertRequestId(value: unknown): string {
101
+ if (typeof value !== "string" || value.trim().length === 0 || /[\r\n]/.test(value)) {
102
+ throw new SubagentRpcError("invalid_request", "RPC requestId must be a non-empty string without newlines.");
103
+ }
104
+ return value;
105
+ }
106
+
107
+ function assertRecordParams(params: unknown, method: SubagentRpcMethod): Record<string, unknown> {
108
+ if (params === undefined) return {};
109
+ if (!isRecord(params)) throw new SubagentRpcError("invalid_params", `RPC ${method} params must be an object.`);
110
+ return params;
111
+ }
112
+
113
+ function assertSubagentParams(params: SubagentParamsLike, label: string): void {
114
+ if (subagentParamsValidator.Check(params)) return;
115
+ const messages = [...subagentParamsValidator.Errors(params)]
116
+ .slice(0, 4)
117
+ .map((error) => error.message);
118
+ throw new SubagentRpcError("invalid_params", `${label}: ${messages.join("; ") || "invalid subagent parameters"}`);
119
+ }
120
+
121
+ function textFromToolResult(result: AgentToolResult<Details>): string {
122
+ return result.content
123
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
124
+ .map((part) => part.text)
125
+ .join("\n");
126
+ }
127
+
128
+ function dataFromToolResult(result: AgentToolResult<Details>): { text: string; details?: Details; isError?: boolean } {
129
+ return {
130
+ text: textFromToolResult(result),
131
+ ...(result.details ? { details: result.details } : {}),
132
+ ...(result.isError ? { isError: true } : {}),
133
+ };
134
+ }
135
+
136
+ function failIfToolError(result: AgentToolResult<Details>): void {
137
+ if (!result.isError) return;
138
+ throw new SubagentRpcError("execution_failed", textFromToolResult(result) || "Subagent RPC execution failed.");
139
+ }
140
+
141
+ function normalizeTargetParams(params: unknown, method: SubagentRpcMethod): Pick<SubagentParamsLike, "id" | "runId" | "dir" | "index"> {
142
+ const input = assertRecordParams(params, method);
143
+ const output: Pick<SubagentParamsLike, "id" | "runId" | "dir" | "index"> = {};
144
+ if (input.id !== undefined) output.id = input.id as string;
145
+ if (input.runId !== undefined) output.runId = input.runId as string;
146
+ if (input.dir !== undefined) output.dir = input.dir as string;
147
+ if (input.index !== undefined) output.index = input.index as number;
148
+ return output;
149
+ }
150
+
151
+ function sessionData(ctx: ExtensionContext | null): { cwd?: string; sessionId?: string; sessionFile?: string | null } {
152
+ if (!ctx) return {};
153
+ return {
154
+ cwd: ctx.cwd,
155
+ sessionId: ctx.sessionManager.getSessionId() ?? undefined,
156
+ sessionFile: ctx.sessionManager.getSessionFile() ?? null,
157
+ };
158
+ }
159
+
160
+ function pingData(ctx: ExtensionContext | null) {
161
+ return {
162
+ version: SUBAGENT_RPC_PROTOCOL_VERSION,
163
+ methods: [...SUBAGENT_RPC_METHODS],
164
+ capabilities: {
165
+ status: true,
166
+ asyncSpawn: true,
167
+ interrupt: true,
168
+ stop: true,
169
+ },
170
+ events: {
171
+ ready: SUBAGENT_RPC_READY_EVENT,
172
+ request: SUBAGENT_RPC_REQUEST_EVENT,
173
+ replyPrefix: SUBAGENT_RPC_REPLY_EVENT_PREFIX,
174
+ },
175
+ session: sessionData(ctx),
176
+ };
177
+ }
178
+
179
+ async function executeChecked(
180
+ options: RegisterSubagentRpcBridgeOptions,
181
+ ctx: ExtensionContext,
182
+ requestId: string,
183
+ method: SubagentRpcMethod,
184
+ params: SubagentParamsLike,
185
+ ): Promise<{ text: string; details?: Details; isError?: boolean }> {
186
+ assertSubagentParams(params, `RPC ${method} params`);
187
+ const controller = new AbortController();
188
+ const result = await options.execute(`rpc-${method}-${requestId}`, params, controller.signal, undefined, ctx);
189
+ failIfToolError(result);
190
+ return dataFromToolResult(result);
191
+ }
192
+
193
+ function spawnParams(params: unknown): SubagentParamsLike {
194
+ const input = assertRecordParams(params, "spawn");
195
+ if (input.action !== undefined) {
196
+ throw new SubagentRpcError("invalid_params", "RPC spawn does not accept management/control actions. Use status or interrupt RPC methods instead.");
197
+ }
198
+ if (input.async === false) {
199
+ throw new SubagentRpcError("invalid_params", "RPC spawn only supports detached async launches; omit async or set async: true.");
200
+ }
201
+ if (input.clarify === true) {
202
+ throw new SubagentRpcError("invalid_params", "RPC spawn cannot open the clarify UI; omit clarify or set clarify: false.");
203
+ }
204
+ return { ...(input as SubagentParamsLike), async: true, clarify: false };
205
+ }
206
+
207
+ function stopAsyncRun(
208
+ params: unknown,
209
+ options: RegisterSubagentRpcBridgeOptions,
210
+ ctx: ExtensionContext,
211
+ ): { runId: string; asyncDir: string; previousState: string; state: "stopping"; message: string } {
212
+ const target = normalizeTargetParams(params, "stop");
213
+ assertSubagentParams({ action: "status", ...target }, "RPC stop target params");
214
+ const asyncDirRoot = options.asyncDirRoot ?? ASYNC_DIR;
215
+ const resultsDir = options.resultsDir ?? RESULTS_DIR;
216
+ let location;
217
+ try {
218
+ location = resolveAsyncRunLocation(target, asyncDirRoot, resultsDir);
219
+ } catch (error) {
220
+ throw new SubagentRpcError("invalid_params", error instanceof Error ? error.message : String(error));
221
+ }
222
+ if (!location.asyncDir) {
223
+ throw new SubagentRpcError("not_found", "Async run not found or already completed; stop requires a live async run directory.");
224
+ }
225
+
226
+ const currentSessionId = ctx.sessionManager.getSessionId();
227
+ const initialStatus = readStatus(location.asyncDir);
228
+ const initialRunId = initialStatus?.runId ?? location.resolvedId ?? path.basename(location.asyncDir);
229
+ if (!initialStatus) throw new SubagentRpcError("not_found", `Status file not found for async run '${initialRunId}'.`);
230
+ if (!currentSessionId || initialStatus.sessionId !== currentSessionId) {
231
+ throw new SubagentRpcError("not_found", `Async run '${initialRunId}' was not found in the active session.`);
232
+ }
233
+
234
+ let status;
235
+ try {
236
+ status = reconcileAsyncRun(location.asyncDir, { resultsDir, kill: options.kill, now: options.now }).status;
237
+ } catch (error) {
238
+ throw new SubagentRpcError("execution_failed", error instanceof Error ? error.message : String(error));
239
+ }
240
+ const runId = status?.runId ?? initialRunId;
241
+ if (!status) throw new SubagentRpcError("not_found", `Status file not found for async run '${runId}'.`);
242
+ if (status.sessionId !== currentSessionId) {
243
+ throw new SubagentRpcError("not_found", `Async run '${runId}' was not found in the active session.`);
244
+ }
245
+ if (status.state !== "running") {
246
+ throw new SubagentRpcError("invalid_state", `Async run ${runId} is ${status.state}; stop only supports running async runs.`);
247
+ }
248
+
249
+ try {
250
+ deliverTimeoutRequest({
251
+ asyncDir: location.asyncDir,
252
+ pid: status.pid,
253
+ kill: options.kill,
254
+ now: options.now,
255
+ source: "rpc-stop",
256
+ });
257
+ } catch (error) {
258
+ throw new SubagentRpcError("execution_failed", error instanceof Error ? error.message : String(error));
259
+ }
260
+
261
+ return {
262
+ runId,
263
+ asyncDir: location.asyncDir,
264
+ previousState: status.state,
265
+ state: "stopping",
266
+ message: `Stop requested for async run ${runId}.`,
267
+ };
268
+ }
269
+
270
+ async function handleRequest(
271
+ request: SubagentRpcRequestEnvelope,
272
+ options: RegisterSubagentRpcBridgeOptions,
273
+ ): Promise<unknown> {
274
+ const ctx = options.getContext();
275
+ if (request.method === "ping") return pingData(ctx);
276
+ if (!ctx) throw new SubagentRpcError("no_active_session", "No active extension context for subagent RPC.");
277
+
278
+ if (request.method === "spawn") {
279
+ return executeChecked(options, ctx, request.requestId, request.method, spawnParams(request.params));
280
+ }
281
+ if (request.method === "status") {
282
+ return executeChecked(options, ctx, request.requestId, request.method, { action: "status", ...normalizeTargetParams(request.params, "status") });
283
+ }
284
+ if (request.method === "interrupt") {
285
+ return executeChecked(options, ctx, request.requestId, request.method, { action: "interrupt", ...normalizeTargetParams(request.params, "interrupt") });
286
+ }
287
+ if (request.method === "stop") {
288
+ return stopAsyncRun(request.params, options, ctx);
289
+ }
290
+ throw new SubagentRpcError("unsupported_method", `Unsupported subagent RPC method: ${String(request.method)}`);
291
+ }
292
+
293
+ function parseRequest(raw: unknown): SubagentRpcRequestEnvelope {
294
+ if (!isRecord(raw)) throw new SubagentRpcError("invalid_request", "Subagent RPC request must be an object.");
295
+ const requestId = assertRequestId(raw.requestId);
296
+ if (raw.version !== SUBAGENT_RPC_PROTOCOL_VERSION) {
297
+ throw new SubagentRpcError("unsupported_version", `Unsupported subagent RPC version: ${String(raw.version)}.`);
298
+ }
299
+ if (typeof raw.method !== "string" || !(SUBAGENT_RPC_METHODS as readonly string[]).includes(raw.method)) {
300
+ throw new SubagentRpcError("unsupported_method", `Unsupported subagent RPC method: ${String(raw.method)}.`);
301
+ }
302
+ return {
303
+ version: SUBAGENT_RPC_PROTOCOL_VERSION,
304
+ requestId,
305
+ method: raw.method as SubagentRpcMethod,
306
+ ...(raw.params !== undefined ? { params: raw.params } : {}),
307
+ ...(isRecord(raw.source) ? { source: raw.source as SubagentRpcRequestEnvelope["source"] } : {}),
308
+ };
309
+ }
310
+
311
+ function safeReplyRequestId(raw: unknown): string {
312
+ if (!isRecord(raw)) return "unknown";
313
+ const requestId = raw.requestId;
314
+ return typeof requestId === "string" && requestId.trim().length > 0 && !/[\r\n]/.test(requestId)
315
+ ? requestId
316
+ : "unknown";
317
+ }
318
+
319
+ function errorReply(raw: unknown, error: unknown): SubagentRpcReplyEnvelope {
320
+ const requestId = safeReplyRequestId(raw);
321
+ const method = isRecord(raw) && typeof raw.method === "string" && (SUBAGENT_RPC_METHODS as readonly string[]).includes(raw.method)
322
+ ? raw.method as SubagentRpcMethod
323
+ : undefined;
324
+ const rpcError = error instanceof SubagentRpcError
325
+ ? error
326
+ : new SubagentRpcError("execution_failed", error instanceof Error ? error.message : String(error));
327
+ return {
328
+ version: SUBAGENT_RPC_PROTOCOL_VERSION,
329
+ requestId,
330
+ ...(method ? { method } : {}),
331
+ success: false,
332
+ error: {
333
+ code: rpcError.code,
334
+ message: rpcError.message,
335
+ },
336
+ };
337
+ }
338
+
339
+ export function registerSubagentRpcBridge(options: RegisterSubagentRpcBridgeOptions): {
340
+ emitReady: (ctx?: ExtensionContext | null) => void;
341
+ dispose: () => void;
342
+ } {
343
+ const unsubscribe = options.events.on(SUBAGENT_RPC_REQUEST_EVENT, async (raw) => {
344
+ let request: SubagentRpcRequestEnvelope | undefined;
345
+ try {
346
+ request = parseRequest(raw);
347
+ const data = await handleRequest(request, options);
348
+ options.events.emit(subagentRpcReplyEvent(request.requestId), {
349
+ version: SUBAGENT_RPC_PROTOCOL_VERSION,
350
+ requestId: request.requestId,
351
+ method: request.method,
352
+ success: true,
353
+ data,
354
+ } satisfies SubagentRpcReplyEnvelope);
355
+ } catch (error) {
356
+ const reply = errorReply(request ?? raw, error);
357
+ options.events.emit(subagentRpcReplyEvent(reply.requestId), reply);
358
+ }
359
+ });
360
+
361
+ return {
362
+ emitReady: (ctx) => {
363
+ options.events.emit(SUBAGENT_RPC_READY_EVENT, pingData(ctx ?? options.getContext()));
364
+ },
365
+ dispose: () => {
366
+ if (typeof unsubscribe === "function") unsubscribe();
367
+ },
368
+ };
369
+ }