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
@@ -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,
@@ -41,7 +41,7 @@ export function normalizeParentModel(model: unknown): ParentModel | undefined {
41
41
  /**
42
42
  * Normalize a model id or provider segment for fuzzy comparison: case-fold,
43
43
  * treat dots/underscores as dashes (so `4.5` matches `4-5`), and collapse
44
- * repeated separators. Pure.
44
+ * repeated separators.
45
45
  */
46
46
  export function normalizeModelSegment(segment: string): string {
47
47
  return segment
@@ -58,7 +58,7 @@ function isPlausibleDateStamp(year: string, month: string, day: string): boolean
58
58
  return yyyy >= 1900 && yyyy <= 2099 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31;
59
59
  }
60
60
 
61
- /** Drop a trailing date stamp (`-20251001` or `-2025-10-01`) so dated and undated ids match. Pure. */
61
+ /** Drop a trailing date stamp (`-20251001` or `-2025-10-01`) so dated and undated ids match. */
62
62
  function stripTrailingDateStamp(segment: string): string {
63
63
  const dashed = /^(.*)-(\d{4})-(\d{2})-(\d{2})$/.exec(segment);
64
64
  if (dashed && isPlausibleDateStamp(dashed[2]!, dashed[3]!, dashed[4]!)) return dashed[1]!;
@@ -67,21 +67,65 @@ function stripTrailingDateStamp(segment: string): string {
67
67
  return segment;
68
68
  }
69
69
 
70
+ function isRegisteredProvider(provider: string, availableModels: AvailableModelInfo[]): boolean {
71
+ const normalized = normalizeModelSegment(provider);
72
+ return availableModels.some((entry) => normalizeModelSegment(entry.provider) === normalized);
73
+ }
74
+
75
+ /**
76
+ * Split `provider/id` only when the first path segment is a registered provider.
77
+ * Hugging Face-style `owner/name` ids therefore stay intact unless `owner` is
78
+ * itself a provider in the active registry. `:` and `.` keep the same rule.
79
+ */
80
+ function splitQualifiedModelQuery(
81
+ baseModel: string,
82
+ availableModels: AvailableModelInfo[],
83
+ ): { queryProvider?: string; queryIdRaw: string } {
84
+ const slashIdx = baseModel.indexOf("/");
85
+ if (slashIdx !== -1) {
86
+ const providerPart = baseModel.slice(0, slashIdx);
87
+ if (isRegisteredProvider(providerPart, availableModels)) {
88
+ return { queryProvider: normalizeModelSegment(providerPart), queryIdRaw: baseModel.slice(slashIdx + 1) };
89
+ }
90
+ return { queryIdRaw: baseModel };
91
+ }
92
+ const providerSeparators = [":", "."];
93
+ for (const separator of providerSeparators) {
94
+ const separatorIdx = baseModel.indexOf(separator);
95
+ if (separatorIdx <= 0) continue;
96
+ const providerPart = baseModel.slice(0, separatorIdx);
97
+ if (!isRegisteredProvider(providerPart, availableModels)) continue;
98
+ return { queryProvider: normalizeModelSegment(providerPart), queryIdRaw: baseModel.slice(separatorIdx + 1) };
99
+ }
100
+ return { queryIdRaw: baseModel };
101
+ }
102
+
103
+ function resolveExactIdMatches(
104
+ baseModel: string,
105
+ availableModels: AvailableModelInfo[],
106
+ preferredProvider?: string,
107
+ ): string | undefined {
108
+ const exactMatches = availableModels.filter((entry) => entry.id === baseModel);
109
+ if (preferredProvider) {
110
+ const preferredMatch = exactMatches.find((entry) => entry.provider === preferredProvider);
111
+ if (preferredMatch) return preferredMatch.fullId;
112
+ }
113
+ if (exactMatches.length === 1) return exactMatches[0]!.fullId;
114
+ return undefined;
115
+ }
116
+
70
117
  function resolveBaseModelCandidate(
71
118
  baseModel: string,
72
119
  availableModels: AvailableModelInfo[],
73
120
  preferredProvider?: string,
74
121
  ): string | undefined {
75
- if (baseModel.includes("/")) {
76
- const exact = availableModels.find((entry) => entry.fullId === baseModel);
77
- if (exact) return exact.fullId;
78
- } else {
79
- const exactMatches = availableModels.filter((entry) => entry.id === baseModel);
80
- if (preferredProvider) {
81
- const preferredMatch = exactMatches.find((entry) => entry.provider === preferredProvider);
82
- if (preferredMatch) return preferredMatch.fullId;
83
- }
84
- if (exactMatches.length === 1) return exactMatches[0]!.fullId;
122
+ const exact = availableModels.find((entry) => entry.fullId === baseModel);
123
+ if (exact) return exact.fullId;
124
+
125
+ const { queryProvider } = splitQualifiedModelQuery(baseModel, availableModels);
126
+ if (queryProvider === undefined) {
127
+ const exactId = resolveExactIdMatches(baseModel, availableModels, preferredProvider);
128
+ if (exactId) return exactId;
85
129
  }
86
130
 
87
131
  return fuzzyResolveModel(baseModel, availableModels, preferredProvider);
@@ -90,35 +134,20 @@ function resolveBaseModelCandidate(
90
134
  /**
91
135
  * Fuzzy-resolve a base model id (thinking suffix already stripped) against the
92
136
  * registry, tolerating separator, case, and optional date-stamp differences so
93
- * users do not have to spell provider/model exactly. A qualified `provider/id`
137
+ * users do not have to spell provider/model exactly. A slash is a provider
138
+ * prefix only when that prefix is a registered provider; otherwise the whole
139
+ * string is the model id (Hugging Face `owner/name`). A qualified provider
94
140
  * query only matches within the named provider — this never silently switches
95
141
  * providers for security/cost-sensitive configs. Returns the matched `fullId`,
96
142
  * or `undefined` when there is no match or the match is ambiguous across
97
- * providers (and no `preferredProvider` disambiguates). Pure.
143
+ * providers (and no `preferredProvider` disambiguates).
98
144
  */
99
145
  export function fuzzyResolveModel(
100
146
  baseModel: string,
101
147
  availableModels: AvailableModelInfo[],
102
148
  preferredProvider?: string,
103
149
  ): string | undefined {
104
- let queryProvider: string | undefined;
105
- let queryIdRaw = baseModel;
106
- const slashIdx = baseModel.indexOf("/");
107
- if (slashIdx !== -1) {
108
- queryProvider = normalizeModelSegment(baseModel.slice(0, slashIdx));
109
- queryIdRaw = baseModel.slice(slashIdx + 1);
110
- } else {
111
- const providerSeparators = [":", "."];
112
- for (const separator of providerSeparators) {
113
- const separatorIdx = baseModel.indexOf(separator);
114
- if (separatorIdx <= 0) continue;
115
- const providerPart = normalizeModelSegment(baseModel.slice(0, separatorIdx));
116
- if (!availableModels.some((entry) => normalizeModelSegment(entry.provider) === providerPart)) continue;
117
- queryProvider = providerPart;
118
- queryIdRaw = baseModel.slice(separatorIdx + 1);
119
- break;
120
- }
121
- }
150
+ const { queryProvider, queryIdRaw } = splitQualifiedModelQuery(baseModel, availableModels);
122
151
  const queryId = normalizeModelSegment(queryIdRaw);
123
152
  const queryIdNoDate = stripTrailingDateStamp(queryId);
124
153
 
@@ -143,7 +172,7 @@ export function fuzzyResolveModel(
143
172
  * thinking suffix). Exact registry matches win; fuzzy normalization
144
173
  * (separator/case/date-stamp via {@link fuzzyResolveModel}) is a fallback so
145
174
  * spelling differences still resolve. Never switches providers for a qualified
146
- * query. Pure.
175
+ * query.
147
176
  */
148
177
  export function resolveModelCandidate(
149
178
  model: string | undefined,
@@ -163,17 +192,26 @@ export function resolveModelCandidate(
163
192
  return model;
164
193
  }
165
194
 
166
- function resolveRequiredSubagentModelCandidate(
195
+ function resolveSubagentModelCandidate(
167
196
  model: string,
168
197
  availableModels: AvailableModelInfo[] | undefined,
169
198
  preferredProvider?: string,
170
- ): string {
199
+ ): string | undefined {
171
200
  if (!availableModels || availableModels.length === 0) return model;
172
201
  const resolvedWhole = resolveBaseModelCandidate(model, availableModels, preferredProvider);
173
202
  if (resolvedWhole) return resolvedWhole;
174
203
  const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
175
204
  const resolvedBase = thinkingSuffix ? resolveBaseModelCandidate(baseModel, availableModels, preferredProvider) : undefined;
176
- if (resolvedBase) return `${resolvedBase}${thinkingSuffix}`;
205
+ return resolvedBase ? `${resolvedBase}${thinkingSuffix}` : undefined;
206
+ }
207
+
208
+ function resolveRequiredSubagentModelCandidate(
209
+ model: string,
210
+ availableModels: AvailableModelInfo[] | undefined,
211
+ preferredProvider?: string,
212
+ ): string {
213
+ const resolved = resolveSubagentModelCandidate(model, availableModels, preferredProvider);
214
+ if (resolved) return resolved;
177
215
  throw new Error(`Unknown subagent model '${model}' in the active Pi model registry.`);
178
216
  }
179
217
 
@@ -263,6 +301,18 @@ export interface BuildModelCandidatesOptions {
263
301
  /** Fallback models warn by default and throw when strict scope enforcement is enabled. */
264
302
  scope?: ModelScopeConfig;
265
303
  onWarn?: (violation: ModelScopeViolation) => void;
304
+ /** The primary model came from the running parent session, not configuration. */
305
+ primaryModelFromParent?: boolean;
306
+ }
307
+
308
+ export function inheritsParentModel(
309
+ explicitModel: string | boolean | undefined,
310
+ agentModel: string | boolean | undefined,
311
+ parentModel: ParentModel | undefined,
312
+ ): boolean {
313
+ const requestedModel = explicitModel ?? agentModel;
314
+ const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : "";
315
+ return Boolean(parentModel && (!trimmed || trimmed === INHERIT_MODEL));
266
316
  }
267
317
 
268
318
  export function buildModelCandidates(
@@ -278,8 +328,17 @@ export function buildModelCandidates(
278
328
  for (let index = 0; index < rawCandidates.length; index++) {
279
329
  const raw = rawCandidates[index];
280
330
  if (!raw) continue;
281
- const normalized = resolveRequiredSubagentModelCandidate(raw.trim(), availableModels, preferredProvider);
282
- if (!normalized || seen.has(normalized)) continue;
331
+ const model = raw.trim();
332
+ const normalized = index === 0
333
+ ? options?.primaryModelFromParent
334
+ ? model
335
+ : resolveRequiredSubagentModelCandidate(model, availableModels, preferredProvider)
336
+ : resolveSubagentModelCandidate(model, availableModels, preferredProvider);
337
+ if (!normalized) {
338
+ console.warn(`[pi-subagents] Skipping fallback model '${model}' because it is unavailable in this environment.`);
339
+ continue;
340
+ }
341
+ if (seen.has(normalized)) continue;
283
342
  if ((index > 0 || options?.scope?.strict === true) && options?.scope?.enforce) {
284
343
  const violation = checkModelScope(normalized, options.scope, "inherited");
285
344
  if (violation) {
@@ -295,6 +354,7 @@ export function buildModelCandidates(
295
354
 
296
355
  const RETRYABLE_MODEL_FAILURE_PATTERNS = [
297
356
  /rate\s*limit/i,
357
+ /usage\s*limit/i,
298
358
  /too many requests/i,
299
359
  /\b429\b/,
300
360
  /quota/i,