pi-subagents 0.31.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +67 -4
  2. package/README.md +219 -40
  3. package/install.mjs +2 -1
  4. package/package.json +3 -2
  5. package/skills/pi-subagents/SKILL.md +71 -10
  6. package/src/agents/agent-management.ts +179 -2
  7. package/src/agents/agent-memory.ts +254 -0
  8. package/src/agents/agent-serializer.ts +11 -0
  9. package/src/agents/agents.ts +193 -19
  10. package/src/agents/chain-serializer.ts +27 -2
  11. package/src/extension/config.ts +27 -4
  12. package/src/extension/doctor.ts +1 -7
  13. package/src/extension/fanout-child.ts +3 -2
  14. package/src/extension/index.ts +70 -41
  15. package/src/extension/rpc.ts +369 -0
  16. package/src/extension/schemas.ts +54 -10
  17. package/src/extension/tool-description.ts +200 -0
  18. package/src/intercom/intercom-bridge.ts +21 -253
  19. package/src/intercom/native-supervisor-channel.ts +510 -0
  20. package/src/runs/background/async-execution.ts +187 -38
  21. package/src/runs/background/async-job-tracker.ts +88 -2
  22. package/src/runs/background/async-status.ts +67 -10
  23. package/src/runs/background/chain-root-attachment.ts +34 -4
  24. package/src/runs/background/completion-batcher.ts +166 -0
  25. package/src/runs/background/control-channel.ts +156 -1
  26. package/src/runs/background/fleet-view.ts +515 -0
  27. package/src/runs/background/notify.ts +161 -44
  28. package/src/runs/background/result-watcher.ts +1 -2
  29. package/src/runs/background/run-id-resolver.ts +3 -2
  30. package/src/runs/background/run-status.ts +167 -6
  31. package/src/runs/background/scheduled-runs.ts +514 -0
  32. package/src/runs/background/stale-run-reconciler.ts +28 -1
  33. package/src/runs/background/subagent-runner.ts +840 -127
  34. package/src/runs/background/wait.ts +353 -0
  35. package/src/runs/foreground/chain-execution.ts +123 -27
  36. package/src/runs/foreground/execution.ts +174 -27
  37. package/src/runs/foreground/subagent-executor.ts +569 -81
  38. package/src/runs/shared/acceptance.ts +45 -22
  39. package/src/runs/shared/dynamic-fanout.ts +2 -2
  40. package/src/runs/shared/model-fallback.ts +171 -20
  41. package/src/runs/shared/model-scope.ts +128 -0
  42. package/src/runs/shared/nested-events.ts +89 -0
  43. package/src/runs/shared/parallel-utils.ts +50 -1
  44. package/src/runs/shared/pi-args.ts +35 -4
  45. package/src/runs/shared/pi-spawn.ts +52 -20
  46. package/src/runs/shared/single-output.ts +2 -0
  47. package/src/runs/shared/subagent-prompt-runtime.ts +110 -4
  48. package/src/runs/shared/tool-budget.ts +74 -0
  49. package/src/runs/shared/turn-budget.ts +52 -0
  50. package/src/runs/shared/worktree.ts +28 -5
  51. package/src/shared/artifacts.ts +16 -1
  52. package/src/shared/atomic-json.ts +15 -2
  53. package/src/shared/child-transcript.ts +212 -0
  54. package/src/shared/fork-context.ts +133 -22
  55. package/src/shared/settings.ts +3 -1
  56. package/src/shared/types.ts +197 -4
  57. package/src/shared/utils.ts +99 -14
  58. package/src/slash/prompt-workflows.ts +330 -0
  59. package/src/slash/slash-commands.ts +133 -2
  60. package/src/tui/render.ts +32 -12
@@ -710,13 +710,15 @@ export function aggregateAcceptanceReport(input: {
710
710
  };
711
711
  }
712
712
 
