pi-subagents 0.57.0 → 0.59.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 (115) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/docs/agents.md +18 -9
  3. package/docs/configuration.md +4 -4
  4. package/docs/extension-api.md +40 -1
  5. package/docs/models.md +27 -4
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +92 -6
  8. package/docs/workflows.md +115 -0
  9. package/package.json +3 -1
  10. package/prompts/review-loop.md +2 -2
  11. package/skills/council-mode/SKILL.md +1 -1
  12. package/skills/pi-subagents/SKILL.md +3 -1
  13. package/skills/pi-subagents/references/execution-controls.md +6 -2
  14. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  15. package/skills/pi-subagents/references/prompting-and-roles.md +26 -5
  16. package/src/agents/agent-management.ts +28 -17
  17. package/src/agents/agent-serializer.ts +9 -2
  18. package/src/agents/agents.ts +103 -27
  19. package/src/agents/runtime-agent-events.ts +70 -0
  20. package/src/agents/runtime-agent-registry.ts +19 -18
  21. package/src/api/agents.ts +10 -5
  22. package/src/api/background-work.ts +5 -1
  23. package/src/api/delegation.ts +0 -7
  24. package/src/api/preflight.ts +32 -10
  25. package/src/extension/fanout-child.ts +5 -3
  26. package/src/extension/index.ts +66 -27
  27. package/src/extension/public-execution.ts +15 -2
  28. package/src/extension/rpc.ts +35 -14
  29. package/src/extension/schemas.ts +37 -12
  30. package/src/extension/tool-description.ts +25 -6
  31. package/src/integrations/herdr-status.ts +5 -0
  32. package/src/intercom/result-intercom.ts +2 -0
  33. package/src/profiles/profiles.ts +5 -6
  34. package/src/runs/background/active-async-capacity.ts +2 -2
  35. package/src/runs/background/async-execution.ts +78 -46
  36. package/src/runs/background/async-job-tracker.ts +14 -12
  37. package/src/runs/background/async-resume.ts +29 -27
  38. package/src/runs/background/async-status-snapshot.ts +23 -261
  39. package/src/runs/background/async-status.ts +68 -7
  40. package/src/runs/background/chain-append.ts +8 -3
  41. package/src/runs/background/chain-root-attachment.ts +60 -8
  42. package/src/runs/background/fleet-view.ts +21 -11
  43. package/src/runs/background/notify.ts +184 -9
  44. package/src/runs/background/result-delivery-ownership.ts +45 -0
  45. package/src/runs/background/result-files.ts +2 -1
  46. package/src/runs/background/result-watcher.ts +29 -10
  47. package/src/runs/background/resume-guidance.ts +1 -1
  48. package/src/runs/background/retained-children.ts +1 -1
  49. package/src/runs/background/run-status.ts +18 -8
  50. package/src/runs/background/scheduled-runs.ts +86 -7
  51. package/src/runs/background/stale-run-reconciler.ts +10 -4
  52. package/src/runs/background/steering.ts +4 -14
  53. package/src/runs/background/subagent-runner.ts +389 -350
  54. package/src/runs/background/subagent-wait.ts +58 -10
  55. package/src/runs/background/terminal-run-index.ts +1 -1
  56. package/src/runs/background/wait-completions.ts +22 -1
  57. package/src/runs/background/wait-config.ts +23 -9
  58. package/src/runs/background/wait-tool.ts +9 -2
  59. package/src/runs/foreground/async-steering-action.ts +2 -2
  60. package/src/runs/foreground/execution.ts +134 -121
  61. package/src/runs/foreground/foreground-control.ts +3 -0
  62. package/src/runs/foreground/foreground-history.ts +1 -0
  63. package/src/runs/foreground/subagent-executor.ts +527 -257
  64. package/src/runs/foreground/workflow-detach-reconcile.ts +130 -120
  65. package/src/runs/shared/abort-recovery.ts +119 -0
  66. package/src/runs/shared/async-status-projection.ts +463 -0
  67. package/src/runs/shared/child-identity.ts +19 -4
  68. package/src/runs/shared/child-launch-plan.ts +151 -0
  69. package/src/runs/shared/completion-evidence.ts +89 -0
  70. package/src/runs/shared/completion-guard.ts +5 -4
  71. package/src/runs/shared/dynamic-fanout.ts +3 -3
  72. package/src/runs/shared/fast-mode-extension.ts +5 -5
  73. package/src/runs/shared/host-step-status.ts +230 -0
  74. package/src/runs/shared/lane-metadata.ts +105 -0
  75. package/src/runs/shared/launch-cwd.ts +16 -0
  76. package/src/runs/shared/long-running-guard.ts +2 -1
  77. package/src/runs/shared/mcp-config-sources.ts +422 -0
  78. package/src/runs/shared/mcp-direct-tool-allowlist.ts +221 -158
  79. package/src/runs/shared/mcp-direct-tool-grant.ts +197 -0
  80. package/src/runs/shared/model-exclusions.ts +12 -0
  81. package/src/runs/shared/model-fallback.ts +25 -6
  82. package/src/runs/shared/nested-events.ts +6 -2
  83. package/src/runs/shared/nested-render.ts +7 -3
  84. package/src/runs/shared/parallel-handoff.ts +419 -7
  85. package/src/runs/shared/parallel-utils.ts +15 -1
  86. package/src/runs/shared/pi-args.ts +81 -8
  87. package/src/runs/shared/single-output.ts +44 -4
  88. package/src/runs/shared/subagent-prompt-runtime.ts +98 -13
  89. package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
  90. package/src/runs/shared/worktree.ts +18 -0
  91. package/src/shared/child-session-name.ts +46 -0
  92. package/src/shared/extension-context.ts +24 -0
  93. package/src/shared/formatters.ts +11 -2
  94. package/src/shared/launch-contract.ts +5 -1
  95. package/src/shared/settings.ts +9 -103
  96. package/src/shared/types.ts +240 -30
  97. package/src/shared/utils.ts +41 -84
  98. package/src/slash/delegation-adapters.ts +1 -8
  99. package/src/slash/delegation-request.ts +0 -4
  100. package/src/slash/slash-bridge.ts +1 -2
  101. package/src/slash/slash-commands.ts +369 -90
  102. package/src/slash/slash-live-state.ts +22 -11
  103. package/src/slash/subagents-admin.ts +3 -0
  104. package/src/tui/fleet-status.ts +125 -74
  105. package/src/tui/fleet.ts +11 -5
  106. package/src/tui/render.ts +330 -33
  107. package/src/watchdog/turn-delta.ts +1 -1
  108. package/src/workflows/chat-progress.ts +6 -3
  109. package/src/workflows/host-command.ts +230 -0
  110. package/src/workflows/scripted-workflow.ts +496 -35
  111. package/src/workflows/workflow-child-summary.ts +9 -5
  112. package/src/workflows/workflow-preflight.ts +270 -0
  113. package/src/workflows/workflow-receipt.ts +79 -5
  114. package/src/workflows/workflow-settlement.ts +246 -0
  115. package/src/runs/shared/turn-budget.ts +0 -98
