pi-subagents 0.50.0 → 0.52.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 (109) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/agents/oracle.md +3 -1
  3. package/agents/reviewer.md +1 -0
  4. package/agents/scout.md +2 -2
  5. package/agents/worker.md +2 -1
  6. package/async-retention-discovery-worker.mjs +180 -0
  7. package/docs/agents.md +40 -2
  8. package/docs/configuration.md +30 -12
  9. package/docs/extension-api.md +42 -1
  10. package/docs/models.md +2 -0
  11. package/docs/observability.md +41 -5
  12. package/docs/tool-reference.md +38 -39
  13. package/docs/workflows.md +171 -3
  14. package/package.json +4 -2
  15. package/skills/pi-subagents/SKILL.md +6 -4
  16. package/skills/pi-subagents/references/constraints-and-recipes.md +12 -6
  17. package/skills/pi-subagents/references/execution-controls.md +29 -15
  18. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
  19. package/skills/pi-subagents/references/prompting-and-roles.md +4 -2
  20. package/src/agents/agent-management.ts +124 -351
  21. package/src/agents/agents.ts +157 -31
  22. package/src/agents/skills.ts +1 -1
  23. package/src/api/external-job-provider.ts +185 -0
  24. package/src/api/preflight.ts +36 -8
  25. package/src/api/shared-types.ts +2 -0
  26. package/src/extension/config.ts +3 -3
  27. package/src/extension/doctor.ts +3 -6
  28. package/src/extension/fanout-child.ts +2 -2
  29. package/src/extension/index.ts +166 -88
  30. package/src/extension/public-execution.ts +31 -2
  31. package/src/extension/schemas.ts +12 -35
  32. package/src/extension/tool-description.ts +35 -24
  33. package/src/inspectors/herdr/actions.ts +2 -2
  34. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  35. package/src/inspectors/herdr/project-panes.ts +2 -2
  36. package/src/intercom/native-supervisor-channel.ts +32 -10
  37. package/src/missions/lifecycle.ts +6 -1
  38. package/src/missions/store.ts +4 -9
  39. package/src/missions/workflow-state.ts +2 -2
  40. package/src/profiles/profiles.ts +3 -1
  41. package/src/runs/background/active-run-index.ts +31 -8
  42. package/src/runs/background/async-execution.ts +68 -40
  43. package/src/runs/background/async-job-tracker.ts +20 -4
  44. package/src/runs/background/async-resume.ts +47 -15
  45. package/src/runs/background/async-retention.ts +886 -0
  46. package/src/runs/background/async-status.ts +39 -53
  47. package/src/runs/background/chain-append.ts +3 -33
  48. package/src/runs/background/completion-dedupe.ts +5 -1
  49. package/src/runs/background/completion-replay.ts +22 -12
  50. package/src/runs/background/control-channel.ts +14 -68
  51. package/src/runs/background/fleet-view.ts +68 -19
  52. package/src/runs/background/index-segment.ts +59 -0
  53. package/src/runs/background/inspect-rpc.ts +443 -0
  54. package/src/runs/background/notify.ts +31 -5
  55. package/src/runs/background/result-files.ts +158 -90
  56. package/src/runs/background/result-watcher.ts +116 -20
  57. package/src/runs/background/resume-guidance.ts +8 -5
  58. package/src/runs/background/retained-children.ts +13 -3
  59. package/src/runs/background/run-id-query.ts +7 -0
  60. package/src/runs/background/run-id-resolver.ts +11 -9
  61. package/src/runs/background/run-status.ts +27 -18
  62. package/src/runs/background/scheduled-runs.ts +24 -4
  63. package/src/runs/background/stale-run-reconciler.ts +6 -3
  64. package/src/runs/background/steering.ts +11 -1
  65. package/src/runs/background/subagent-runner.ts +253 -140
  66. package/src/runs/background/subagent-wait.ts +8 -8
  67. package/src/runs/background/terminal-run-index.ts +129 -0
  68. package/src/runs/background/wait-completions.ts +21 -4
  69. package/src/runs/background/wait-subscriptions.ts +81 -2
  70. package/src/runs/foreground/async-steering-action.ts +21 -14
  71. package/src/runs/foreground/execution.ts +3 -1
  72. package/src/runs/foreground/subagent-executor.ts +666 -1579
  73. package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
  74. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  75. package/src/runs/shared/acceptance.ts +4 -4
  76. package/src/runs/shared/chain-outputs.ts +1 -3
  77. package/src/runs/shared/external-job-bridge.ts +444 -0
  78. package/src/runs/shared/external-job-runner.ts +286 -0
  79. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  80. package/src/runs/shared/model-fallback.ts +98 -38
  81. package/src/runs/shared/orca-progress-tabs.ts +84 -22
  82. package/src/runs/shared/parallel-handoff.ts +46 -4
  83. package/src/runs/shared/parallel-utils.ts +4 -15
  84. package/src/runs/shared/permissions.ts +5 -1
  85. package/src/runs/shared/pi-args.ts +8 -1
  86. package/src/runs/shared/session-lease.ts +0 -6
  87. package/src/runs/shared/subagent-control.ts +26 -4
  88. package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
  89. package/src/runs/shared/workflow-graph.ts +1 -23
  90. package/src/runs/shared/worktree.ts +14 -2
  91. package/src/shared/atomic-json.ts +22 -2
  92. package/src/shared/capacity-resilient-json.ts +102 -0
  93. package/src/shared/completion-owner.ts +14 -0
  94. package/src/shared/file-system-retry.ts +49 -1
  95. package/src/shared/fork-context.ts +42 -0
  96. package/src/shared/prompt-resources.ts +0 -40
  97. package/src/shared/settings.ts +3 -27
  98. package/src/shared/types.ts +71 -26
  99. package/src/shared/utils.ts +8 -0
  100. package/src/shared/watch-strategy.ts +10 -0
  101. package/src/slash/slash-bridge.ts +2 -1
  102. package/src/slash/slash-commands.ts +29 -3
  103. package/src/tui/fleet.ts +63 -15
  104. package/src/watchdog/change-signature.ts +1 -1
  105. package/src/watchdog/lsp-diagnostics.ts +1 -0
  106. package/src/workflows/chat-progress.ts +2 -2
  107. package/src/workflows/scripted-workflow.ts +220 -67
  108. package/src/runs/foreground/chain-clarify.ts +0 -1354
  109. package/src/runs/foreground/chain-execution.ts +0 -1581
