pi-subagents 0.49.0 → 0.51.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 (117) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/agents/gpt-pro.md +17 -0
  3. package/agents/oracle.md +7 -5
  4. package/agents/researcher.md +1 -1
  5. package/agents/reviewer.md +2 -2
  6. package/agents/scout.md +1 -1
  7. package/agents/worker.md +1 -1
  8. package/async-retention-discovery-worker.mjs +180 -0
  9. package/docs/agents.md +37 -2
  10. package/docs/configuration.md +76 -14
  11. package/docs/extension-api.md +78 -1
  12. package/docs/missions.md +1 -1
  13. package/docs/observability.md +20 -4
  14. package/docs/tool-reference.md +55 -39
  15. package/docs/workflows.md +171 -5
  16. package/package.json +4 -2
  17. package/skills/pi-subagents/SKILL.md +5 -4
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +9 -6
  19. package/skills/pi-subagents/references/execution-controls.md +22 -18
  20. package/skills/pi-subagents/references/management-authoring-rpc.md +5 -5
  21. package/skills/pi-subagents/references/prompting-and-roles.md +33 -17
  22. package/src/agents/agent-management.ts +100 -345
  23. package/src/agents/agent-serializer.ts +2 -0
  24. package/src/agents/agents.ts +135 -25
  25. package/src/api/external-job-provider.ts +185 -0
  26. package/src/api/external-runs.ts +174 -84
  27. package/src/api/preflight.ts +42 -11
  28. package/src/api/shared-types.ts +2 -0
  29. package/src/extension/config.ts +36 -3
  30. package/src/extension/doctor.ts +3 -6
  31. package/src/extension/fanout-child.ts +2 -2
  32. package/src/extension/index.ts +210 -90
  33. package/src/extension/public-execution.ts +31 -2
  34. package/src/extension/rpc.ts +5 -1
  35. package/src/extension/schemas.ts +14 -36
  36. package/src/extension/tool-description.ts +37 -24
  37. package/src/inspectors/herdr/actions.ts +5 -9
  38. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  39. package/src/inspectors/herdr/project-panes.ts +4 -8
  40. package/src/inspectors/herdr/shell-command.ts +16 -0
  41. package/src/intercom/intercom-bridge.ts +2 -3
  42. package/src/intercom/native-supervisor-channel.ts +49 -51
  43. package/src/missions/goal-driver.ts +3 -1
  44. package/src/missions/lifecycle.ts +6 -1
  45. package/src/missions/store.ts +4 -9
  46. package/src/profiles/profiles.ts +3 -1
  47. package/src/runs/background/active-run-index.ts +94 -1
  48. package/src/runs/background/async-execution.ts +98 -38
  49. package/src/runs/background/async-job-tracker.ts +21 -4
  50. package/src/runs/background/async-resume.ts +30 -17
  51. package/src/runs/background/async-retention.ts +888 -0
  52. package/src/runs/background/async-status-snapshot.ts +277 -0
  53. package/src/runs/background/async-status.ts +47 -56
  54. package/src/runs/background/chain-append.ts +3 -33
  55. package/src/runs/background/chain-root-attachment.ts +2 -2
  56. package/src/runs/background/completion-replay.ts +11 -1
  57. package/src/runs/background/control-channel.ts +14 -68
  58. package/src/runs/background/fleet-view.ts +3 -1
  59. package/src/runs/background/index-segment.ts +59 -0
  60. package/src/runs/background/notify.ts +3 -1
  61. package/src/runs/background/result-files.ts +505 -0
  62. package/src/runs/background/result-watcher.ts +250 -51
  63. package/src/runs/background/retained-children.ts +79 -20
  64. package/src/runs/background/run-id-query.ts +7 -0
  65. package/src/runs/background/run-id-resolver.ts +37 -29
  66. package/src/runs/background/run-status.ts +34 -20
  67. package/src/runs/background/scheduled-runs.ts +71 -27
  68. package/src/runs/background/stale-run-reconciler.ts +33 -16
  69. package/src/runs/background/steering.ts +11 -1
  70. package/src/runs/background/subagent-runner.ts +535 -161
  71. package/src/runs/background/subagent-wait.ts +9 -7
  72. package/src/runs/background/terminal-run-index.ts +129 -0
  73. package/src/runs/background/wait-completions.ts +22 -2
  74. package/src/runs/background/wait-subscriptions.ts +80 -1
  75. package/src/runs/foreground/async-dismiss-action.ts +2 -1
  76. package/src/runs/foreground/async-steering-action.ts +21 -14
  77. package/src/runs/foreground/execution.ts +221 -15
  78. package/src/runs/foreground/subagent-executor.ts +529 -1519
  79. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  80. package/src/runs/shared/chain-outputs.ts +1 -3
  81. package/src/runs/shared/completion-guard.ts +96 -6
  82. package/src/runs/shared/external-cli-runner.ts +4 -0
  83. package/src/runs/shared/external-job-bridge.ts +450 -0
  84. package/src/runs/shared/external-job-runner.ts +286 -0
  85. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  86. package/src/runs/shared/model-fallback.ts +34 -3
  87. package/src/runs/shared/nested-events.ts +66 -62
  88. package/src/runs/shared/orca-progress-tabs.ts +437 -0
  89. package/src/runs/shared/parallel-handoff.ts +46 -4
  90. package/src/runs/shared/parallel-utils.ts +6 -15
  91. package/src/runs/shared/permissions.ts +5 -1
  92. package/src/runs/shared/pi-args.ts +8 -1
  93. package/src/runs/shared/subagent-control.ts +41 -4
  94. package/src/runs/shared/subagent-prompt-runtime.ts +13 -15
  95. package/src/runs/shared/subagent-startup-retry.ts +12 -0
  96. package/src/runs/shared/tool-timeout.ts +93 -0
  97. package/src/runs/shared/workflow-graph.ts +1 -23
  98. package/src/runs/shared/worktree.ts +12 -1
  99. package/src/shared/atomic-json.ts +22 -2
  100. package/src/shared/capacity-resilient-json.ts +102 -0
  101. package/src/shared/completion-owner.ts +14 -0
  102. package/src/shared/file-system-retry.ts +49 -1
  103. package/src/shared/fork-context.ts +42 -0
  104. package/src/shared/prompt-resources.ts +0 -40
  105. package/src/shared/settings.ts +3 -27
  106. package/src/shared/types.ts +88 -27
  107. package/src/shared/utils.ts +8 -0
  108. package/src/shared/watch-strategy.ts +10 -0
  109. package/src/slash/slash-commands.ts +45 -28
  110. package/src/slash/slash-live-state.ts +3 -0
  111. package/src/tui/fleet-status.ts +160 -45
  112. package/src/tui/fleet.ts +186 -28
  113. package/src/tui/render.ts +41 -10
  114. package/src/workflows/chat-progress.ts +7 -5
  115. package/src/workflows/scripted-workflow.ts +424 -125
  116. package/src/runs/foreground/chain-clarify.ts +0 -1354
  117. package/src/runs/foreground/chain-execution.ts +0 -1565