@@ -22,6 +22,10 @@ import { listAsyncRuns, formatAsyncRunProgressLabel, type AsyncRunSummary } from
22
22
  import { encodeInspectReply, handleInspectRpcArgs, INSPECT_WIDGET_KEY } from "../runs/background/inspect-rpc.ts";
23
23
  import { listScheduledRunSummaries } from "../runs/background/scheduled-runs.ts";
24
24
  import { SUBAGENT_FANOUT_CHILD_ENV } from "../runs/shared/pi-args.ts";
25
+ import { resolveAsyncStatusChild } from "../runs/shared/child-identity.ts";
26
+ import { readStatus } from "../shared/utils.ts";
27
+ import { getArtifactPaths, getArtifactsDir } from "../shared/artifacts.ts";
28
+ import { readWorkflowReceipt } from "../workflows/workflow-receipt.ts";
25
29
  import { FLEET_OPEN_SHORTCUT } from "../shared/shortcuts.ts";
26
30
  import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.ts";
27
31
  import { registerPromptWorkflowCommands } from "./prompt-workflows.ts";
@@ -46,7 +50,6 @@ import {
46
50
  DIRS,
47
51
  type Details,
48
52
  type FleetKeybindingsConfig,
49
- type JsonSchemaObject,
50
53
  type SingleResult,
51
54
  type SubagentState,
52
55
  type Usage,
@@ -346,25 +349,60 @@ function usageHasValue(usage: Usage): boolean {
346
349
  return usage.input !== 0 || usage.output !== 0 || usage.cacheRead !== 0 || usage.cacheWrite !== 0 || usage.cost !== 0 || usage.turns !== 0;
347
350
  }
348
351
 
352
+ function nonNegativeNumber(value: unknown): number | undefined {
353
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
354
+ }
355
+
356
+ function usageFromValue(value: unknown, turnsOverride?: number): Usage | undefined {
357
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
358
+ const record = value as Record<string, unknown>;
359
+ const costValue = typeof record.cost === "object" && record.cost !== null && !Array.isArray(record.cost)
360
+ ? (record.cost as Record<string, unknown>).total
361
+ : record.cost;
362
+ const turnsValue = turnsOverride ?? record.turns;
363
+ const usage = {
364
+ input: nonNegativeNumber(record.input) ?? 0,
365
+ output: nonNegativeNumber(record.output) ?? 0,
366
+ cacheRead: nonNegativeNumber(record.cacheRead) ?? 0,
367
+ cacheWrite: nonNegativeNumber(record.cacheWrite) ?? 0,
368
+ cost: nonNegativeNumber(costValue) ?? 0,
369
+ turns: nonNegativeNumber(turnsValue) ?? 0,
370
+ };
371
+ return Number.isSafeInteger(usage.turns) ? usage : undefined;
372
+ }
373
+
349
374
  function assistantUsageFromMessage(message: unknown): Usage | undefined {
350
375
  if (!message || typeof message !== "object") return undefined;
351
376
  const msg = message as { role?: unknown; usage?: unknown };
352
- if (msg.role !== "assistant" || !msg.usage || typeof msg.usage !== "object") return undefined;
353
- const usage = msg.usage as {
354
- input?: unknown;
355
- output?: unknown;
356
- cacheRead?: unknown;
357
- cacheWrite?: unknown;
358
- cost?: { total?: unknown };
359
- };
360
- return {
361
- input: typeof usage.input === "number" ? usage.input : 0,
362
- output: typeof usage.output === "number" ? usage.output : 0,
363
- cacheRead: typeof usage.cacheRead === "number" ? usage.cacheRead : 0,
364
- cacheWrite: typeof usage.cacheWrite === "number" ? usage.cacheWrite : 0,
365
- cost: typeof usage.cost?.total === "number" ? usage.cost.total : 0,
366
- turns: 1,
367
- };
377
+ return msg.role === "assistant" ? usageFromValue(msg.usage, 1) : undefined;
378
+ }
379
+
380
+ function compactionUsageFromEntry(entry: unknown): Usage | undefined {
381
+ if (!entry || typeof entry !== "object") return undefined;
382
+ const record = entry as { type?: unknown; usage?: unknown };
383
+ return record.type === "compaction" ? usageFromValue(record.usage, 0) : undefined;
384
+ }
385
+
386
+ const MAX_USAGE_METADATA_BYTES = 2 * 1024 * 1024;
387
+
388
+ function readUsageMetadata(metadataPath: string): Record<string, unknown> | undefined {
389
+ let fd: number | undefined;
390
+ try {
391
+ fd = fs.openSync(metadataPath, "r");
392
+ const stat = fs.fstatSync(fd);
393
+ if (!stat.isFile() || stat.size > MAX_USAGE_METADATA_BYTES) return undefined;
394
+ const buffer = Buffer.allocUnsafe(stat.size);
395
+ let offset = 0;
396
+ while (offset < buffer.length) {
397
+ const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, null);
398
+ if (bytesRead <= 0) return undefined;
399
+ offset += bytesRead;
400
+ }
401
+ const value: unknown = JSON.parse(buffer.toString("utf-8"));
402
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
403
+ } finally {
404
+ if (fd !== undefined) fs.closeSync(fd);
405
+ }
368
406
  }