713
- function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string): Promise<AcceptanceVerifyResult> {
713
+ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: { signal?: AbortSignal; abortMessage?: string } = {}): Promise<AcceptanceVerifyResult> {
714
714
  return new Promise((resolve) => {
715
715
  const startedAt = Date.now();
716
716
  const cwd = command.cwd ? path.resolve(defaultCwd, command.cwd) : defaultCwd;
717
717
  let stdout = "";
718
718
  let stderr = "";
719
719
  let timedOut = false;
720
+ let settled = false;
721
+ let hardKill: NodeJS.Timeout | undefined;
720
722
  const child = spawn(command.command, {
721
723
  cwd,
722
724
  env: { ...process.env, ...(command.env ?? {}) },
@@ -724,12 +726,39 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string):
724
726
  stdio: ["ignore", "pipe", "pipe"],
725
727
  windowsHide: true,
726
728
  });
727
- const timeout = setTimeout(() => {
729
+ const finish = (result: Omit<AcceptanceVerifyResult, "id" | "command" | "cwd" | "durationMs">) => {
730
+ if (settled) return;
731
+ settled = true;
732
+ clearTimeout(timeout);
733
+ if (hardKill) clearTimeout(hardKill);
734
+ options.signal?.removeEventListener("abort", abortVerification);
735
+ resolve({
736
+ id: command.id,
737
+ command: command.command,
738
+ cwd,
739
+ durationMs: Date.now() - startedAt,
740
+ ...result,
741
+ });
742
+ };
743
+ const abortVerification = () => {
744
+ if (settled || timedOut) return;
728
745
  timedOut = true;
729
746
  child.kill("SIGTERM");
730
- setTimeout(() => child.kill("SIGKILL"), 1000).unref?.();
731
- }, command.timeoutMs ?? 120_000);
747
+ hardKill = setTimeout(() => {
748
+ child.kill("SIGKILL");
749
+ finish({
750
+ exitCode: null,
751
+ status: "timed-out",
752
+ stdout: trimOutput(stdout),
753
+ stderr: trimOutput(stderr || options.abortMessage || "Acceptance verification timed out."),
754
+ });
755
+ }, 1000);
756
+ hardKill.unref?.();
757
+ };
758
+ const timeout = setTimeout(abortVerification, command.timeoutMs ?? 120_000);
732
759
  timeout.unref?.();
760
+ if (options.signal?.aborted) abortVerification();
761
+ else options.signal?.addEventListener("abort", abortVerification, { once: true });
733
762
  child.stdout.on("data", (chunk: Buffer) => {
734
763
  stdout += chunk.toString();
735
764
  });
@@ -737,30 +766,19 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string):
737
766
  stderr += chunk.toString();
738
767
  });
739
768
  child.on("close", (exitCode) => {
740
- clearTimeout(timeout);
741
- const durationMs = Date.now() - startedAt;
742
769
  const passed = exitCode === 0 && !timedOut;
743
- resolve({
744
- id: command.id,
745
- command: command.command,
746
- cwd,
770
+ finish({
747
771
  exitCode,
748
772
  status: timedOut ? "timed-out" : passed ? "passed" : command.allowFailure ? "allowed-failure" : "failed",
749
773
  stdout: trimOutput(stdout),
750
- stderr: trimOutput(stderr),
751
- durationMs,
774
+ stderr: trimOutput(stderr || (timedOut ? options.abortMessage ?? "" : "")),
752
775
  });
753
776
  });
754
777
  child.on("error", (error) => {
755
- clearTimeout(timeout);
756
- resolve({
757
- id: command.id,
758
- command: command.command,
759
- cwd,
760
- exitCode: 1,
761
- status: command.allowFailure ? "allowed-failure" : "failed",
762
- stderr: error instanceof Error ? error.message : String(error),
763
- durationMs: Date.now() - startedAt,
778
+ finish({
779
+ exitCode: timedOut ? null : 1,
780
+ status: timedOut ? "timed-out" : command.allowFailure ? "allowed-failure" : "failed",
781
+ stderr: timedOut ? trimOutput(stderr || options.abortMessage || "Acceptance verification timed out.") : error instanceof Error ? error.message : String(error),
764
782
  });
765
783
  });
766
784
  });
@@ -772,6 +790,8 @@ export async function evaluateAcceptance(input: {
772
790
  cwd: string;
773
791
  report?: AcceptanceReport;
774
792
  reviewResult?: AcceptanceReviewResult;
793
+ signal?: AbortSignal;
794
+ abortMessage?: string;
775
795
  }): Promise<AcceptanceLedger> {
776
796
  const acceptance = input.acceptance;
777
797
  const ledger: AcceptanceLedger = {
@@ -815,7 +835,10 @@ export async function evaluateAcceptance(input: {
815
835
  return ledger;
816
836
  }
817
837
  ledger.verifyRuns = [];
818
- for (const command of acceptance.verify) ledger.verifyRuns.push(await runVerifyCommand(command, input.cwd));
838
+ for (const command of acceptance.verify) {
839
+ ledger.verifyRuns.push(await runVerifyCommand(command, input.cwd, { signal: input.signal, abortMessage: input.abortMessage }));
840
+ if (input.signal?.aborted) break;
841
+ }
819
842
  if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
820
843
  ledger.status = "rejected";
821
844
  return ledger;
@@ -42,10 +42,10 @@ const ITEM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
42
42
  const ITEM_REF_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([^{}]+))?\}/g;
43
43
  const RESERVED_TEMPLATE_NAMES = new Set(["task", "previous", "chain_dir", "outputs"]);
44
44
  const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency", "failFast", "phase", "label", "acceptance"]);
45
- const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance"]);
45
+ const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance", "sessionFiles", "thinkingOverrides"]);
46
46
  const DYNAMIC_EXPAND_KEYS = new Set(["from", "item", "key", "maxItems", "onEmpty"]);
47
47
  const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
48
- const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "acceptance"]);
48
+ const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "toolBudget", "acceptance"]);
49
49
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
50
50
  ...DYNAMIC_PARALLEL_KEYS,