@@ -2,11 +2,11 @@ import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { discoverAgents, discoverAgentsAll, resolveAgentName, type AgentConfig, type AgentScope, type AgentSource } from "../agents/agents.ts";
5
+ import { discoverAgents, discoverAgentsAll, findBlockingAgentDiagnostic, resolveAgentName, type AgentConfig, type AgentScope, type AgentSource } from "../agents/agents.ts";
6
6
  import { resolveExecutionAgentScope } from "../agents/agent-scope.ts";
7
7
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../agents/skills.ts";
8
8
  import { buildAgentMemoryInjection } from "../agents/agent-memory.ts";
9
- import { buildModelCandidates, resolveEffectiveSubagentModel, type AvailableModelInfo, type ParentModel } from "../runs/shared/model-fallback.ts";
9
+ import { buildModelCandidates, inheritsParentModel, resolveEffectiveSubagentModel, type AvailableModelInfo, type ParentModel } from "../runs/shared/model-fallback.ts";
10
10
  import { applyThinkingSuffix, resolvePiLaunchToolPlan, type PiLaunchToolPlan } from "../runs/shared/pi-args.ts";
11
11
  import { injectOutputPathSystemPrompt, normalizeSingleOutputOverride, resolveSingleOutputPath } from "../runs/shared/single-output.ts";
12
12
  import { getArtifactPaths, getArtifactsDir } from "../shared/artifacts.ts";
@@ -17,6 +17,8 @@ import { appendTurnBudgetSystemPrompt } from "../runs/shared/turn-budget.ts";
17
17
  import type { ResolvedTurnBudget } from "../shared/types.ts";
18
18
  import type { ResolvedMcpDirectToolSelection } from "../runs/shared/mcp-direct-tool-allowlist.ts";
19
19
  import { resolveStepBehavior } from "../shared/settings.ts";
20
+ import { canPreferForkFromSnapshot, resolveSubagentLaunchContext } from "../shared/fork-context.ts";
21
+ import { loadConfig } from "../extension/config.ts";
20
22
  import { agentDefinitionDigest, AGENT_DEFINITION_PROJECTION_VERSION, launchBindingDigest } from "../shared/launch-contract.ts";
21
23
  import { DIRS, TEMP_ROOT_DIR } from "../shared/types.ts";
22
24
  import { processTerminalCandidatePath, processTerminalPath } from "../runs/background/process-terminal.ts";