369
407
 
370
408
  function isSubagentDetails(value: unknown): value is Details {
@@ -382,10 +420,32 @@ function detailsFromSessionEntry(entry: unknown): Details | undefined {
382
420
  }
383
421
  if (record.type !== "message" || !record.message || typeof record.message !== "object") return undefined;
384
422
  const message = record.message as { role?: unknown; toolName?: unknown; details?: unknown };
385
- if (message.role !== "toolResult" || message.toolName !== "subagent") return undefined;
423
+ if (message.role !== "toolResult" || (message.toolName !== "subagent" && message.toolName !== "subagent_wait")) return undefined;
386
424
  return isSubagentDetails(message.details) ? message.details : undefined;
387
425
  }
388
426
 
427
+ function metadataUsage(
428
+ artifactsDirs: string[],
429
+ input: { runId: string; agent: string },
430
+ ): Usage | undefined {
431
+ if (!/^[A-Za-z0-9._-]+$/.test(input.runId)) return undefined;
432
+ for (const artifactsDir of artifactsDirs) {
433
+ for (const index of [0, undefined] as const) {
434
+ const metadataPath = getArtifactPaths(artifactsDir, input.runId, input.agent, index).metadataPath;
435
+ try {
436
+ const metadata = readUsageMetadata(metadataPath);
437
+ if (!metadata) continue;
438
+ if (metadata.runId !== input.runId || metadata.agent !== input.agent) continue;
439
+ const usage = usageFromValue(metadata.usage);
440
+ if (usage && usageHasValue(usage)) return usage;
441
+ } catch (error) {
442
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to read subagent usage metadata '${metadataPath}':`, error);
443
+ }
444
+ }
445
+ }
446
+ return undefined;
447
+ }
448
+
389
449
  function formatCostUsage(label: string, usage: Usage): string {
390
450
  const extras = [
391
451
  usage.cacheRead ? `cache read ${formatTokens(usage.cacheRead)}` : "",
@@ -395,28 +455,94 @@ function formatCostUsage(label: string, usage: Usage): string {
395
455
  return `${label}: ↑${formatTokens(usage.input)} ↓${formatTokens(usage.output)} $${usage.cost.toFixed(4)}${extras.length ? ` (${extras.join(", ")})` : ""}`;
396
456
  }
397
457
 
398
- function buildSubagentCostReport(ctx: ExtensionContext): string {
458
+ function buildSubagentCostReport(ctx: ExtensionContext, state: SubagentState): string {
399
459
  const parent = emptyUsage();
400
460
  const childTotal = emptyUsage();
401
461
  const total = emptyUsage();
402
462
  const children: Array<{ label: string; usage: Usage; sessionFile?: string }> = [];
463
+ const seenChildren = new Set<string>();
464
+ const workflowRunIds = new Set<string>();
465
+ let unresolvedAsyncChildren = 0;
466
+
467
+ const addChild = (input: { agent?: string; runId?: string; usage?: Usage; sessionFile?: string }): boolean => {
468
+ if (!input.usage || !usageHasValue(input.usage)) return false;
469
+ const identity = input.runId ? `run:${input.runId}` : input.sessionFile ? `session:${input.sessionFile}` : undefined;
470
+ if (identity && seenChildren.has(identity)) return true;
471
+ if (identity) seenChildren.add(identity);
472
+ const usage = { ...input.usage };
473
+ children.push({
474
+ label: `Child ${children.length + 1} (${input.agent ?? "unknown"})`,
475
+ usage,
476
+ ...(input.sessionFile ? { sessionFile: input.sessionFile } : {}),
477
+ });
478
+ addUsage(childTotal, usage);
479
+ return true;
480
+ };
481
+
403
482
  for (const entry of ctx.sessionManager.getBranch()) {
404
483
  const message = entry.type === "message" ? (entry as { message?: unknown }).message : undefined;
405
- const parentUsage = assistantUsageFromMessage(message);
484
+ const parentUsage = assistantUsageFromMessage(message) ?? compactionUsageFromEntry(entry);
406
485
  if (parentUsage) addUsage(parent, parentUsage);
407
486
  const details = detailsFromSessionEntry(entry);
408
487
  if (!details) continue;
488
+ if (details.mode === "workflow" && details.runId) workflowRunIds.add(details.runId);
409
489
  for (const result of details.results) {
410
- if (!usageHasValue(result.usage)) continue;
411
- const usage = { ...result.usage };
412
- children.push({
413
- label: `Child ${children.length + 1} (${result.agent})`,
414
- usage,
415
- ...(result.sessionFile ? { sessionFile: result.sessionFile } : {}),
416
- });
417
- addUsage(childTotal, usage);
490
+ const resultRunId = (result as SingleResult & { runId?: unknown }).runId;
491
+ addChild({ agent: result.agent, runId: typeof resultRunId === "string" ? resultRunId : undefined, usage: usageFromValue(result.usage), sessionFile: result.sessionFile });
492
+ }
493
+ for (const completion of details.completions ?? []) {
494
+ if (completion.mode === "workflow") workflowRunIds.add(completion.runId);
495
+ for (const result of completion.results ?? []) {
496
+ addChild({ agent: result.agent, runId: result.runId, usage: usageFromValue(result.usage), sessionFile: result.sessionFile });
497
+ }
418
498
  }
419
499
  }
500
+
501
+ let sessionFile: string | null = null;
502
+ try {
503
+ sessionFile = ctx.sessionManager.getSessionFile() ?? null;
504
+ } catch { /* an unpersisted session can still report direct child usage */ }
505
+ const artifactsDirs = new Set<string>();
506
+ const addArtifactsDir = (cwd: string | undefined): void => {
507
+ try {
508
+ artifactsDirs.add(getArtifactsDir(sessionFile, cwd, state.artifactDirPreference));
509
+ } catch { /* invalid or unavailable project roots are ignored */ }
510
+ };
511
+ addArtifactsDir(ctx.cwd);
512
+ addArtifactsDir(state.baseCwd);
513
+
514
+ for (const workflowRunId of workflowRunIds) {
515
+ try {
516
+ const status = readStatus(path.join(DIRS.async, workflowRunId));
517
+ addArtifactsDir(status?.cwd);
518
+ const receipt = readWorkflowReceipt(DIRS.async, workflowRunId);
519
+ const summaryChildren = new Map((receipt.workflowChildren?.children ?? []).map((child) => [child.childId, child]));
520
+ const refsByRunId = new Map<string, { runId: string; agent: string }>();
521
+ const addRef = (runId: string | undefined, agent: string | undefined): void => {
522
+ if (!runId || !agent || refsByRunId.has(runId)) return;
523
+ refsByRunId.set(runId, { runId, agent });
524
+ };
525
+ for (const [key, entry] of Object.entries(receipt.entries)) {
526
+ const summaryChild = summaryChildren.get(key);
527
+ const runIds = entry.continuation?.runIds?.length
528
+ ? entry.continuation.runIds
529
+ : entry.latestRunId ? [entry.latestRunId] : summaryChild?.runId ? [summaryChild.runId] : [];
530
+ for (const runId of runIds) addRef(runId, entry.agent ?? summaryChild?.agent);
531
+ }
532
+ for (const child of receipt.workflowChildren?.children ?? []) {
533
+ if (!Object.hasOwn(receipt.entries, child.childId)) addRef(child.runId, child.agent);
534
+ }
535
+ const refs = [...refsByRunId.values()];
536
+ for (const ref of refs) {
537
+ if (seenChildren.has(`run:${ref.runId}`)) continue;
538
+ const usage = metadataUsage([...artifactsDirs], ref);
539
+ if (!usage || !addChild({ ...ref, usage })) unresolvedAsyncChildren += 1;
540
+ }
541
+ } catch (error) {
542
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to resolve async subagent usage for '${workflowRunId}':`, error);
543
+ }
544
+ }
545
+
420
546
  addUsage(total, parent);
421
547
  addUsage(total, childTotal);
422
548
  const lines = [
@@ -432,6 +558,7 @@ function buildSubagentCostReport(ctx: ExtensionContext): string {
432
558
  if (child.sessionFile) lines.push(` Session: ${child.sessionFile}`);
433
559
  }
434
560
  }
561
+ if (unresolvedAsyncChildren > 0) lines.push(`Async child usage unavailable: ${unresolvedAsyncChildren}.`);
435
562
  lines.push("────────────────────────────", formatCostUsage("Children", childTotal), formatCostUsage("Total", total));
436
563
  return lines.join("\n");
437
564
  }