@@ -0,0 +1,286 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { ExternalJobProviderError, type ExternalJobHandle, type ExternalJobResult, type ExternalJobState } from "../../api/external-job-provider.ts";
5
+ import type { ExternalJobStatus } from "../../shared/types.ts";
6
+ import { requestExternalJobOperation } from "./external-job-bridge.ts";
7
+
8
+ const STATUS_POLL_INTERVAL_MS = 1_000;
9
+
10
+ export interface ExternalJobRunResult {
11
+ output: string;
12
+ exitCode: number;
13
+ error?: string;
14
+ timedOut?: boolean;
15
+ stopped?: boolean;
16
+ externalJob: ExternalJobStatus;
17
+ }
18
+
19
+ export function externalJobPromptDigest(prompt: string): string {
20
+ return createHash("sha256").update(prompt).digest("hex");
21
+ }
22
+
23
+ function sleep(ms: number): Promise<void> {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
26
+
27
+ function terminal(state: ExternalJobState): boolean {
28
+ return state === "completed" || state === "failed" || state === "stopped" || state === "blocked";
29
+ }
30
+
31
+ function formatError(error: ExternalJobProviderError, provider: string): string {
32
+ const blocking = error.blockingJobId ? ` Blocking provider job: ${error.blockingJobId}.` : "";
33
+ return `External-job provider '${provider}' failed closed (${error.code}): ${error.message}.${blocking}`;
34
+ }
35
+
36
+ function isNotFound(error: unknown): boolean {
37
+ return typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT";
38
+ }
39
+
40
+ function isRecord(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+
44
+ function malformedStatus(statusPath: string): ExternalJobProviderError {
45
+ return new ExternalJobProviderError(`Malformed external-job status '${statusPath}'. Refusing to redispatch the prompt.`, { code: "status-unreadable" });
46
+ }
47
+
48
+ function parseExternalJobStatus(value: unknown, statusPath: string): ExternalJobStatus {
49
+ if (!isRecord(value)) throw malformedStatus(statusPath);
50
+ const provider = value.provider;
51
+ const promptDigest = value.promptDigest;
52
+ const state = value.state;
53
+ if (typeof provider !== "string" || provider.length === 0) throw malformedStatus(statusPath);
54
+ if (typeof promptDigest !== "string" || promptDigest.length === 0) throw malformedStatus(statusPath);
55
+ if (state !== "queued" && state !== "running" && state !== "completed" && state !== "failed" && state !== "stopped" && state !== "blocked") throw malformedStatus(statusPath);
56
+ if (value.options !== undefined && !isRecord(value.options)) throw malformedStatus(statusPath);
57
+ const providerJobId = value.providerJobId;
58
+ const handleUrl = value.handleUrl;
59
+ const conversationUrl = value.conversationUrl;
60
+ const resultArtifactPath = value.resultArtifactPath;
61
+ const failureCode = value.failureCode;
62
+ const failureMessage = value.failureMessage;
63
+ const blockingJobId = value.blockingJobId;
64
+ const startedAt = value.startedAt;
65
+ const updatedAt = value.updatedAt;
66
+ if (providerJobId !== undefined && (typeof providerJobId !== "string" || providerJobId.length === 0)) throw malformedStatus(statusPath);
67
+ if (handleUrl !== undefined && typeof handleUrl !== "string") throw malformedStatus(statusPath);
68
+ if (conversationUrl !== undefined && typeof conversationUrl !== "string") throw malformedStatus(statusPath);
69
+ if (resultArtifactPath !== undefined && typeof resultArtifactPath !== "string") throw malformedStatus(statusPath);
70
+ if (failureCode !== undefined && typeof failureCode !== "string") throw malformedStatus(statusPath);
71
+ if (failureMessage !== undefined && typeof failureMessage !== "string") throw malformedStatus(statusPath);
72
+ if (blockingJobId !== undefined && typeof blockingJobId !== "string") throw malformedStatus(statusPath);
73
+ if (startedAt !== undefined && typeof startedAt !== "number") throw malformedStatus(statusPath);
74
+ if (updatedAt !== undefined && typeof updatedAt !== "number") throw malformedStatus(statusPath);
75
+ return {
76
+ provider,
77
+ promptDigest,
78
+ options: value.options ?? {},
79
+ state,
80
+ ...(providerJobId ? { providerJobId } : {}),
81
+ ...(handleUrl ? { handleUrl } : {}),
82
+ ...(conversationUrl ? { conversationUrl } : {}),
83
+ ...(resultArtifactPath ? { resultArtifactPath } : {}),
84
+ ...(failureCode ? { failureCode } : {}),
85
+ ...(failureMessage ? { failureMessage } : {}),
86
+ ...(blockingJobId ? { blockingJobId } : {}),
87
+ ...(startedAt !== undefined ? { startedAt } : {}),
88
+ ...(updatedAt !== undefined ? { updatedAt } : {}),
89
+ };
90
+ }
91
+
92
+ function readExistingExternalJob(asyncDir: string, stepIndex: number): ExternalJobStatus | undefined {
93
+ const statusPath = path.join(asyncDir, "status.json");
94
+ let raw: string;
95
+ try {
96
+ raw = fs.readFileSync(statusPath, "utf-8");
97
+ } catch (error) {
98
+ if (isNotFound(error)) return undefined;
99
+ throw new ExternalJobProviderError(`Unreadable external-job status '${statusPath}'. Refusing to redispatch the prompt.`, { code: "status-unreadable", cause: error });
100
+ }
101
+ let parsed: unknown;
102
+ try {
103
+ parsed = JSON.parse(raw);
104
+ } catch (error) {
105
+ throw new ExternalJobProviderError(`Malformed external-job status '${statusPath}'. Refusing to redispatch the prompt.`, { code: "status-unreadable", cause: error });
106
+ }
107
+ if (!isRecord(parsed) || !Array.isArray(parsed.steps)) throw malformedStatus(statusPath);
108
+ const step = parsed.steps[stepIndex];
109
+ if (step === undefined || !isRecord(step)) throw malformedStatus(statusPath);
110
+ if (step.externalJob === undefined) return undefined;
111
+ return parseExternalJobStatus(step.externalJob, statusPath);
112
+ }
113
+
114
+ function statusFromHandle(input: {
115
+ provider: string;
116
+ promptDigest: string;
117
+ options: Record<string, unknown>;
118
+ startedAt?: number;
119
+ previous?: ExternalJobStatus;
120
+ handle: ExternalJobHandle;
121
+ resultArtifactPath?: string;
122
+ }): ExternalJobStatus {
123
+ return {
124
+ provider: input.provider,
125
+ providerJobId: input.handle.providerJobId,
126
+ promptDigest: input.promptDigest,
127
+ options: input.options,
128
+ handleUrl: input.handle.handleUrl ?? input.previous?.handleUrl,
129
+ conversationUrl: input.handle.conversationUrl ?? input.previous?.conversationUrl,
130
+ resultArtifactPath: input.resultArtifactPath ?? input.previous?.resultArtifactPath,
131
+ state: input.handle.state,
132
+ failureCode: input.handle.failureCode,
133
+ failureMessage: input.handle.failureMessage,
134
+ blockingJobId: input.handle.blockingJobId,
135
+ startedAt: input.previous?.startedAt ?? input.startedAt,
136
+ updatedAt: Date.now(),
137
+ };
138
+ }
139
+
140
+ function failureStatus(input: {
141
+ provider: string;
142
+ promptDigest: string;
143
+ options: Record<string, unknown>;
144
+ previous?: ExternalJobStatus;
145
+ code: string;
146
+ message: string;
147
+ blockingJobId?: string;
148
+ }): ExternalJobStatus {
149
+ return {
150
+ provider: input.provider,
151
+ providerJobId: input.previous?.providerJobId,
152
+ promptDigest: input.promptDigest,
153
+ options: input.options,
154
+ handleUrl: input.previous?.handleUrl,
155
+ conversationUrl: input.previous?.conversationUrl,
156
+ resultArtifactPath: input.previous?.resultArtifactPath,
157
+ state: input.blockingJobId ? "blocked" : "failed",
158
+ failureCode: input.code,
159
+ failureMessage: input.message,
160
+ blockingJobId: input.blockingJobId,
161
+ startedAt: input.previous?.startedAt,
162
+ updatedAt: Date.now(),
163
+ };
164
+ }
165
+
166
+ function resultOutput(result: ExternalJobResult, artifactPath: string | undefined): string {
167
+ if (result.output?.trim()) return result.output.trim();
168
+ if (artifactPath) return `External job finished. Result artifact: ${artifactPath}`;
169
+ return "External job finished without text output.";
170
+ }
171
+
172
+ function blocksStartRedispatch(status: ExternalJobStatus, provider: string, promptDigest: string): boolean {
173
+ return status.provider === provider
174
+ && status.promptDigest === promptDigest
175
+ && !status.providerJobId
176
+ && status.failureCode !== "provider-unavailable";
177
+ }
178
+
179
+ export async function runExternalJob(input: {
180
+ provider: string;
181
+ options?: Record<string, unknown>;
182
+ cwd: string;
183
+ prompt: string;
184
+ asyncDir: string;
185
+ stepIndex: number;
186
+ runId: string;
187
+ agent: string;
188
+ sessionId?: string;
189
+ registerTimeout?: (stop: (() => void) | undefined) => void;
190
+ registerStop?: (stop: (() => void) | undefined) => void;
191
+ timeoutMessage?: string;
192
+ stopMessage?: string;
193
+ onExternalJob?: (status: ExternalJobStatus) => void;
194
+ }): Promise<ExternalJobRunResult> {
195
+ const provider = input.provider;
196
+ const options = input.options ?? {};
197
+ const promptDigest = externalJobPromptDigest(input.prompt);
198
+ const startedAt = Date.now();
199
+ let timedOut = false;
200
+ let stopped = false;
201
+ let current: ExternalJobStatus | undefined;
202
+ const timeout = () => { timedOut = true; };
203
+ const stop = () => { stopped = true; };
204
+ input.registerTimeout?.(timeout);
205
+ input.registerStop?.(stop);
206
+ const publish = (status: ExternalJobStatus) => {
207
+ current = status;
208
+ input.onExternalJob?.(status);
209
+ };
210
+ const localCancellation = () => {
211
+ if (!timedOut && !stopped) return undefined;
212
+ const message = stopped
213
+ ? input.stopMessage ?? `Subagent stopped locally before external provider '${provider}' returned a job id. The provider start may still be running.`
214
+ : input.timeoutMessage ?? `Subagent timed out locally before external provider '${provider}' returned a job id. The provider start may still be running.`;
215
+ return new ExternalJobProviderError(message, { code: stopped ? "local-stop" : "local-timeout" });
216
+ };
217
+ try {
218
+ current = readExistingExternalJob(input.asyncDir, input.stepIndex);
219
+ let handle: ExternalJobHandle;
220
+ if (current?.providerJobId) {
221
+ if (current.provider !== provider || current.promptDigest !== promptDigest) {
222
+ const message = `Existing external job '${current.providerJobId}' does not match provider or prompt digest. Refusing to redispatch prompt.`;
223
+ const status = failureStatus({ provider, promptDigest, options, previous: current, code: "recovery-mismatch", message });
224
+ publish(status);
225
+ return { output: message, exitCode: 1, error: message, externalJob: status };
226
+ }
227
+ handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, { operation: "reattach", provider, providerJobId: current.providerJobId });
228
+ } else {
229
+ if (current && blocksStartRedispatch(current, provider, promptDigest)) {
230
+ const message = `External-job start for provider '${provider}' previously ended without a durable provider job id. Refusing to redispatch the prompt automatically.`;
231
+ const status = failureStatus({ provider, promptDigest, options, previous: current, code: "start-redispatch-blocked", message });
232
+ publish(status);
233
+ return { output: message, exitCode: 1, error: message, externalJob: status };
234
+ }
235
+ publish({ provider, promptDigest, options, state: "queued", startedAt, updatedAt: Date.now() });
236
+ handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, {
237
+ operation: "start",
238
+ provider,
239
+ start: {
240
+ prompt: input.prompt,
241
+ promptDigest,
242
+ cwd: input.cwd,
243
+ runId: input.runId,
244
+ stepIndex: input.stepIndex,
245
+ agent: input.agent,
246
+ options,
247
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
248
+ },
249
+ }, undefined, localCancellation);
250
+ }
251
+ publish(statusFromHandle({ provider, promptDigest, options, startedAt, previous: current, handle }));
252
+ while (!terminal(handle.state)) {
253
+ if (timedOut || stopped) {
254
+ const message = stopped
255
+ ? input.stopMessage ?? `Subagent stopped locally; external provider job '${handle.providerJobId}' may still be running.`
256
+ : input.timeoutMessage ?? `Subagent timed out locally; external provider job '${handle.providerJobId}' may still be running.`;
257
+ return { output: message, exitCode: 1, error: message, ...(timedOut ? { timedOut: true } : {}), ...(stopped ? { stopped: true } : {}), externalJob: current! };
258
+ }
259
+ await sleep(STATUS_POLL_INTERVAL_MS);
260
+ handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, { operation: "status", provider, providerJobId: handle.providerJobId });
261
+ publish(statusFromHandle({ provider, promptDigest, options, previous: current, handle }));
262
+ }
263
+ const result = await requestExternalJobOperation<ExternalJobResult>(input.asyncDir, { operation: "result", provider, providerJobId: handle.providerJobId });
264
+ let artifactPath = result.artifactPath;
265
+ if (!artifactPath && result.output !== undefined) {
266
+ artifactPath = path.join(input.asyncDir, `external-job-${input.stepIndex}.result.md`);
267
+ fs.writeFileSync(artifactPath, result.output, "utf-8");
268
+ }
269
+ const finalStatus = statusFromHandle({ provider, promptDigest, options, previous: current, handle: result, resultArtifactPath: artifactPath });
270
+ publish(finalStatus);
271
+ const output = resultOutput(result, artifactPath);
272
+ const error = result.state === "completed" ? undefined : result.failureMessage ?? `External job ${result.state}.`;
273
+ return { output, exitCode: result.state === "completed" ? 0 : 1, ...(error ? { error } : {}), externalJob: finalStatus };
274
+ } catch (error) {
275
+ const providerError = error instanceof ExternalJobProviderError
276
+ ? error
277
+ : new ExternalJobProviderError(error instanceof Error ? error.message : String(error), { code: "provider-error", cause: error });
278
+ const message = formatError(providerError, provider);
279
+ const status = failureStatus({ provider, promptDigest, options, previous: current, code: providerError.code, message, ...(providerError.blockingJobId ? { blockingJobId: providerError.blockingJobId } : {}) });
280
+ publish(status);
281
+ return { output: message, exitCode: 1, error: message, ...(timedOut ? { timedOut: true } : {}), ...(stopped ? { stopped: true } : {}), externalJob: status };
282
+ } finally {
283
+ input.registerTimeout?.(undefined);
284
+ input.registerStop?.(undefined);
285
+ }
286
+ }
@@ -32,6 +32,12 @@ interface ServerEntry {
32
32
  cwd?: string;
33
33
  url?: string;
34
34
  headers?: Record<string, string>;
35
+ requestHeadersCommand?: {
36
+ command: string;
37
+ args?: string[];
38
+ env?: Record<string, string>;
39
+ timeoutMs?: number;
40
+ };
35
41
  auth?: "oauth" | "bearer" | false;
36
42
  bearerToken?: string;
37
43
  bearerTokenEnv?: string;
@@ -279,6 +285,14 @@ export function computeMcpServerHash(definition: ServerEntry): string {
279
285
  cwd: resolveConfigPath(definition.cwd),
280
286
  url: resolveServerUrl(definition),
281
287
  headers: interpolateEnvRecord(definition.headers),
288
+ requestHeadersCommand: definition.requestHeadersCommand
289
+ ? {
290
+ command: interpolateEnvVars(definition.requestHeadersCommand.command),
291
+ args: definition.requestHeadersCommand.args?.map(interpolateEnvVars),
292
+ env: interpolateEnvRecord(definition.requestHeadersCommand.env),
293
+ timeoutMs: definition.requestHeadersCommand.timeoutMs,
294
+ }
295
+ : undefined,
282
296
  auth: definition.auth,
283
297
  bearerToken: resolveBearerToken(definition),
284
298
  bearerTokenEnv: definition.bearerTokenEnv,
@@ -163,6 +163,29 @@ export function resolveModelCandidate(
163
163
  return model;
164
164
  }
165
165
 
166
+ function resolveSubagentModelCandidate(
167
+ model: string,
168
+ availableModels: AvailableModelInfo[] | undefined,
169
+ preferredProvider?: string,
170
+ ): string | undefined {
171
+ if (!availableModels || availableModels.length === 0) return model;
172
+ const resolvedWhole = resolveBaseModelCandidate(model, availableModels, preferredProvider);
173
+ if (resolvedWhole) return resolvedWhole;
174
+ const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
175
+ const resolvedBase = thinkingSuffix ? resolveBaseModelCandidate(baseModel, availableModels, preferredProvider) : undefined;
176
+ return resolvedBase ? `${resolvedBase}${thinkingSuffix}` : undefined;
177
+ }
178
+
179
+ function resolveRequiredSubagentModelCandidate(
180
+ model: string,
181
+ availableModels: AvailableModelInfo[] | undefined,
182
+ preferredProvider?: string,
183
+ ): string {
184
+ const resolved = resolveSubagentModelCandidate(model, availableModels, preferredProvider);
185
+ if (resolved) return resolved;
186
+ throw new Error(`Unknown subagent model '${model}' in the active Pi model registry.`);
187
+ }
188
+
166
189
  export interface ResolveSubagentModelOverrideOptions {
167
190
  /** When set with `enforce: true`, out-of-scope models are rejected. */
168
191
  scope?: ModelScopeConfig;
@@ -207,7 +230,7 @@ export function resolveSubagentModelOverride(
207
230
  if (explicit === undefined) {
208
231
  resolved = parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
209
232
  } else {
210
- resolved = resolveModelCandidate(explicit, availableModels, preferredProvider);
233
+ resolved = resolveRequiredSubagentModelCandidate(explicit, availableModels, preferredProvider);
211
234
  }
212
235
  if (resolved && options?.scope?.enforce) {
213
236
  const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited");
@@ -264,8 +287,15 @@ export function buildModelCandidates(
264
287
  for (let index = 0; index < rawCandidates.length; index++) {
265
288
  const raw = rawCandidates[index];
266
289
  if (!raw) continue;
267
- const normalized = resolveModelCandidate(raw.trim(), availableModels, preferredProvider);
268
- if (!normalized || seen.has(normalized)) continue;
290
+ const model = raw.trim();
291
+ const normalized = index === 0
292
+ ? resolveRequiredSubagentModelCandidate(model, availableModels, preferredProvider)
293
+ : resolveSubagentModelCandidate(model, availableModels, preferredProvider);
294
+ if (!normalized) {
295
+ console.warn(`[pi-subagents] Skipping fallback model '${model}' because it is unavailable in this environment.`);
296
+ continue;
297
+ }
298
+ if (seen.has(normalized)) continue;
269
299
  if ((index > 0 || options?.scope?.strict === true) && options?.scope?.enforce) {
270
300
  const violation = checkModelScope(normalized, options.scope, "inherited");
271
301
  if (violation) {
@@ -281,6 +311,7 @@ export function buildModelCandidates(
281
311
 
282
312
  const RETRYABLE_MODEL_FAILURE_PATTERNS = [
283
313
  /rate\s*limit/i,
314
+ /usage\s*limit/i,
284
315
  /too many requests/i,
285
316
  /\b429\b/,
286
317
  /quota/i,
@@ -34,6 +34,7 @@ import { THINKING_LEVELS } from "../../shared/model-info.ts";
34
34
  export const NESTED_EVENTS_DIR = path.join(TEMP_ROOT_DIR, "nested-subagent-events");
35
35
  const ROUTE_FILE = "route.json";
36
36
  const REGISTRY_FILE = "registry.json";
37
+ const ROUTE_INDEX_DIR = ".route-index";
37
38
  const MAX_EVENT_BYTES = 64 * 1024;
38
39
  const MAX_STEPS = 12;
39
40
  const MAX_CHILDREN = 16;
@@ -105,6 +106,18 @@ function commonRouteRoot(route: Pick<NestedRoute, "eventSink" | "controlInbox">)
105
106
  return path.dirname(path.resolve(route.eventSink));
106
107
  }
107
108
 
109
+ function routeIndexRoot(): string {
110
+ return path.join(NESTED_EVENTS_DIR, ROUTE_INDEX_DIR, "roots");
111
+ }
112
+
113
+ function routeIndexDir(rootRunId: string): string {
114
+ return path.join(routeIndexRoot(), encodeURIComponent(rootRunId));
115
+ }
116
+
117
+ function routeIndexPath(rootRunId: string, capabilityToken: string): string {
118
+ return path.join(routeIndexDir(rootRunId), `${encodeURIComponent(capabilityToken)}.json`);
119
+ }
120
+
108
121
  function validateRouteShape(route: NestedRoute): void {
109
122
  assertSafeId("rootRunId", route.rootRunId);
110
123
  assertSafeId("capabilityToken", route.capabilityToken);
@@ -121,7 +134,10 @@ export function createNestedRoute(rootRunId: string): NestedRoute {
121
134
  const controlInbox = path.join(routeRoot, "controls");
122
135
  fs.mkdirSync(eventSink, { recursive: true, mode: 0o700 });
123
136
  fs.mkdirSync(controlInbox, { recursive: true, mode: 0o700 });
124
- fs.writeFileSync(path.join(routeRoot, ROUTE_FILE), `${JSON.stringify({ rootRunId, capabilityToken, createdAt: Date.now() })}\n`, { mode: 0o600 });
137
+ const createdAt = Date.now();
138
+ fs.mkdirSync(routeIndexDir(rootRunId), { recursive: true, mode: 0o700 });
139
+ writeAtomicJson(routeIndexPath(rootRunId, capabilityToken), { rootRunId, capabilityToken, routeRoot: path.basename(routeRoot), createdAt });
140
+ writeAtomicJson(path.join(routeRoot, ROUTE_FILE), { rootRunId, capabilityToken, createdAt });
125
141
  return { rootRunId, eventSink, controlInbox, capabilityToken };
126
142
  }
127
143
 
@@ -515,68 +531,57 @@ function registryPath(route: NestedRoute): string {
515
531
  return path.join(commonRouteRoot(route), REGISTRY_FILE);
516
532
  }
517
533
 
534
+ function routeFromIndexEntry(rootRunId: string, filePath: string): NestedRoute | undefined {
535
+ try {
536
+ const metadata = JSON.parse(fs.readFileSync(filePath, "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown; routeRoot?: unknown; createdAt?: unknown };
537
+ if (metadata.rootRunId !== rootRunId || typeof metadata.capabilityToken !== "string" || typeof metadata.routeRoot !== "string") return undefined;
538
+ const routeRoot = path.join(NESTED_EVENTS_DIR, path.basename(metadata.routeRoot));
539
+ const route = {
540
+ rootRunId,
541
+ eventSink: path.join(routeRoot, "events"),
542
+ controlInbox: path.join(routeRoot, "controls"),
543
+ capabilityToken: metadata.capabilityToken,
544
+ };
545
+ validateRouteShape(route);
546
+ if (!fs.statSync(route.eventSink).isDirectory() || !fs.statSync(route.controlInbox).isDirectory()) return undefined;
547
+ const routeFilePath = path.join(routeRoot, ROUTE_FILE);
548
+ try {
549
+ const routeFile = JSON.parse(fs.readFileSync(routeFilePath, "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
550
+ if (routeFile.rootRunId !== rootRunId || routeFile.capabilityToken !== metadata.capabilityToken) return undefined;
551
+ } catch (error) {
552
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return undefined;
553
+ try {
554
+ writeAtomicJson(routeFilePath, { rootRunId, capabilityToken: metadata.capabilityToken, createdAt: typeof metadata.createdAt === "number" ? metadata.createdAt : Date.now() });
555
+ } catch {
556
+ // The route index already carries enough data for reload lookup.
557
+ }
558
+ }
559
+ return route;
560
+ } catch {
561
+ return undefined;
562
+ }
563
+ }
564
+
518
565
  export function findNestedRouteForRootId(rootRunId: string): NestedRoute | undefined {
519
566
  assertSafeId("rootRunId", rootRunId);
520
567
  let entries: string[];
521
568
  try {
522
- entries = fs.readdirSync(NESTED_EVENTS_DIR);
569
+ entries = fs.readdirSync(routeIndexDir(rootRunId));
523
570
  } catch (error) {
524
571
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
525
572
  throw error;
526
573
  }
527
574
  for (const entry of entries) {
528
- if (!entry.startsWith(`${rootRunId}-`)) continue;
529
- const routeRoot = path.join(NESTED_EVENTS_DIR, entry);
530
- try {
531
- const metadata = JSON.parse(fs.readFileSync(path.join(routeRoot, ROUTE_FILE), "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
532
- if (metadata.rootRunId !== rootRunId || typeof metadata.capabilityToken !== "string") continue;
533
- const route = {
534
- rootRunId,
535
- eventSink: path.join(routeRoot, "events"),
536
- controlInbox: path.join(routeRoot, "controls"),
537
- capabilityToken: metadata.capabilityToken,
538
- };
539
- validateRouteShape(route);
540
- return route;
541
- } catch {
542
- continue;
543
- }
575
+ const route = routeFromIndexEntry(rootRunId, path.join(routeIndexDir(rootRunId), entry));
576
+ if (route) return route;
544
577
  }
545
578
  return undefined;
546
579
  }
547
580
 
548
- /**
549
- * Scan the nested-events directory once and index every route by its root run
550
- * id. Use this when resolving routes for many runs (e.g. listAsyncRuns) so the
551
- * cost is O(routes) total instead of O(runs * routes) from calling
552
- * findNestedRouteForRootId per run.
553
- */
554
581
  export function buildNestedRouteIndex(): Map<string, NestedRoute> {
555
- let entries: string[];
556
- try {
557
- entries = fs.readdirSync(NESTED_EVENTS_DIR);
558
- } catch (error) {
559
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Map();
560
- throw error;
561
- }
562
582
  const index = new Map<string, NestedRoute>();
563
- for (const entry of entries) {
564
- const routeRoot = path.join(NESTED_EVENTS_DIR, entry);
565
- try {
566
- const metadata = JSON.parse(fs.readFileSync(path.join(routeRoot, ROUTE_FILE), "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
567
- if (typeof metadata.rootRunId !== "string" || typeof metadata.capabilityToken !== "string") continue;
568
- if (index.has(metadata.rootRunId)) continue;
569
- const route: NestedRoute = {
570
- rootRunId: metadata.rootRunId,
571
- eventSink: path.join(routeRoot, "events"),
572
- controlInbox: path.join(routeRoot, "controls"),
573
- capabilityToken: metadata.capabilityToken,
574
- };
575
- validateRouteShape(route);
576
- index.set(metadata.rootRunId, route);
577
- } catch {
578
- continue;
579
- }
583
+ for (const route of listNestedRoutes()) {
584
+ if (!index.has(route.rootRunId)) index.set(route.rootRunId, route);
580
585
  }
581
586
  return index;
582
587
  }
@@ -630,27 +635,26 @@ function collectScopedNestedRuns(children: NestedRunSummary[] | undefined, scope
630
635
  }
631
636
 
632
637
  function listNestedRoutes(): NestedRoute[] {
633
- let entries: string[];
638
+ let rootEntries: string[];
634
639
  try {
635
- entries = fs.readdirSync(NESTED_EVENTS_DIR);
640
+ rootEntries = fs.readdirSync(routeIndexRoot());
636
641
  } catch (error) {
637
642
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
638
643
  throw error;
639
644
  }
640
645
  const routes: NestedRoute[] = [];
641
- for (const entry of entries) {
642
- const routeRoot = path.join(NESTED_EVENTS_DIR, entry);
646
+ for (const rootEntry of rootEntries) {
647
+ let rootRunId: string;
643
648
  try {
644
- const metadata = JSON.parse(fs.readFileSync(path.join(routeRoot, ROUTE_FILE), "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
645
- if (typeof metadata.rootRunId !== "string" || typeof metadata.capabilityToken !== "string") continue;
646
- const route = {
647
- rootRunId: metadata.rootRunId,
648
- eventSink: path.join(routeRoot, "events"),
649
- controlInbox: path.join(routeRoot, "controls"),
650
- capabilityToken: metadata.capabilityToken,
651
- };
652
- validateRouteShape(route);
653
- routes.push(route);
649
+ rootRunId = decodeURIComponent(rootEntry);
650
+ } catch {
651
+ continue;
652
+ }
653
+ try {
654
+ for (const entry of fs.readdirSync(path.join(routeIndexRoot(), rootEntry))) {
655
+ const route = routeFromIndexEntry(rootRunId, path.join(routeIndexRoot(), rootEntry, entry));
656
+ if (route) routes.push(route);
657
+ }
654
658
  } catch {
655
659
  continue;
656
660
  }