@@ -60,6 +62,8 @@ export interface SubagentLaunchContractInput {
60
62
  artifacts?: boolean;
61
63
  artifactDir?: ArtifactDirPreference;
62
64
  parentSessionFile?: string | null;
65
+ /** Current parent leaf required before an implicit `defaultContext: fork` stays `fork`. */
66
+ parentLeafId?: string | null;
63
67
  sessionRoot?: string;
64
68
  sessionDir?: string;
65
69
  runId?: string;
@@ -190,6 +194,18 @@ function normalizeAvailableModels(models: SubagentLaunchContractInput["available
190
194
  return (models ?? []).map((model) => ({ ...model, fullId: model.fullId ?? `${model.provider}/${model.id}` }));
191
195
  }
192
196
 
197
+ function resolveLaunchContractContext(input: SubagentLaunchContractInput, agent: AgentConfig): "fresh" | "fork" {
198
+ return resolveSubagentLaunchContext({
199
+ explicitContext: input.context,
200
+ agentDefaultContext: agent.defaultContext,
201
+ defaultSubagentContext: loadConfig().defaultSubagentContext,
202
+ canUseImplicitFork: canPreferForkFromSnapshot({
203
+ parentSessionFile: input.parentSessionFile,
204
+ leafId: input.parentLeafId,
205
+ }),
206
+ });
207
+ }
208
+
193
209
  function candidateList(inputAgent: string, selected: AgentConfig | undefined, cwd: string): SubagentLaunchContractAgentCandidate[] {
194
210
  const all = discoverAgentsAll(cwd);
195
211
  return [...all.builtin, ...all.package, ...all.user, ...all.project]
@@ -222,12 +238,17 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
222
238
  if (input.artifactDir !== undefined && input.artifactDir !== "project" && input.artifactDir !== "session" && input.artifactDir !== "temp") {
223
239
  return { ok: false, code: "invalid_artifact_dir", message: `Unsupported artifactDir '${String(input.artifactDir)}'; expected 'project', 'session', or 'temp'.`, diagnostics };
224
240
  }
225
- if (input.context === "fork") {
226
- diagnostics.push({ code: "host_required", severity: "host-required", message: "Exact fork session branching and fork-thinking downgrade checks require Pi host session and model-registry snapshots." });
227
- }
228
241
  const scope = resolveExecutionAgentScope(input.agentScope);
229
242
  const discovered = discoverAgents(effectiveCwd, scope);
230
243
  const resolvedAgent = resolveAgentName(input.agent, discovered.agents);
244
+ const ambiguousCandidates = resolvedAgent.error
245
+ ? discovered.agents.filter((agent) => resolveAgentName(input.agent, [agent]).agent)
246
+ : resolvedAgent.agent;
247
+ const invalidAgent = findBlockingAgentDiagnostic(input.agent, ambiguousCandidates, discovered.agentDiagnostics);
248
+ if (invalidAgent) {
249
+ const message = `Agent '${input.agent}' has invalid configuration: ${invalidAgent.error}`;
250
+ return { ok: false, code: "missing_agent", message, diagnostics: [{ code: "missing_agent", severity: "error", message }] };
251
+ }
231
252
  if (resolvedAgent.error) {
232
253
  return { ok: false, code: "ambiguous_agent", message: resolvedAgent.error, diagnostics };
233
254
  }
@@ -235,6 +256,10 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
235
256
  return { ok: false, code: "missing_agent", message: `Unknown agent: ${input.agent}`, diagnostics };
236
257
  }
237
258
  const agent = resolvedAgent.agent;
259
+ const context = resolveLaunchContractContext(input, agent);
260
+ if (context === "fork") {
261
+ diagnostics.push({ code: "host_required", severity: "host-required", message: "Exact fork session branching and fork-thinking downgrade checks require Pi host session and model-registry snapshots." });
262
+ }
238
263
  const effectiveCapabilityCeiling = intersectSubagentCapabilityCeilings(input.capabilityCeiling, input.inheritedCapabilityCeiling);
239
264
  const restrictionMessage = capabilityCeilingAgentRestrictionMessage(agent.name, effectiveCapabilityCeiling);
240
265
  if (restrictionMessage) return { ok: false, code: "restricted_agent", message: restrictionMessage, diagnostics };
@@ -260,7 +285,7 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
260
285
  }
261
286
  if (resolvedSkills.missing.length > 0) diagnostics.push({ code: "missing_skill", severity: "error", message: `Missing skills: ${resolvedSkills.missing.join(", ")}` });
262
287
 
263
- const externalRunner = agent.runner?.type === "external-cli";
288
+ const externalRunner = agent.runner?.type === "external-cli" || agent.runner?.type === "external-job";
264
289
  const availableModels = normalizeAvailableModels(input.availableModels);
265
290
  const preferredProvider = input.preferredProvider ?? input.parentModel?.provider;
266
291
  const primaryModel = externalRunner
@@ -270,7 +295,10 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
270
295
  const model = externalRunner ? undefined : applyThinkingSuffix(primaryModel, effectiveThinkingConfig, input.thinking !== undefined);
271
296
  const modelCandidates = externalRunner
272
297
  ? []
273
- : buildModelCandidates(primaryModel, agent.fallbackModels, availableModels, preferredProvider, { scope: discovered.modelScope })
298
+ : buildModelCandidates(primaryModel, agent.fallbackModels, availableModels, preferredProvider, {
299
+ scope: discovered.modelScope,
300
+ primaryModelFromParent: inheritsParentModel(input.model, agent.model, input.parentModel),
301
+ })
274
302
  .map((candidate) => applyThinkingSuffix(candidate, effectiveThinkingConfig, input.thinking !== undefined) ?? candidate);
275
303
  let toolPlan: PiLaunchToolPlan;
276
304
  try {
@@ -335,7 +363,7 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
335
363
  definitionDigest,
336
364
  shadowedCandidates,
337
365
  },
338
- context: input.context ?? agent.defaultContext ?? "fresh",
366
+ context,
339
367
  ...(model ? { model } : {}),
340
368
  modelCandidates,
341
369
  ...(resolveEffectiveThinking(model, effectiveThinkingConfig) ? { thinking: resolveEffectiveThinking(model, effectiveThinkingConfig) } : {}),
@@ -9,6 +9,8 @@ export {
9
9
  type ControlEvent,
10
10
  type Details,
11
11
  type ExecutionProjection,
12
+ type ExternalJobRunnerStatus,
13
+ type ExternalJobStatus,
12
14
  type JsonSchemaObject,
13
15
  type OutputMode,
14
16
  type ReviewProjection,
@@ -94,6 +94,9 @@ function validateMainWindowRendererConfig(value: unknown): void {
94
94
  }
95
95
 
96
96
  function validateConfig(config: Record<string, unknown>): void {
97
+ if (config.defaultSubagentContext !== undefined && config.defaultSubagentContext !== "fresh" && config.defaultSubagentContext !== "fork") {
98
+ throw new Error('config.defaultSubagentContext must be "fresh" or "fork"');
99
+ }
97
100
  if (config.foregroundDetachShortcut !== undefined
98
101
  && (typeof config.foregroundDetachShortcut !== "string" || !isValidKeyId(config.foregroundDetachShortcut))) {
99
102
  throw new Error("config.foregroundDetachShortcut must be a valid keybinding string such as \"ctrl+b\"");
@@ -101,9 +104,6 @@ function validateConfig(config: Record<string, unknown>): void {
101
104
  if (config.artifactDir !== undefined && !ARTIFACT_DIR_PREFERENCES.has(config.artifactDir as ArtifactDirPreference)) {
102
105
  throw new Error(`config.artifactDir must be "project", "session", or "temp"`);
103
106
  }
104
- if (config.legacyChainControls !== undefined && typeof config.legacyChainControls !== "boolean") {
105
- throw new Error("config.legacyChainControls must be a boolean");
106
- }
107
107
  if (config.maxActiveAsyncRunsPerSession !== undefined
108
108
  && (typeof config.maxActiveAsyncRunsPerSession !== "number"
109
109
  || !Number.isInteger(config.maxActiveAsyncRunsPerSession)
@@ -134,7 +134,7 @@ function formatSessionLines(input: DoctorReportInput): string[] {
134
134
 
135
135
  function formatDiscovery(input: DoctorReportInput, deps: DoctorDeps): string[] {
136
136
  return [
137
- lineFromCheck("agents/chains", () => {
137
+ lineFromCheck("agents", () => {
138
138
  const discovered = deps.discoverAgentsAll(input.cwd);
139
139
  const agentCounts = {
140
140
  builtin: discovered.builtin.length,
@@ -142,13 +142,10 @@ function formatDiscovery(input: DoctorReportInput, deps: DoctorDeps): string[] {
142
142
  user: discovered.user.length,
143
143
  project: discovered.project.length,
144
144
  };
145
- const chainCounts = discovered.chains.reduce<Record<AgentSource, number>>((counts, chain) => {
146
- counts[chain.source] += 1;
147
- return counts;
148
- }, { builtin: 0, package: 0, user: 0, project: 0 });
145
+ const diagnostics = discovered.agentDiagnostics ?? [];
149
146
  return [
150
147
  `- agents: total ${agentCounts.builtin + agentCounts.package + agentCounts.user + agentCounts.project} (${formatSourceCounts(agentCounts)})`,
151
- `- chains: total ${discovered.chains.length} (${formatSourceCounts(chainCounts)})`,
148
+ ...diagnostics.map((diagnostic) => `- invalid agent ${diagnostic.name ?? diagnostic.filePath} (${diagnostic.source}): ${diagnostic.error}`),
152
149
  ].join("\n");
153
150
  }),
154
151
  lineFromCheck("skills", () => {
@@ -171,13 +171,13 @@ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI):
171
171
  allowMutatingManagementActions: false,
172
172
  });
173
173
 
174
- const params = createSubagentParamsSchema(config);
174
+ const params = createSubagentParamsSchema();
175
175
  const tool: ToolDefinition<typeof params, Details> = {
176
176
  name: "subagent",
177
177
  label: "Subagent",
178
178
  description: [
179
179
  "Delegate to subagents from child-safe fanout mode.",
180
- `Allowed management/control actions: list, get, status, interrupt, resume, steer${config.legacyChainControls === true ? ", append-step" : ""}, doctor.`,
180
+ "Allowed management/control actions: list, get, status, interrupt, resume, steer, doctor.",
181
181
  "Mutating management actions (create, update, delete, eject, disable, enable, reset, grant-spawn-budget) are blocked in this mode.",
182
182
  ].join("\n"),
183
183
  parameters: params,
@@ -9,7 +9,7 @@
9
9
  * Toggle: async parameter (default: true; set asyncByDefault:false in config.json to opt out)
10
10
  *
11
11
  * Config file: ~/.pi/agent/extensions/subagent/config.json
12
- * { "asyncByDefault": true, "forceTopLevelAsync": true, "maxSubagentDepth": 1, "intercomBridge": { "mode": "always", "instructionFile": "./intercom-bridge.md" }, "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
12
+ * { "asyncByDefault": true, "defaultSubagentContext": "fork", "forceTopLevelAsync": true, "maxSubagentDepth": 1, "intercomBridge": { "mode": "always", "instructionFile": "./intercom-bridge.md" }, "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
13
13
  */
14
14
 
15
15
  import { randomUUID } from "node:crypto";
@@ -23,6 +23,7 @@ import { discoverAgents } from "../agents/agents.ts";
23
23
  import { ensureAccessibleDir } from "../shared/accessible-dir.ts";
24
24
  import { cleanupAllArtifactDirs, cleanupOldArtifacts, getArtifactsDir } from "../shared/artifacts.ts";
25
25
  import { resolveCurrentSessionId } from "../shared/session-identity.ts";
26
+ import { currentCompletionOwnerId } from "../shared/completion-owner.ts";
26
27
  import { cleanupOldChainDirs } from "../shared/settings.ts";
27
28
  import { clearLegacyResultAnimationTimer, renderSubagentResult, renderSubagentSummary } from "../tui/render.ts";
28
29
  import { openSubagentFleet } from "../tui/fleet.ts";
@@ -31,7 +32,8 @@ import { createSubagentParamsSchema } from "./schemas.ts";
31
32
  import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
32
33
  import { createAsyncJobTracker } from "../runs/background/async-job-tracker.ts";
33
34
  import { getActiveAsyncCapacitySnapshot, resolveMaxActiveAsyncRunsPerSession } from "../runs/background/active-async-capacity.ts";
34
- import { cleanupResultIndexes } from "../runs/background/result-files.ts";
35
+ import { cleanupResultIndexes, missionObserverResultCandidateFiles } from "../runs/background/result-files.ts";
36
+ import { ASYNC_RETENTION_DELAY_MS, cleanupAsyncRetention } from "../runs/background/async-retention.ts";
35
37
  import { createResultWatcher } from "../runs/background/result-watcher.ts";
36
38
  import { createScheduledRunManager } from "../runs/background/scheduled-runs.ts";
37
39
  import { registerSlashCommands } from "../slash/slash-commands.ts";
@@ -53,7 +55,7 @@ import { SUBAGENT_CHILD_ENV, SUBAGENT_PARENT_SESSION_ENV } from "../runs/shared/
53
55
  import { resolveCurrentSubagentCapabilityCeiling } from "../runs/shared/capability-ceiling.ts";
54
56
  import { formatDuration, shortenPath } from "../shared/formatters.ts";
55
57
  import { loadConfig, resolveAsyncByDefault, resolveScheduledStoreRoot } from "./config.ts";
56
- import { buildSubagentToolDescription } from "./tool-description.ts";
58
+ import { buildSubagentToolDescription, buildSubagentToolPromptMetadata } from "./tool-description.ts";
57
59
  import { collectGoalContinuationNotices } from "../missions/goal-driver.ts";
58
60
  import { restoreForegroundRunHistory } from "../runs/foreground/foreground-history.ts";
59
61
  import { resolveMissionStoreLocation } from "../missions/store.ts";
@@ -84,6 +86,37 @@ import {
84
86
  export { loadConfig, resolveAsyncByDefault } from "./config.ts";
85
87
 
86
88
  const SLOW_RELOAD_PHASE_MS = 250;
89
+ const RUNTIME_REGISTRY_STORE_KEY = "__piSubagentRuntimeRegistry";
90
+
91
+ interface SubagentRuntimeEntry {
92
+ cleanup(): void;
93
+ sessionManager: object | null;
94
+ visibleControlNotices: Set<string>;
95
+ }
96
+
97
+ interface SubagentRuntimeRegistry {
98
+ bySessionManager: WeakMap<object, SubagentRuntimeEntry>;
99
+ visibleControlNoticesBySessionManager: WeakMap<object, Set<string>>;
100
+ activeEntries: Set<SubagentRuntimeEntry>;
101
+ }
102
+
103
+ function getRuntimeRegistry(): SubagentRuntimeRegistry {
104
+ const globalStore = globalThis as Record<string, unknown>;
105
+ const existing = globalStore[RUNTIME_REGISTRY_STORE_KEY] as Partial<SubagentRuntimeRegistry> | undefined;
106
+ if (existing?.bySessionManager instanceof WeakMap && existing.activeEntries instanceof Set) {
107
+ if (!(existing.visibleControlNoticesBySessionManager instanceof WeakMap)) {
108
+ existing.visibleControlNoticesBySessionManager = new WeakMap();
109
+ }
110
+ return existing as SubagentRuntimeRegistry;
111
+ }
112
+ const registry: SubagentRuntimeRegistry = {
113
+ bySessionManager: new WeakMap(),
114
+ visibleControlNoticesBySessionManager: new WeakMap(),
115
+ activeEntries: new Set(),
116
+ };
117
+ globalStore[RUNTIME_REGISTRY_STORE_KEY] = registry;
118
+ return registry;
119
+ }
87
120
 
88
121
  function logSlowPhase(label: string, startedAt: number): void {
89
122
  const elapsed = Date.now() - startedAt;
@@ -358,16 +391,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
358
391
  if (process.env[SUBAGENT_CHILD_ENV] === "1") {
359
392
  return;
360
393
  }
361
- const globalStore = globalThis as Record<string, unknown>;
362
- const runtimeCleanupStoreKey = "__piSubagentRuntimeCleanup";
363
- const previousRuntimeCleanup = globalStore[runtimeCleanupStoreKey];
364
- if (typeof previousRuntimeCleanup === "function") {
365
- try {
366
- previousRuntimeCleanup();
367
- } catch {
368
- // Best effort cleanup for stale timers from an older reload.
369
- }
370
- }
394
+ const runtimeRegistry = getRuntimeRegistry();
371
395
 
372
396
  DIRS.results = ensureAccessibleDir(DIRS.results);
373
397
  DIRS.async = ensureAccessibleDir(DIRS.async);
@@ -395,6 +419,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
395
419
  const state: SubagentState = {
396
420
  baseCwd: "",
397
421
  currentSessionId: null,
422
+ completionOwnerId: currentCompletionOwnerId(),
398
423
  artifactDirPreference: config.artifactDir ?? DEFAULT_ARTIFACT_CONFIG.dir,
399
424
  ...(config.authorityPolicy ? { authorityPolicy: config.authorityPolicy } : {}),
400
425
  ...(config.missions ? { missionStoreConfig: config.missions } : {}),
@@ -440,6 +465,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
440
465
  : undefined;
441
466
  let executorScheduled: ((id: string, params: SubagentParamsLike, signal: AbortSignal, ctx: ExtensionContext) => Promise<AgentToolResult<Details>>) | undefined;
442
467
  let goalTurnId = 0;
468
+ let parentSessionEnvValue: string | null = null;
443
469
  const scheduledStoreRoot = config.scheduledRuns?.storeRoot === undefined ? undefined : resolveScheduledStoreRoot(config.scheduledRuns.storeRoot);
444
470
  const scheduledRunManager = createScheduledRunManager({
445
471
  config,
@@ -456,10 +482,18 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
456
482
  },
457
483
  resolveCapabilityCeiling: (sessionId) => resolveCurrentSubagentCapabilityCeiling(sessionId),
458
484
  });
485
+ let refreshResultDelivery = () => {};
486
+ const hasResultDeliveryDemand = () => {
487
+ if ([...state.asyncJobs.values()].some((job) => job.status === "queued" || job.status === "running")) return true;
488
+ if (state.foregroundControls.size > 0) return true;
489
+ if (scheduledRunManager.observedCompletionRunIds().size > 0) return true;
490
+ return missionObserverResultCandidateFiles(DIRS.results).length > 0;
491
+ };
459
492
  const { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs, dispose: disposeAsyncJobTracker } = createAsyncJobTracker(pi, state, DIRS.async, {
460
493
  widgetEnabled: asyncWidgetEnabled,
494
+ onJobTerminal: () => refreshResultDelivery(),
461
495
  });
462
- const { startResultWatcher, primeExistingResults, stopResultWatcher } = createResultWatcher(
496
+ const resultWatcher = createResultWatcher(
463
497
  pi,
464
498
  state,
465
499
  DIRS.results,
@@ -468,23 +502,30 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
468
502
  notifier: completionNotifier,
469
503
  observeCompletion: (result) => scheduledRunManager.handleAsyncCompletion(result),
470
504
  observedCompletionRunIds: () => scheduledRunManager.observedCompletionRunIds(),
505
+ hasDeliveryDemand: hasResultDeliveryDemand,
471
506
  deliverIntercomResults: config.intercomBridge?.resultDelivery === true,
472
507
  },
473
508
  );
474
-
475
- const runtimeCleanup = () => {
476
- clearTimeout(resultIndexCleanupTimer);
477
- stopResultWatcher();
478
- state.currentSessionId = null;
479
- completionNotifier.dispose();
480
- mainWatchdog.dispose();
481
- scheduledRunManager.stop();
482
- supervisorChannel.dispose();
483
- waitSubscriptionManager.dispose();
484
- fleetStatus?.dispose();
485
- disposeAsyncJobTracker();
486
- };
487
- globalStore[runtimeCleanupStoreKey] = runtimeCleanup;
509
+ const { startResultWatcher, primeExistingResults, stopResultWatcher } = resultWatcher;
510
+ refreshResultDelivery = resultWatcher.refreshResultDelivery;
511
+ const asyncRetentionAbort = new AbortController();
512
+ const asyncRetentionTimer = setTimeout(async () => {
513
+ try {
514
+ await cleanupAsyncRetention({
515
+ asyncDirRoot: DIRS.async,
516
+ resultsDir: DIRS.results,
517
+ signal: asyncRetentionAbort.signal,
518
+ protectedRunIds: new Set([
519
+ ...state.asyncJobs.keys(),
520
+ ...(state.workflowControllers?.keys() ?? []),
521
+ ...scheduledRunManager.referencedAsyncRunIds(),
522
+ ]),
523
+ });
524
+ } catch (error) {
525
+ console.error("Failed to clean retained async subagent state:", error);
526
+ }
527
+ }, ASYNC_RETENTION_DELAY_MS);
528
+ asyncRetentionTimer.unref?.();
488
529
 
489
530
  const executor = createSubagentExecutor({
490
531
  pi,
@@ -498,6 +539,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
498
539
  getSubagentSessionRoot,
499
540
  expandTilde,
500
541
  discoverAgents,
542
+ activateSupervisorTransport: () => supervisorChannel.activateTransport(),
543
+ refreshResultDelivery: () => refreshResultDelivery(),
501
544
  });
502
545
  executorScheduled = executor.executeScheduled;
503
546
 
@@ -568,6 +611,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
568
611
 
569
612
  const slashBridge = registerSlashSubagentBridge({
570
613
  events: pi.events,
614
+ asyncByDefault,
571
615
  getContext: () => state.lastUiContext,
572
616
  execute: (id, params, signal, onUpdate, ctx) =>
573
617
  executeSubagentCollapsed(id, params, signal, onUpdate, ctx),
@@ -592,11 +636,12 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
592
636
  });
593
637
 
594
638
 
595
- const parameters = createSubagentParamsSchema(config);
639
+ const parameters = createSubagentParamsSchema();
596
640
  const tool: ToolDefinition<typeof parameters, Details> = {
597
641
  name: "subagent",
598
642
  label: "Subagent",
599
643
  description: buildSubagentToolDescription(config),
644
+ ...buildSubagentToolPromptMetadata(config),
600
645
  parameters,
601
646
 
602
647
  execute(id, params, signal, onUpdate, ctx) {
@@ -607,7 +652,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
607
652
  const gap = " ".repeat(config.mainWindowRenderer?.horizontalSpacing ?? 1);
608
653
  const title = theme.fg("toolTitle", theme.bold("subagent"));
609
654
  if (args.action) {
610
- const target = args.agent || args.chainName || "";
655
+ const target = args.agent || "";
611
656
  return new Text(
612
657
  `${title}${gap}${args.action}${target ? `${gap}${theme.fg("accent", target)}` : ""}`,
613
658
  0, 0,
@@ -667,22 +712,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
667
712
  foregroundDetachShortcut: config.foregroundDetachShortcut,
668
713
  });
669
714
 
670
- const eventUnsubscribeStoreKey = "__piSubagentEventUnsubscribes";
671
- const controlNoticeSeenStoreKey = "__piSubagentVisibleControlNotices";
672
- const previousEventUnsubscribes = globalStore[eventUnsubscribeStoreKey];
673
- if (Array.isArray(previousEventUnsubscribes)) {
674
- for (const unsubscribe of previousEventUnsubscribes) {
675
- if (typeof unsubscribe !== "function") continue;
676
- try {
677
- unsubscribe();
678
- } catch {
679
- // Best effort cleanup for stale handlers from an older reload.
680
- }
681
- }
682
- }
683
- const existingVisibleControlNotices = globalStore[controlNoticeSeenStoreKey];
684
- const visibleControlNotices = existingVisibleControlNotices instanceof Set ? existingVisibleControlNotices as Set<string> : new Set<string>();
685
- globalStore[controlNoticeSeenStoreKey] = visibleControlNotices;
715
+ let visibleControlNotices = new Set<string>();
686
716
  const activeHerdrRuns = () => projectActiveHerdrRuns(state);
687
717
  const herdrStatusBridge = registerHerdrStatusBridge({
688
718
  events: pi.events,
@@ -704,10 +734,13 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
704
734
  };
705
735
  const asyncStartedHandler = (payload: unknown) => {
706
736
  handleStarted(payload);
737
+ supervisorChannel.activateTransport();
738
+ refreshResultDelivery();
707
739
  fleetStatus?.refresh();
708
740
  };
709
741
  const asyncCompleteHandler = (payload: unknown) => {
710
742
  handleComplete(payload);
743
+ refreshResultDelivery();
711
744
  refreshActiveAsyncCapacity();
712
745
  scheduledRunManager.handleAsyncCompletion(payload);
713
746
  fleetStatus?.refresh();
@@ -724,7 +757,6 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
724
757
  herdrStatusBridge.dispose,
725
758
  rpcBridge.dispose,
726
759
  ];
727
- globalStore[eventUnsubscribeStoreKey] = eventUnsubscribes;
728
760
 
729
761
  pi.on("tool_result", (event, ctx) => {
730
762
  if (event.toolName !== "subagent") return;
@@ -802,6 +834,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
802
834
  const sessionId = ctx.sessionManager.getSessionId();
803
835
  if (sessionId) {
804
836
  process.env[SUBAGENT_PARENT_SESSION_ENV] = sessionId;
837
+ parentSessionEnvValue = sessionId;
805
838
  }
806
839
  }
807
840
  state.lastUiContext = ctx;
@@ -840,6 +873,89 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
840
873
  fleetStatus?.setContext(ctx);
841
874
  };
842
875
 
876
+ let runtimeCleaned = false;
877
+ const runtimeEntry: SubagentRuntimeEntry = {
878
+ sessionManager: null,
879
+ visibleControlNotices,
880
+ cleanup() {
881
+ if (runtimeCleaned) return;
882
+ runtimeCleaned = true;
883
+ const shuttingDownParentSession = parentSessionEnvValue;
884
+ clearTimeout(resultIndexCleanupTimer);
885
+ clearTimeout(asyncRetentionTimer);
886
+ asyncRetentionAbort.abort();
887
+ stopResultWatcher();
888
+ completionNotifier.dispose();
889
+ mainWatchdog.dispose();
890
+ scheduledRunManager.stop();
891
+ supervisorChannel.dispose();
892
+ waitSubscriptionManager.dispose();
893
+ fleetStatus?.dispose();
894
+ disposeAsyncJobTracker();
895
+ for (const timer of state.cleanupTimers.values()) clearTimeout(timer);
896
+ state.cleanupTimers.clear();
897
+ state.asyncJobs.clear();
898
+ for (const unsubscribe of eventUnsubscribes) {
899
+ try {
900
+ unsubscribe();
901
+ } catch {
902
+ // Best effort cleanup during shutdown or reload.
903
+ }
904
+ }
905
+ slashBridge.cancelAll();
906
+ slashBridge.dispose();
907
+ promptTemplateBridge.cancelAll();
908
+ promptTemplateBridge.dispose();
909
+ state.widgetsSuspended = false;
910
+ state.currentSessionId = null;
911
+ state.parentSessionFile = null;
912
+ parentSessionEnvValue = null;
913
+ if (shuttingDownParentSession && process.env[SUBAGENT_PARENT_SESSION_ENV] === shuttingDownParentSession) {
914
+ delete process.env[SUBAGENT_PARENT_SESSION_ENV];
915
+ }
916
+ if (runtimeEntry.sessionManager && runtimeRegistry.bySessionManager.get(runtimeEntry.sessionManager) === runtimeEntry) {
917
+ runtimeRegistry.bySessionManager.delete(runtimeEntry.sessionManager);
918
+ }
919
+ runtimeRegistry.activeEntries.delete(runtimeEntry);
920
+ if (runtimeRegistry.activeEntries.size === 0) clearSlashSnapshots();
921
+ try {
922
+ if (state.lastUiContext?.hasUI) state.lastUiContext.ui.setWidget(WIDGET_KEY, undefined);
923
+ } catch (error) {
924
+ if (!isStaleExtensionContextError(error)) throw error;
925
+ }
926
+ state.lastUiContext = null;
927
+ },
928
+ };
929
+
930
+ const installRuntime = (ctx: ExtensionContext) => {
931
+ if (runtimeCleaned) {
932
+ throw new Error("Cannot restart a cleaned pi-subagents extension runtime; register a new extension instance.");
933
+ }
934
+ const sessionManager = ctx.sessionManager as object;
935
+ const previousRuntime = runtimeRegistry.bySessionManager.get(sessionManager);
936
+ const existingVisibleControlNotices = runtimeRegistry.visibleControlNoticesBySessionManager.get(sessionManager);
937
+ if (existingVisibleControlNotices) {
938
+ visibleControlNotices = existingVisibleControlNotices;
939
+ } else {
940
+ runtimeRegistry.visibleControlNoticesBySessionManager.set(sessionManager, visibleControlNotices);
941
+ }
942
+ runtimeEntry.visibleControlNotices = visibleControlNotices;
943
+ if (runtimeEntry.sessionManager && runtimeEntry.sessionManager !== sessionManager
944
+ && runtimeRegistry.bySessionManager.get(runtimeEntry.sessionManager) === runtimeEntry) {
945
+ runtimeRegistry.bySessionManager.delete(runtimeEntry.sessionManager);
946
+ }
947
+ runtimeEntry.sessionManager = sessionManager;
948
+ runtimeRegistry.bySessionManager.set(sessionManager, runtimeEntry);
949
+ runtimeRegistry.activeEntries.add(runtimeEntry);
950
+ if (previousRuntime && previousRuntime !== runtimeEntry) {
951
+ try {
952
+ previousRuntime.cleanup();
953
+ } catch {
954
+ // Best effort cleanup for stale resources from the replaced runtime.
955
+ }
956
+ }
957
+ };
958
+
843
959
  pi.on("agent_start", () => {
844
960
  resumeWidgetsAfterCompaction();
845
961
  herdrStatusBridge.agentStarted();
@@ -867,6 +983,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
867
983
  });
868
984
 
869
985
  pi.on("session_start", (event, ctx) => {
986
+ installRuntime(ctx);
870
987
  const recovering = event.reason === "startup" || event.reason === "reload" || event.reason === "resume";
871
988
  resetSessionState(ctx, recovering);
872
989
  herdrStatusBridge.sessionStarted({
@@ -875,50 +992,11 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
875
992
  });
876
993
  rpcBridge.emitReady(ctx);
877
994
  supervisorChannel.start();
995
+ supervisorChannel.activateTransport();
878
996
  });
879
997
 
880
998
  pi.on("session_shutdown", async () => {
881
- state.widgetsSuspended = false;
882
- clearTimeout(resultIndexCleanupTimer);
883
- stopResultWatcher();
884
- state.currentSessionId = null;
885
- state.parentSessionFile = null;
886
- completionNotifier.dispose();
887
- delete process.env[SUBAGENT_PARENT_SESSION_ENV];
888
- for (const unsubscribe of eventUnsubscribes) {
889
- try {
890
- unsubscribe();
891
- } catch {
892
- // Best effort cleanup during shutdown.
893
- }
894
- }
895
- if (globalStore[eventUnsubscribeStoreKey] === eventUnsubscribes) {
896
- delete globalStore[eventUnsubscribeStoreKey];
897
- }
898
- scheduledRunManager.stop();
899
- disposeAsyncJobTracker();
900
- for (const timer of state.cleanupTimers.values()) {
901
- clearTimeout(timer);
902
- }
903
- state.cleanupTimers.clear();
904
- state.asyncJobs.clear();
905
- clearSlashSnapshots();
906
- slashBridge.cancelAll();
907
- slashBridge.dispose();
908
- promptTemplateBridge.cancelAll();
909
- promptTemplateBridge.dispose();
910
- supervisorChannel.dispose();
911
- fleetStatus?.dispose();
912
- if (globalStore[runtimeCleanupStoreKey] === runtimeCleanup) {
913
- delete globalStore[runtimeCleanupStoreKey];
914
- }
915
- try {
916
- if (state.lastUiContext?.hasUI) {
917
- state.lastUiContext.ui.setWidget(WIDGET_KEY, undefined);
918
- }
919
- } catch (error) {
920
- if (!isStaleExtensionContextError(error)) throw error;
921
- }
999
+ runtimeEntry.cleanup();
922
1000
  await herdrStatusBridge.flush();
923
1001
  });
924
1002
  }