@@ -447,50 +574,76 @@ function getProfileWorkerModel(profile: { subagents?: { agentOverrides?: Record<
447
574
  return typeof model === "string" && model.trim() ? model.trim() : undefined;
448
575
  }
449
576
 
577
+ function isStaleExtensionContextError(error: unknown): boolean {
578
+ return error instanceof Error
579
+ && (error.message.includes("This extension ctx is stale")
580
+ || error.message.includes("Extension context no longer active"));
581
+ }
582
+
583
+ function slashHasUI(ctx: ExtensionContext): boolean | undefined {
584
+ try {
585
+ return ctx.hasUI;
586
+ } catch (error) {
587
+ if (isStaleExtensionContextError(error)) return undefined;
588
+ throw error;
589
+ }
590
+ }
591
+
450
592
 
451
593
  async function requestSlashRun(
452
594
  pi: ExtensionAPI,
453
595
  ctx: ExtensionContext,
454
596
  requestId: string,
455
597
  params: SubagentParamsLike,
598
+ signal?: AbortSignal,
456
599
  ): Promise<SlashSubagentResponse> {
457
600
  return new Promise((resolve, reject) => {
458
601
  let done = false;
459
602
  let started = false;
460
-
461
- const startTimeoutMs = 15_000;
462
- const startTimeout = setTimeout(() => {
463
- finish(() => reject(new Error(
464
- "Slash subagent bridge did not start within 15s. Ensure the extension is loaded correctly.",
465
- )));
466
- }, startTimeoutMs);
603
+ let startTimeout: ReturnType<typeof setTimeout> | undefined;
467
604
 
468
605
  const onStarted = (data: unknown) => {
469
- if (done || !data || typeof data !== "object") return;
606
+ if (done || signal?.aborted || !data || typeof data !== "object") return;
470
607
  if ((data as { requestId?: unknown }).requestId !== requestId) return;
471
608
  started = true;
472
- clearTimeout(startTimeout);
473
- if (ctx.hasUI) ctx.ui.setStatus("subagent-slash", "running...");
609
+ if (startTimeout) clearTimeout(startTimeout);
610
+ try {
611
+ if (ctx.hasUI) ctx.ui.setStatus("subagent-slash", "running...");
612
+ } catch (error) {
613
+ if (isStaleExtensionContextError(error)) {
614
+ finish(() => reject(error));
615
+ return;
616
+ }
617
+ throw error;
618
+ }
474
619
  };
475
620
 
476
621
  const onResponse = (data: unknown) => {
477
- if (done || !data || typeof data !== "object") return;
622
+ if (done || signal?.aborted || !data || typeof data !== "object") return;
478
623
  const response = data as Partial<SlashSubagentResponse>;
479
624
  if (response.requestId !== requestId) return;
480
- clearTimeout(startTimeout);
625
+ if (startTimeout) clearTimeout(startTimeout);
481
626
  finish(() => resolve(response as SlashSubagentResponse));
482
627
  };
483
628
 
484
629
  const onUpdate = (data: unknown) => {
485
- if (done || !data || typeof data !== "object") return;
630
+ if (done || signal?.aborted || !data || typeof data !== "object") return;
486
631
  const update = data as SlashSubagentUpdate;
487
632
  if (update.requestId !== requestId) return;
488
633
  applySlashUpdate(requestId, update);
489
- if (!ctx.hasUI) return;
490
- const tool = update.currentTool ? ` ${update.currentTool}` : "";
491
- const count = update.toolCount ?? 0;
492
- const liveDetailKey = keyText("app.tools.expand");
493
- ctx.ui.setStatus("subagent-slash", `${count} tools${tool} | ${liveDetailKey} live detail`);
634
+ try {
635
+ if (!ctx.hasUI) return;
636
+ const tool = update.currentTool ? ` ${update.currentTool}` : "";
637
+ const count = update.toolCount ?? 0;
638
+ const liveDetailKey = keyText("app.tools.expand");
639
+ ctx.ui.setStatus("subagent-slash", `${count} tools${tool} | ${liveDetailKey} live detail`);
640
+ } catch (error) {
641
+ if (isStaleExtensionContextError(error)) {
642
+ finish(() => reject(error));
643
+ return;
644
+ }
645
+ throw error;
646
+ }
494
647
  };
495
648
 
496
649
  const onTerminalInput = ctx.hasUI
@@ -506,18 +659,43 @@ async function requestSlashRun(
506
659
  const unsubResponse = pi.events.on(SLASH_SUBAGENT_RESPONSE_EVENT, onResponse);
507
660
  const unsubUpdate = pi.events.on(SLASH_SUBAGENT_UPDATE_EVENT, onUpdate);
508
661
 
662
+ let onAbort: () => void;
509
663
  const finish = (next: () => void) => {
510
664
  if (done) return;
511
665
  done = true;
512
- clearTimeout(startTimeout);
666
+ if (startTimeout) clearTimeout(startTimeout);
513
667
  unsubStarted();
514
668
  unsubResponse();
515
669
  unsubUpdate();
516
- onTerminalInput?.();
670
+ try {
671
+ onTerminalInput?.();
672
+ } catch (error) {
673
+ if (!isStaleExtensionContextError(error)) throw error;
674
+ }
675
+ signal?.removeEventListener("abort", onAbort);
517
676
  next();
518
677
  };
678
+ onAbort = () => finish(() => reject(new Error("Slash subagent request canceled during session replacement or reload.")));
679
+ startTimeout = setTimeout(() => {
680
+ finish(() => reject(new Error(
681
+ "Slash subagent bridge did not start within 15s. Ensure the extension is loaded correctly.",
682
+ )));
683
+ }, 15_000);
519
684
 
520
- pi.events.emit(SLASH_SUBAGENT_REQUEST_EVENT, { requestId, params, ctx });
685
+ if (signal?.aborted) {
686
+ onAbort();
687
+ return;
688
+ }
689
+ signal?.addEventListener("abort", onAbort, { once: true });
690
+ try {
691
+ pi.events.emit(SLASH_SUBAGENT_REQUEST_EVENT, { requestId, params, ctx });
692
+ } catch (error) {
693
+ if (isStaleExtensionContextError(error)) {
694
+ finish(() => reject(error));
695
+ return;
696
+ }
697
+ throw error;
698
+ }
521
699
 
522
700
  // Bridge emits STARTED synchronously during REQUEST emit.
523
701
  // If not started, no bridge received the request.
@@ -583,64 +761,76 @@ async function runSlashSubagent(
583
761
  pi: ExtensionAPI,
584
762
  ctx: ExtensionContext,
585
763
  params: SubagentParamsLike,
764
+ signal?: AbortSignal,
586
765
  ): Promise<void> {
587
- if (ctx.hasUI) ctx.ui.setToolsExpanded(false);
766
+ if (signal?.aborted) return;
767
+ const initialHasUI = slashHasUI(ctx);
768
+ if (initialHasUI === undefined) return;
769
+ try {
770
+ if (initialHasUI) ctx.ui.setToolsExpanded(false);
771
+ } catch (error) {
772
+ if (isStaleExtensionContextError(error)) return;
773
+ throw error;
774
+ }
588
775
  const requestId = randomUUID();
589
776
  const initialDetails = buildSlashInitialResult(requestId, params);
590
777
  const initialText = extractSlashMessageText(initialDetails.result.content) || "Running subagent...";
591
- pi.sendMessage({
592
- customType: SLASH_RESULT_TYPE,
593
- content: initialText,
594
- display: true,
595
- details: initialDetails,
596
- });
778
+ try {
779
+ pi.sendMessage({
780
+ customType: SLASH_RESULT_TYPE,
781
+ content: initialText,
782
+ display: true,
783
+ details: initialDetails,
784
+ });
785
+ } catch (error) {
786
+ if (isStaleExtensionContextError(error)) return;
787
+ throw error;
788
+ }
597
789
  persistSlashSessionSnapshot(ctx);
598
790
 
599
791
  try {
600
- const response = await requestSlashRun(pi, ctx, requestId, params);
792
+ const response = await requestSlashRun(pi, ctx, requestId, params, signal);
793
+ if (signal?.aborted) return;
794
+ const hasUI = slashHasUI(ctx);
795
+ if (hasUI === undefined) return;
601
796
  const finalDetails = finalizeSlashResult(response);
602
797
  pi.sendMessage({
603
798
  customType: SLASH_RESULT_TYPE,
604
799
  content: buildSlashExportText(response),
605
- display: !ctx.hasUI,
800
+ display: !hasUI,
606
801
  details: finalDetails,
607
802
  });
608
803
  persistSlashSessionSnapshot(ctx);
609
- if (ctx.hasUI) {
804
+ if (hasUI) {
610
805
  ctx.ui.setStatus("subagent-slash", undefined);
611
806
  }
612
- if (response.isError && ctx.hasUI) {
807
+ if (response.isError && hasUI) {
613
808
  ctx.ui.notify(response.errorText || "Subagent failed", "error");
614
809
  }
615
810
  } catch (error) {
811
+ if (signal?.aborted || isStaleExtensionContextError(error)) return;
616
812
  const message = error instanceof Error ? error.message : String(error);
813
+ const hasUI = slashHasUI(ctx);
814
+ if (hasUI === undefined) return;
617
815
  const failedDetails = failSlashResult(requestId, params, message);
618
816
  pi.sendMessage({
619
817
  customType: SLASH_RESULT_TYPE,
620
818
  content: `## Subagent result\n\n${message}`,
621
- display: !ctx.hasUI,
819
+ display: !hasUI,
622
820
  details: failedDetails,
623
821
  });
624
822
  persistSlashSessionSnapshot(ctx);
625
- if (ctx.hasUI) {
823
+ if (hasUI) {
626
824
  ctx.ui.setStatus("subagent-slash", undefined);
627
825
  }
628
826
  if (message === "Cancelled") {
629
- if (ctx.hasUI) ctx.ui.notify("Cancelled", "warning");
827
+ if (hasUI) ctx.ui.notify("Cancelled", "warning");
630
828
  return;
631
829
  }
632
- if (ctx.hasUI) ctx.ui.notify(message, "error");
830
+ if (hasUI) ctx.ui.notify(message, "error");
633
831
  }
634
832
  }
635
833
 
636
- function launchSlashSubagent(
637
- pi: ExtensionAPI,
638
- ctx: ExtensionContext,
639
- params: SubagentParamsLike,
640
- ): void {
641
- void runSlashSubagent(pi, ctx, params);
642
- }
643
-
644
834
  function slashRunWorkflowScript(key: string, child: Record<string, unknown>): string {
645
835
  return `return runs.run(${JSON.stringify(key)}, ${JSON.stringify(child)})`;
646
836
  }
@@ -649,12 +839,23 @@ export function registerSlashCommands(
649
839
  pi: ExtensionAPI,
650
840
  state: SubagentState,
651
841
  options: { fleetKeybindings?: FleetKeybindingsConfig; foregroundDetachShortcut?: string } = {},
652
- ): void {
842
+ ): { dispose: () => void } {
653
843
  let fleetOpen = false;
844
+ let disposed = false;
845
+ const pendingRequests = new Set<AbortController>();
846
+ const runCommand = (ctx: ExtensionContext, params: SubagentParamsLike): Promise<void> => {
847
+ if (disposed) return Promise.resolve();
848
+ const controller = new AbortController();
849
+ pendingRequests.add(controller);
850
+ return runSlashSubagent(pi, ctx, params, controller.signal).finally(() => pendingRequests.delete(controller));
851
+ };
852
+ const launchCommand = (ctx: ExtensionContext, params: SubagentParamsLike): void => {
853
+ void runCommand(ctx, params);
854
+ };
654
855
  const showFleet = async (ctx: ExtensionContext) => {
655
856
  state.lastUiContext = ctx;
656
857
  if (!ctx.hasUI) {
657
- await runSlashSubagent(pi, ctx, { action: "status", view: "fleet" });
858
+ await runCommand(ctx, { action: "status", view: "fleet" });
658
859
  return;
659
860
  }
660
861
  if (fleetOpen) {
@@ -710,21 +911,21 @@ export function registerSlashCommands(
710
911
  if (inline.skill !== undefined) child.skill = inline.skill;
711
912
  if (inline.model) child.model = inline.model;
712
913
  if (fork) child.context = "fork";
713
- launchSlashSubagent(pi, ctx, { workflowScript: slashRunWorkflowScript("run", child), async: bg ? true : false });
914
+ launchCommand(ctx, { workflowScript: slashRunWorkflowScript("run", child), async: bg ? true : false });
714
915
  },
715
916
  });
716
917
 
717
918
  pi.registerCommand("subagent-cost", {
718
919
  description: "Show parent and subagent child usage cost for this session",
719
920
  handler: async (_args, ctx) => {
720
- sendSlashText(pi, buildSubagentCostReport(ctx));
921
+ sendSlashText(pi, buildSubagentCostReport(ctx, state));
721
922
  },
722
923
  });
723
924
 
724
925
  pi.registerCommand("subagents-doctor", {
725
926
  description: "Show subagent diagnostics",
726
927
  handler: async (_args, ctx) => {
727
- await runSlashSubagent(pi, ctx, { action: "doctor" });
928
+ await runCommand(ctx, { action: "doctor" });
728
929
  },
729
930
  });
730
931
 
@@ -756,7 +957,7 @@ export function registerSlashCommands(
756
957
  ctx.ui.notify("Usage: /subagents-guide [topic]", "error");
757
958
  return;
758
959
  }
759
- await runSlashSubagent(pi, ctx, { action: "guide", ...(topic ? { topic } : {}) });
960
+ await runCommand(ctx, { action: "guide", ...(topic ? { topic } : {}) });
760
961
  },
761
962
  });
762
963
 
@@ -769,7 +970,7 @@ export function registerSlashCommands(
769
970
  ctx.ui.notify("Usage: /subagents-refine <agent>", "error");
770
971
  return;
771
972
  }
772
- await runSlashSubagent(pi, ctx, { action: "refine", agent: parts[0] });
973
+ await runCommand(ctx, { action: "refine", agent: parts[0] });
773
974
  },
774
975
  });
775
976
 
@@ -820,11 +1021,15 @@ export function registerSlashCommands(
820
1021
  }
821
1022
 
822
1023
  pi.registerCommand("subagents-stop", {
823
- description: "Stop a current-session async subagent run",
1024
+ description: "Stop a current-session async subagent run, or one child of it with /subagents-stop <run-id> <child-id>",
824
1025
  handler: async (args, ctx) => {
825
- const id = args.trim();
1026
+ const [id, childId, ...extra] = args.trim().split(/\s+/).filter(Boolean);
1027
+ if (extra.length > 0) {
1028
+ sendSlashText(pi, "Usage: /subagents-stop [run-id] [child-id]");
1029
+ return;
1030
+ }
826
1031
  if (id) {
827
- await runSlashSubagent(pi, ctx, { action: "stop", id });
1032
+ await runCommand(ctx, childId ? { action: "stop", id, childId } : { action: "stop", id });
828
1033
  return;
829
1034
  }
830
1035
 
@@ -856,27 +1061,91 @@ export function registerSlashCommands(
856
1061
  );
857
1062
  if (!result?.confirmed || !result.target) return;
858
1063
  if (result.target.kind === "scheduled") {
859
- await runSlashSubagent(pi, ctx, { action: "schedule.pause", id: result.target.id });
1064
+ await runCommand(ctx, { action: "schedule.pause", id: result.target.id });
860
1065
  return;
861
1066
  }
862
- await runSlashSubagent(pi, ctx, { action: "stop", id: result.target.id });
1067
+ await runCommand(ctx, { action: "stop", id: result.target.id });
1068
+ },
1069
+ });
1070
+
1071
+ pi.registerCommand("subagents-steer", {
1072
+ description: "Steer a live async subagent run with a message, or one child of it with --child <child-id>",
1073
+ handler: async (args, ctx) => {
1074
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
1075
+ const usage = "Usage: /subagents-steer <run-id> [--child <child-id>] <message>";
1076
+ const [id, ...rest] = tokens;
1077
+ if (!id || id.startsWith("--")) {
1078
+ sendSlashText(pi, usage);
1079
+ return;
1080
+ }
1081
+ // Only the optional child selector is parsed as a flag. Everything
1082
+ // after the run id (or selector) is free-form message text, including
1083
+ // a leading token such as `--verbose`.
1084
+ let childId: string | undefined;
1085
+ let messageStart = 0;
1086
+ if (rest[0] === "--child" && rest[1] && !rest[1].startsWith("--")) {
1087
+ childId = rest[1];
1088
+ messageStart = 2;
1089
+ }
1090
+ const message = rest.slice(messageStart).join(" ").trim();
1091
+ if (!message) {
1092
+ sendSlashText(pi, usage);
1093
+ return;
1094
+ }
1095
+ let targetId = id;
1096
+ let childIndex: number | undefined;
1097
+ if (childId !== undefined) {
1098
+ const sessionId = state.currentSessionId ?? ctx.sessionManager.getSessionId() ?? undefined;
1099
+ const run = listAsyncRuns(DIRS.async, {
1100
+ runId: id,
1101
+ states: ["queued", "running", "paused"],
1102
+ ...(sessionId ? { sessionId } : {}),
1103
+ })[0];
1104
+ const status = run ? readStatus(run.asyncDir) : null;
1105
+ if (!run || !status) {
1106
+ sendSlashText(pi, `No current-session async run found for '${id}'.`);
1107
+ return;
1108
+ }
1109
+ const resolutionStatus = {
1110
+ ...status,
1111
+ steps: status.steps?.map((step, index) => ({ ...step, children: run.steps[index]?.children ?? step.children })),
1112
+ };
1113
+ const resolution = resolveAsyncStatusChild(resolutionStatus, childId, { includeNested: true });
1114
+ if (!resolution.ok) {
1115
+ sendSlashText(pi, resolution.message);
1116
+ return;
1117
+ }
1118
+ if (resolution.child.nested) targetId = resolution.child.nested.id;
1119
+ else if (resolution.child.step.runId) targetId = resolution.child.step.runId;
1120
+ else childIndex = resolution.child.index;
1121
+ }
1122
+ await runCommand(ctx, {
1123
+ action: "steer",
1124
+ id: targetId,
1125
+ message,
1126
+ ...(childIndex !== undefined ? { index: childIndex } : {}),
1127
+ // Host-style exact-child authority, matching the extension RPC
1128
+ // nonRecoveringSteer guarantee: never swap the addressed child for
1129
+ // a pause-and-revive replacement after a missed acknowledgment.
1130
+ steeringRecovery: false,
1131
+ });
863
1132
  },
864
1133
  });
865
1134
 
866
1135
  registerPromptWorkflowCommands({
867
1136
  pi,
868
1137
  run: async (params, ctx) => {
869
- launchSlashSubagent(pi, ctx, params);
1138
+ launchCommand(ctx, params);
870
1139
  },
871
1140
  });
872
1141
 
873
1142
  pi.registerCommand("subagents-models", {
874
1143
  description: "Show runtime-loaded builtin subagent models",
875
1144
  getArgumentCompletions: makeBuiltinAgentNameCompletions(),
876
- handler: async (args, ctx) => {
877
- const trimmed = args.trim();
878
- if (!trimmed) {
879
- await runSlashSubagent(pi, ctx, { action: "models" });
1145
+ handler: async (args, ctx) => {
1146
+ const trimmed = args.trim();
1147
+ if (!trimmed) {
1148
+ await runCommand(ctx, { action: "models" });
880
1149
  return;
881
1150
  }
882
1151
  const parts = trimmed.split(/\s+/).filter(Boolean);
@@ -889,7 +1158,7 @@ export function registerSlashCommands(
889
1158
  ctx.ui.notify(`Unknown builtin agent: ${agent}`, "error");
890
1159
  return;
891
1160
  }
892
- await runSlashSubagent(pi, ctx, { action: "models", agent });
1161
+ await runCommand(ctx, { action: "models", agent });
893
1162
  },
894
1163
  });
895
1164
 
@@ -1062,4 +1331,14 @@ export function registerSlashCommands(
1062
1331
  },
1063
1332
  });
1064
1333
 
1334
+ return {
1335
+ dispose: () => {
1336
+ if (disposed) return;
1337
+ disposed = true;
1338
+ // A reload revokes the command context; do not let delayed responses
1339
+ // resume a command against the old Pi context.
1340
+ for (const controller of pendingRequests) controller.abort();
1341
+ pendingRequests.clear();
1342
+ },
1343
+ };
1065
1344
  }