51
51
  "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "maxSubagentDepth",
@@ -1,5 +1,6 @@
1
1
  import type { ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
2
2
  import type { Usage } from "../../shared/types.ts";
3
+ import { checkModelScope, type ModelScopeConfig, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
3
4
 
4
5
  export type { AvailableModelInfo };
5
6
 
@@ -29,6 +30,144 @@ export interface ParentModel {
29
30
  id: string;
30
31
  }
31
32
 
33
+ /**
34
+ * Normalize a model id or provider segment for fuzzy comparison: case-fold,
35
+ * treat dots/underscores as dashes (so `4.5` matches `4-5`), and collapse
36
+ * repeated separators. Pure.
37
+ */
38
+ export function normalizeModelSegment(segment: string): string {
39
+ return segment
40
+ .toLowerCase()
41
+ .replace(/[._]+/g, "-")
42
+ .replace(/-+/g, "-")
43
+ .replace(/^-|-$/g, "");
44
+ }
45
+
46
+ function isPlausibleDateStamp(year: string, month: string, day: string): boolean {
47
+ const yyyy = Number(year);
48
+ const mm = Number(month);
49
+ const dd = Number(day);
50
+ return yyyy >= 1900 && yyyy <= 2099 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31;
51
+ }
52
+
53
+ /** Drop a trailing date stamp (`-20251001` or `-2025-10-01`) so dated and undated ids match. Pure. */
54
+ function stripTrailingDateStamp(segment: string): string {
55
+ const dashed = /^(.*)-(\d{4})-(\d{2})-(\d{2})$/.exec(segment);
56
+ if (dashed && isPlausibleDateStamp(dashed[2]!, dashed[3]!, dashed[4]!)) return dashed[1]!;
57
+ const compact = /^(.*)-(\d{4})(\d{2})(\d{2})$/.exec(segment);
58
+ if (compact && isPlausibleDateStamp(compact[2]!, compact[3]!, compact[4]!)) return compact[1]!;
59
+ return segment;
60
+ }
61
+
62
+ function resolveBaseModelCandidate(
63
+ baseModel: string,
64
+ availableModels: AvailableModelInfo[],
65
+ preferredProvider?: string,
66
+ ): string | undefined {
67
+ if (baseModel.includes("/")) {
68
+ const exact = availableModels.find((entry) => entry.fullId === baseModel);
69
+ if (exact) return exact.fullId;
70
+ } else {
71
+ const exactMatches = availableModels.filter((entry) => entry.id === baseModel);
72
+ if (preferredProvider) {
73
+ const preferredMatch = exactMatches.find((entry) => entry.provider === preferredProvider);
74
+ if (preferredMatch) return preferredMatch.fullId;
75
+ }
76
+ if (exactMatches.length === 1) return exactMatches[0]!.fullId;
77
+ }
78
+
79
+ return fuzzyResolveModel(baseModel, availableModels, preferredProvider);
80
+ }
81
+
82
+ /**
83
+ * Fuzzy-resolve a base model id (thinking suffix already stripped) against the
84
+ * registry, tolerating separator, case, and optional date-stamp differences so
85
+ * users do not have to spell provider/model exactly. A qualified `provider/id`
86
+ * query only matches within the named provider — this never silently switches
87
+ * providers for security/cost-sensitive configs. Returns the matched `fullId`,
88
+ * or `undefined` when there is no match or the match is ambiguous across
89
+ * providers (and no `preferredProvider` disambiguates). Pure.
90
+ */
91
+ export function fuzzyResolveModel(
92
+ baseModel: string,
93
+ availableModels: AvailableModelInfo[],
94
+ preferredProvider?: string,
95
+ ): string | undefined {
96
+ let queryProvider: string | undefined;
97
+ let queryIdRaw = baseModel;
98
+ const slashIdx = baseModel.indexOf("/");
99
+ if (slashIdx !== -1) {
100
+ queryProvider = normalizeModelSegment(baseModel.slice(0, slashIdx));
101
+ queryIdRaw = baseModel.slice(slashIdx + 1);
102
+ } else {
103
+ const providerSeparators = [":", "."];
104
+ for (const separator of providerSeparators) {
105
+ const separatorIdx = baseModel.indexOf(separator);
106
+ if (separatorIdx <= 0) continue;
107
+ const providerPart = normalizeModelSegment(baseModel.slice(0, separatorIdx));
108
+ if (!availableModels.some((entry) => normalizeModelSegment(entry.provider) === providerPart)) continue;
109
+ queryProvider = providerPart;
110
+ queryIdRaw = baseModel.slice(separatorIdx + 1);
111
+ break;
112
+ }
113
+ }
114
+ const queryId = normalizeModelSegment(queryIdRaw);
115
+ const queryIdNoDate = stripTrailingDateStamp(queryId);
116
+
117
+ const candidates = availableModels.filter((entry) => {
118
+ const entryId = normalizeModelSegment(entry.id);
119
+ if (entryId !== queryId && stripTrailingDateStamp(entryId) !== queryIdNoDate) return false;
120
+ if (queryProvider !== undefined && normalizeModelSegment(entry.provider) !== queryProvider) return false;
121
+ return true;
122
+ });
123
+ if (candidates.length === 0) return undefined;
124
+ if (preferredProvider) {
125
+ const preferredProviderNorm = normalizeModelSegment(preferredProvider);
126
+ const preferred = candidates.find((entry) => normalizeModelSegment(entry.provider) === preferredProviderNorm);
127
+ if (preferred) return preferred.fullId;
128
+ }
129
+ if (candidates.length === 1) return candidates[0]!.fullId;
130
+ return undefined;
131
+ }
132
+
133
+ /**
134
+ * Resolve a possibly-loose model id to a canonical `provider/id` (plus any
135
+ * thinking suffix). Exact registry matches win; fuzzy normalization
136
+ * (separator/case/date-stamp via {@link fuzzyResolveModel}) is a fallback so
137
+ * spelling differences still resolve. Never switches providers for a qualified
138
+ * query. Pure.
139
+ */
140
+ export function resolveModelCandidate(
141
+ model: string | undefined,
142
+ availableModels: AvailableModelInfo[] | undefined,
143
+ preferredProvider?: string,
144
+ ): string | undefined {
145
+ if (!model) return undefined;
146
+ if (!availableModels || availableModels.length === 0) return model;
147
+
148
+ const resolvedWhole = resolveBaseModelCandidate(model, availableModels, preferredProvider);
149
+ if (resolvedWhole) return resolvedWhole;
150
+
151
+ const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
152
+ if (!thinkingSuffix) return model;
153
+ const resolvedBase = resolveBaseModelCandidate(baseModel, availableModels, preferredProvider);
154
+ if (resolvedBase) return `${resolvedBase}${thinkingSuffix}`;
155
+ return model;
156
+ }
157
+
158
+ export interface ResolveSubagentModelOverrideOptions {
159
+ /** When set with `enforce: true`, out-of-scope models are rejected. */
160
+ scope?: ModelScopeConfig;
161
+ /** Origin of the requested model: explicit caller-supplied (hard error) vs inherited (warn). Defaults to `"inherited"`. */
162
+ source?: ModelSource;
163
+ /** Called for warn-severity violations instead of `console.warn`. */
164
+ onWarn?: (violation: ModelScopeViolation) => void;
165
+ }
166
+
167
+ function defaultScopeWarn(violation: ModelScopeViolation): void {
168
+ console.warn(`[pi-subagents] ${violation.message}`);
169
+ }
170
+
32
171
  /**
33
172
  * Resolve the `--model` override passed to a spawned subagent.
34
173
  *
@@ -43,38 +182,39 @@ export interface ParentModel {
43
182
  * session's model.
44
183
  *
45
184
  * An explicitly requested model string is resolved via {@link resolveModelCandidate}.
185
+ * When `options.scope.enforce` is on, an out-of-scope resolved model throws for
186
+ * an explicit (`source: "explicit"`) request and warns for an inherited one.
46
187
  */
47
188
  export function resolveSubagentModelOverride(
48
189
  requestedModel: string | boolean | undefined,
49
190
  parentModel: ParentModel | undefined,
50
191
  availableModels: AvailableModelInfo[] | undefined,
51
192
  preferredProvider?: string,
193
+ options?: ResolveSubagentModelOverrideOptions,
52
194
  ): string | undefined {
53
195
  const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : "";
54
196
  const explicit = trimmed && trimmed !== INHERIT_MODEL ? trimmed : undefined;
197
+ let resolved: string | undefined;
55
198
  if (explicit === undefined) {
56
- return parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
199
+ resolved = parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
200
+ } else {
201
+ resolved = resolveModelCandidate(explicit, availableModels, preferredProvider);
202
+ }
203
+ if (resolved && options?.scope?.enforce) {
204
+ const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited");
205
+ const violation = checkModelScope(resolved, options.scope, source);
206
+ if (violation) {
207
+ if (violation.severity === "error") throw new Error(violation.message);
208
+ (options.onWarn ?? defaultScopeWarn)(violation);
209
+ }
57
210
  }
58
- return resolveModelCandidate(explicit, availableModels, preferredProvider);
211
+ return resolved;
59
212
  }
60
213
 
61
- export function resolveModelCandidate(
62
- model: string | undefined,
63
- availableModels: AvailableModelInfo[] | undefined,
64
- preferredProvider?: string,
65
- ): string | undefined {
66
- if (!model) return undefined;
67
- if (model.includes("/")) return model;
68
- if (!availableModels || availableModels.length === 0) return model;
69
-
70
- const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
71
- const matches = availableModels.filter((entry) => entry.id === baseModel);
72
- if (preferredProvider) {
73
- const preferredMatch = matches.find((entry) => entry.provider === preferredProvider);
74
- if (preferredMatch) return `${preferredMatch.fullId}${thinkingSuffix}`;
75
- }
76
- if (matches.length !== 1) return model;
77
- return `${matches[0]!.fullId}${thinkingSuffix}`;
214
+ export interface BuildModelCandidatesOptions {
215
+ /** Fallback models are inherited agent config and warn, rather than error, when out of scope. */
216
+ scope?: ModelScopeConfig;
217
+ onWarn?: (violation: ModelScopeViolation) => void;
78
218
  }
79
219
 
80
220
  export function buildModelCandidates(
@@ -82,13 +222,20 @@ export function buildModelCandidates(
82
222
  fallbackModels: string[] | undefined,
83
223
  availableModels: AvailableModelInfo[] | undefined,
84
224
  preferredProvider?: string,
225
+ options?: BuildModelCandidatesOptions,
85
226
  ): string[] {
86
227
  const seen = new Set<string>();
87
228
  const candidates: string[] = [];
88
- for (const raw of [primaryModel, ...(fallbackModels ?? [])]) {
229
+ const rawCandidates = [primaryModel, ...(fallbackModels ?? [])];
230
+ for (let index = 0; index < rawCandidates.length; index++) {
231
+ const raw = rawCandidates[index];
89
232
  if (!raw) continue;
90
233
  const normalized = resolveModelCandidate(raw.trim(), availableModels, preferredProvider);
91
234
  if (!normalized || seen.has(normalized)) continue;
235
+ if (index > 0 && options?.scope?.enforce) {
236
+ const violation = checkModelScope(normalized, options.scope, "inherited");
237
+ if (violation) (options.onWarn ?? defaultScopeWarn)(violation);
238
+ }
92
239
  seen.add(normalized);
93
240
  candidates.push(normalized);
94
241
  }
@@ -126,6 +273,10 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
126
273
  /\b502\b/,
127
274
  /\b503\b/,
128
275
  /\b504\b/,
276
+ /cold.?start/i,
277
+ /empty response/i,
278
+ /no output/i,
279
+ /model.*(?:load|fail|error)/i,
129
280
  ];
130
281
 
131
282
  export function isRetryableModelFailure(error: string | undefined): boolean {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Optional model-scope enforcement for subagent model resolution.
3
+ *
4
+ * When `subagents.modelScope.enforce` is enabled in settings, a resolved model
5
+ * that does not match any `allow` pattern is rejected. The severity depends on
6
+ * where the model came from: an explicit caller-supplied model (`--model`,
7
+ * tool-call `model`, or a TUI clarify pick) is a hard error, while a model
8
+ * inherited from agent frontmatter / `defaultModel` / the parent session only
9
+ * emits a warning so existing configurations keep working.
10
+ *
11
+ * The decision logic ({@link checkModelScope}) is a pure function of its
12
+ * inputs so it can be unit-tested without touching the filesystem or config.
13
+ */
14
+
15
+ import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
16
+
17
+ export interface ModelScopeConfig {
18
+ enforce?: boolean;
19
+ /** Glob-style allow patterns (only `*` is special), matched against `provider/id`. */
20
+ allow?: string[];
21
+ }
22
+
23
+ /** Where a resolved model originated, deciding enforcement severity. */
24
+ export type ModelSource = "explicit" | "inherited";
25
+
26
+ export interface ModelScopeViolation {
27
+ /** Resolved model id (without thinking suffix) that fell outside the scope. */
28
+ model: string;
29
+ severity: "warn" | "error";
30
+ message: string;
31
+ allowedPatterns: string[];
32
+ }
33
+
34
+ function stripThinkingSuffix(model: string): string {
35
+ return splitKnownThinkingSuffix(model).baseModel;
36
+ }
37
+
38
+ /** Escape RegExp specials except `*`, then turn `*` into `.*`. */
39
+ function globToRegExp(pattern: string): RegExp {
40
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
41
+ return new RegExp(`^${escaped}$`, "i");
42
+ }
43
+
44
+ /**
45
+ * Test whether a resolved model matches a single allow pattern. Both sides are
46
+ * compared case-insensitively against the full `provider/id` (thinking suffix
47
+ * stripped from the model).
48
+ */
49
+ export function matchesScopePattern(model: string, pattern: string): boolean {
50
+ return globToRegExp(pattern).test(stripThinkingSuffix(model));
51
+ }
52
+
53
+ /**
54
+ * Pure scope decision. Returns a {@link ModelScopeViolation} when the model is
55
+ * out of scope and enforcement is on, otherwise `undefined`. Enforcement with
56
+ * no `allow` list is a no-op (the settings parser rejects that combination, but
57
+ * this stays defensive for callers that build configs programmatically).
58
+ */
59
+ export function checkModelScope(
60
+ model: string | undefined,
61
+ scope: ModelScopeConfig | undefined,
62
+ source: ModelSource,
63
+ ): ModelScopeViolation | undefined {
64
+ if (!model || !scope?.enforce) return undefined;
65
+ const allow = scope.allow;
66
+ if (!allow || allow.length === 0) return undefined;
67
+ if (allow.some((pattern) => matchesScopePattern(model, pattern))) return undefined;
68
+
69
+ const baseModel = stripThinkingSuffix(model);
70
+ const severity: ModelScopeViolation["severity"] = source === "explicit" ? "error" : "warn";
71
+ return {
72
+ model: baseModel,
73
+ severity,
74
+ allowedPatterns: allow,
75
+ message:
76
+ `Model '${baseModel}' is outside the configured subagent model scope. ` +
77
+ `Allowed patterns: ${allow.join(", ")}.`,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Validate and normalize a raw `subagents.modelScope` value from settings.
83
+ * Throws a descriptive error for malformed configs (matching the surrounding
84
+ * settings-parsing style). Returns `undefined` when the field is absent.
85
+ */
86
+ export function parseModelScopeConfig(
87
+ value: unknown,
88
+ meta: { filePath: string },
89
+ ): ModelScopeConfig | undefined {
90
+ if (value === undefined) return undefined;
91
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
92
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope'; expected an object.`);
93
+ }
94
+
95
+ const input = value as Record<string, unknown>;
96
+ const config: ModelScopeConfig = {};
97
+
98
+ if ("enforce" in input) {
99
+ if (typeof input.enforce !== "boolean") {
100
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.enforce'; expected a boolean.`);
101
+ }
102
+ config.enforce = input.enforce;
103
+ }
104
+
105
+ if ("allow" in input) {
106
+ if (!Array.isArray(input.allow)) {
107
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
108
+ }
109
+ const allow: string[] = [];
110
+ for (const entry of input.allow) {
111
+ if (typeof entry !== "string") {
112
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
113
+ }
114
+ const trimmed = entry.trim();
115
+ if (trimmed) allow.push(trimmed);
116
+ }
117
+ if (allow.length === 0) {
118
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected a non-empty array of patterns.`);
119
+ }
120
+ config.allow = allow;
121
+ }
122
+
123
+ if (config.enforce === true && (!config.allow || config.allow.length === 0)) {
124
+ throw new Error(`Subagent settings in '${meta.filePath}' set modelScope.enforce without a non-empty 'allow' list; supply allowed model patterns or disable enforcement.`);
125
+ }
126
+
127
+ return Object.keys(config).length > 0 ? config : undefined;
128
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type AsyncJobState,
9
9
  type AsyncStatus,
10
10
  type NestedRouteInfo,
11
+ type TurnBudgetState,
11
12
  type NestedRunSummary,
12
13
  type NestedRunState,
13
14
  type NestedStepSummary,
@@ -184,6 +185,35 @@ function sanitizeTokenUsage(value: unknown): NestedRunSummary["totalTokens"] | u
184
185
  : undefined;
185
186
  }
186
187
 
188
+ function sanitizeCost(value: unknown): NestedRunSummary["totalCost"] | undefined {
189
+ if (!value || typeof value !== "object") return undefined;
190
+ const raw = value as Record<string, unknown>;
191
+ const inputTokens = clampNumber(raw.inputTokens);
192
+ const outputTokens = clampNumber(raw.outputTokens);
193
+ const costUsd = clampNumber(raw.costUsd);
194
+ return inputTokens !== undefined && outputTokens !== undefined && costUsd !== undefined
195
+ ? { inputTokens, outputTokens, costUsd }
196
+ : undefined;
197
+ }
198
+
199
+ function sanitizeTurnBudget(value: unknown): TurnBudgetState | undefined {
200
+ if (!value || typeof value !== "object") return undefined;
201
+ const raw = value as Record<string, unknown>;
202
+ const maxTurns = clampNumber(raw.maxTurns);
203
+ const graceTurns = clampNumber(raw.graceTurns);
204
+ const turnCount = clampNumber(raw.turnCount);
205
+ const outcome = raw.outcome === "within-budget" || raw.outcome === "wrap-up-requested" || raw.outcome === "exceeded" ? raw.outcome : undefined;
206
+ if (maxTurns === undefined || graceTurns === undefined || turnCount === undefined || !outcome) return undefined;
207
+ return {
208
+ maxTurns,
209
+ graceTurns,
210
+ turnCount,
211
+ outcome,
212
+ ...(clampNumber(raw.wrapUpRequestedAtTurn) !== undefined ? { wrapUpRequestedAtTurn: clampNumber(raw.wrapUpRequestedAtTurn) } : {}),
213
+ ...(clampNumber(raw.exceededAtTurn) !== undefined ? { exceededAtTurn: clampNumber(raw.exceededAtTurn) } : {}),
214
+ };
215
+ }
216
+
187
217
  function sanitizeState(value: unknown, fallback: NestedRunState): NestedRunState {
188
218
  return value === "queued" || value === "running" || value === "complete" || value === "failed" || value === "paused"
189
219
  ? value
@@ -212,6 +242,10 @@ function sanitizeStep(input: unknown, depth: number): NestedStepSummary | undefi
212
242
  ...(clampNumber(raw.startedAt) !== undefined ? { startedAt: clampNumber(raw.startedAt) } : {}),
213
243
  ...(clampNumber(raw.endedAt) !== undefined ? { endedAt: clampNumber(raw.endedAt) } : {}),
214
244
  ...(stringValue(raw.error, 1024) ? { error: stringValue(raw.error, 1024) } : {}),
245
+ ...(raw.timedOut === true ? { timedOut: true } : {}),
246
+ ...(sanitizeTurnBudget(raw.turnBudget) ? { turnBudget: sanitizeTurnBudget(raw.turnBudget) } : {}),
247
+ ...(raw.turnBudgetExceeded === true ? { turnBudgetExceeded: true } : {}),
248
+ ...(raw.wrapUpRequested === true ? { wrapUpRequested: true } : {}),
215
249
  ...(depth < MAX_DEPTH && Array.isArray(raw.children) ? { children: raw.children.map((child) => sanitizeSummary(child, depth + 1)).filter((child): child is NestedRunSummary => Boolean(child)).slice(0, MAX_CHILDREN) } : {}),
216
250
  };
217
251
  }
@@ -225,6 +259,7 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
225
259
  ? raw.steps.map((step) => sanitizeStep(step, depth + 1)).filter((step): step is NestedStepSummary => Boolean(step)).slice(0, MAX_STEPS)
226
260
  : undefined;
227
261
  const totalTokens = sanitizeTokenUsage(raw.totalTokens);
262
+ const totalCost = sanitizeCost(raw.totalCost);
228
263
  return {
229
264
  id: raw.id,
230
265
  parentRunId: raw.parentRunId,
@@ -256,9 +291,16 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
256
291
  ...(clampNumber(raw.turnCount) !== undefined ? { turnCount: clampNumber(raw.turnCount) } : {}),
257
292
  ...(clampNumber(raw.toolCount) !== undefined ? { toolCount: clampNumber(raw.toolCount) } : {}),
258
293
  ...(totalTokens ? { totalTokens } : {}),
294
+ ...(totalCost ? { totalCost } : {}),
259
295
  ...(clampNumber(raw.startedAt) !== undefined ? { startedAt: clampNumber(raw.startedAt) } : {}),
260
296
  ...(clampNumber(raw.endedAt) !== undefined ? { endedAt: clampNumber(raw.endedAt) } : {}),
261
297
  ...(clampNumber(raw.lastUpdate) !== undefined ? { lastUpdate: clampNumber(raw.lastUpdate) } : {}),
298
+ ...(clampNumber(raw.timeoutMs) !== undefined ? { timeoutMs: clampNumber(raw.timeoutMs) } : {}),
299
+ ...(clampNumber(raw.deadlineAt) !== undefined ? { deadlineAt: clampNumber(raw.deadlineAt) } : {}),
300
+ ...(raw.timedOut === true ? { timedOut: true } : {}),
301
+ ...(sanitizeTurnBudget(raw.turnBudget) ? { turnBudget: sanitizeTurnBudget(raw.turnBudget) } : {}),
302
+ ...(raw.turnBudgetExceeded === true ? { turnBudgetExceeded: true } : {}),
303
+ ...(raw.wrapUpRequested === true ? { wrapUpRequested: true } : {}),
262
304
  ...(stringValue(raw.error, 1024) ? { error: stringValue(raw.error, 1024) } : {}),
263
305
  ...(steps && steps.length > 0 ? { steps } : {}),
264
306
  ...(depth < MAX_DEPTH && Array.isArray(raw.children) ? { children: raw.children.map((child) => sanitizeSummary(child, depth + 1)).filter((child): child is NestedRunSummary => Boolean(child)).slice(0, MAX_CHILDREN) } : {}),
@@ -394,6 +436,42 @@ export function findNestedRouteForRootId(rootRunId: string): NestedRoute | undef
394
436
  return undefined;
395
437
  }
396
438
 
439
+ /**
440
+ * Scan the nested-events directory once and index every route by its root run
441
+ * id. Use this when resolving routes for many runs (e.g. listAsyncRuns) so the
442
+ * cost is O(routes) total instead of O(runs * routes) from calling
443
+ * findNestedRouteForRootId per run.
444
+ */
445
+ export function buildNestedRouteIndex(): Map<string, NestedRoute> {
446
+ let entries: string[];
447
+ try {
448
+ entries = fs.readdirSync(NESTED_EVENTS_DIR);
449
+ } catch (error) {
450
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Map();
451
+ throw error;
452
+ }
453
+ const index = new Map<string, NestedRoute>();
454
+ for (const entry of entries) {
455
+ const routeRoot = path.join(NESTED_EVENTS_DIR, entry);
456
+ try {
457
+ const metadata = JSON.parse(fs.readFileSync(path.join(routeRoot, ROUTE_FILE), "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
458
+ if (typeof metadata.rootRunId !== "string" || typeof metadata.capabilityToken !== "string") continue;
459
+ if (index.has(metadata.rootRunId)) continue;
460
+ const route: NestedRoute = {
461
+ rootRunId: metadata.rootRunId,
462
+ eventSink: path.join(routeRoot, "events"),
463
+ controlInbox: path.join(routeRoot, "controls"),
464
+ capabilityToken: metadata.capabilityToken,
465
+ };
466
+ validateRouteShape(route);
467
+ index.set(metadata.rootRunId, route);
468
+ } catch {
469
+ continue;
470
+ }
471
+ }
472
+ return index;
473
+ }
474
+
397
475
  export function projectNestedRegistryForRoot(rootRunId: string): NestedRegistry | undefined {
398
476
  const route = findNestedRouteForRootId(rootRunId);
399
477
  return route ? projectNestedEvents(route) : undefined;
@@ -778,6 +856,13 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
778
856
  ...(status.turnCount !== undefined ? { turnCount: status.turnCount } : {}),
779
857
  ...(status.toolCount !== undefined ? { toolCount: status.toolCount } : {}),
780
858
  ...(status.totalTokens ? { totalTokens: status.totalTokens } : {}),
859
+ ...(status.timeoutMs !== undefined ? { timeoutMs: status.timeoutMs } : {}),
860
+ ...(status.deadlineAt !== undefined ? { deadlineAt: status.deadlineAt } : {}),
861
+ ...(status.timedOut !== undefined ? { timedOut: status.timedOut } : {}),
862
+ ...(status.turnBudget ? { turnBudget: status.turnBudget } : {}),
863
+ ...(status.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: status.turnBudgetExceeded } : {}),
864
+ ...(status.wrapUpRequested !== undefined ? { wrapUpRequested: status.wrapUpRequested } : {}),
865
+ ...(status.error ? { error: status.error } : {}),
781
866
  ...(status.startedAt !== undefined ? { startedAt: status.startedAt } : { startedAt: fallback.ts }),
782
867
  ...(status.endedAt !== undefined ? { endedAt: status.endedAt } : {}),
783
868
  lastUpdate: status.lastUpdate ?? fallback.ts,
@@ -796,6 +881,10 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
796
881
  ...(step.startedAt !== undefined ? { startedAt: step.startedAt } : {}),
797
882
  ...(step.endedAt !== undefined ? { endedAt: step.endedAt } : {}),
798
883
  ...(step.error ? { error: step.error } : {}),
884
+ ...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}),
885
+ ...(step.turnBudget ? { turnBudget: step.turnBudget } : {}),
886
+ ...(step.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: step.turnBudgetExceeded } : {}),
887
+ ...(step.wrapUpRequested !== undefined ? { wrapUpRequested: step.wrapUpRequested } : {}),
799
888
  })).slice(0, MAX_STEPS) } : {}),
800
889
  };
801
890
  }