@oh-my-pi/pi-coding-agent 16.2.9 → 16.2.12

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 (110) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli.js +3464 -3306
  3. package/dist/types/cli/bench-cli.d.ts +0 -5
  4. package/dist/types/cli/dry-balance-cli.d.ts +0 -5
  5. package/dist/types/cli/models-cli.d.ts +1 -1
  6. package/dist/types/config/inline-tool-descriptors-mode.d.ts +1 -2
  7. package/dist/types/config/model-registry.d.ts +1 -23
  8. package/dist/types/config/model-resolver.d.ts +10 -15
  9. package/dist/types/config/models-config-schema.d.ts +0 -6
  10. package/dist/types/config/models-config.d.ts +0 -6
  11. package/dist/types/config/settings-schema.d.ts +31 -1
  12. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +15 -0
  14. package/dist/types/extensibility/skills.d.ts +29 -0
  15. package/dist/types/lsp/client.d.ts +9 -3
  16. package/dist/types/modes/components/todo-reminder.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +0 -1
  18. package/dist/types/modes/skill-command.d.ts +1 -1
  19. package/dist/types/modes/types.d.ts +0 -1
  20. package/dist/types/stt/asr-client.d.ts +7 -3
  21. package/dist/types/task/spawn-policy.d.ts +17 -0
  22. package/dist/types/task/spawn-policy.test.d.ts +1 -0
  23. package/dist/types/task/types.d.ts +8 -2
  24. package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
  25. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
  26. package/dist/types/tools/browser/registry.d.ts +2 -0
  27. package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
  28. package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
  29. package/dist/types/tools/eval.d.ts +3 -6
  30. package/dist/types/tools/write.d.ts +1 -1
  31. package/dist/types/utils/image-vision-fallback.d.ts +1 -1
  32. package/package.json +14 -13
  33. package/scripts/bundle-dist.ts +23 -4
  34. package/scripts/generate-docs-index.ts +116 -24
  35. package/src/advisor/__tests__/advisor.test.ts +1 -1
  36. package/src/async/job-manager.ts +27 -3
  37. package/src/auto-thinking/classifier.ts +1 -1
  38. package/src/cli/bench-cli.ts +3 -12
  39. package/src/cli/dry-balance-cli.ts +1 -6
  40. package/src/cli/grep-cli.ts +1 -1
  41. package/src/cli/models-cli.ts +3 -81
  42. package/src/commands/models.ts +1 -2
  43. package/src/commit/model-selection.ts +4 -4
  44. package/src/config/inline-tool-descriptors-mode.ts +1 -2
  45. package/src/config/model-discovery.ts +150 -82
  46. package/src/config/model-registry.ts +11 -225
  47. package/src/config/model-resolver.ts +23 -135
  48. package/src/config/models-config-schema.ts +0 -22
  49. package/src/config/prompt-templates.ts +20 -0
  50. package/src/config/settings-schema.ts +35 -1
  51. package/src/config/settings.ts +60 -24
  52. package/src/eval/__tests__/agent-bridge.test.ts +17 -3
  53. package/src/eval/agent-bridge.ts +7 -9
  54. package/src/eval/completion-bridge.ts +1 -1
  55. package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
  56. package/src/eval/py/runner.py +169 -18
  57. package/src/extensibility/extensions/model-api.ts +1 -3
  58. package/src/extensibility/extensions/runner.ts +62 -5
  59. package/src/extensibility/skills.ts +77 -0
  60. package/src/internal-urls/docs-index.generated.txt +2 -2
  61. package/src/lsp/client.ts +162 -45
  62. package/src/lsp/config.ts +17 -3
  63. package/src/lsp/index.ts +31 -21
  64. package/src/main.ts +0 -1
  65. package/src/mcp/transports/http.ts +19 -17
  66. package/src/mcp/transports/stdio.test.ts +51 -1
  67. package/src/mcp/transports/stdio.ts +19 -9
  68. package/src/memories/index.ts +0 -1
  69. package/src/mnemopi/backend.ts +1 -1
  70. package/src/modes/acp/acp-agent.ts +6 -9
  71. package/src/modes/components/model-selector.ts +32 -186
  72. package/src/modes/components/todo-reminder.ts +5 -1
  73. package/src/modes/controllers/event-controller.ts +1 -16
  74. package/src/modes/controllers/selector-controller.ts +57 -36
  75. package/src/modes/interactive-mode.ts +0 -6
  76. package/src/modes/rpc/rpc-mode.ts +5 -8
  77. package/src/modes/skill-command.ts +8 -20
  78. package/src/modes/types.ts +0 -1
  79. package/src/prompts/system/subagent-system-prompt.md +2 -2
  80. package/src/prompts/system/tool-call-loop-redirect.md +8 -0
  81. package/src/prompts/tools/eval.md +2 -2
  82. package/src/prompts/tools/task.md +1 -1
  83. package/src/sdk.ts +2 -9
  84. package/src/session/agent-session.ts +89 -8
  85. package/src/session/session-context.ts +2 -1
  86. package/src/session/session-manager.ts +2 -1
  87. package/src/session/unexpected-stop-classifier.ts +1 -1
  88. package/src/stt/asr-client.ts +87 -27
  89. package/src/stt/downloader.ts +8 -2
  90. package/src/task/executor.ts +1 -3
  91. package/src/task/index.ts +37 -32
  92. package/src/task/spawn-policy.test.ts +63 -0
  93. package/src/task/spawn-policy.ts +58 -0
  94. package/src/task/types.ts +77 -6
  95. package/src/tools/__tests__/eval-description.test.ts +19 -0
  96. package/src/tools/ast-grep.ts +34 -12
  97. package/src/tools/browser/cmux/cmux-tab.ts +66 -24
  98. package/src/tools/browser/registry.ts +25 -0
  99. package/src/tools/browser/run-cancellation.ts +47 -0
  100. package/src/tools/browser/tab-supervisor.ts +120 -7
  101. package/src/tools/browser/tab-worker.ts +22 -10
  102. package/src/tools/browser.ts +1 -0
  103. package/src/tools/eval.ts +15 -10
  104. package/src/tools/grep.ts +11 -8
  105. package/src/tools/inspect-image.ts +1 -1
  106. package/src/tools/write.ts +49 -9
  107. package/src/utils/commit-message-generator.ts +0 -1
  108. package/src/utils/git.ts +22 -1
  109. package/src/utils/image-vision-fallback.ts +2 -3
  110. package/src/utils/title-generator.ts +1 -1
@@ -4,8 +4,8 @@
4
4
  * Layering:
5
5
  * - `matchModel` is the single matching engine. Order: exact `provider/id`
6
6
  * reference (with variant-alias and OpenRouter routed/date fallbacks) →
7
- * exact canonical id → exact bare idretired variant alias
8
- * provider-scoped fuzzy → substring with alias-vs-dated pick.
7
+ * exact bare id → retired variant aliasprovider-scoped fuzzy substring
8
+ * with alias-vs-dated pick.
9
9
  * - `parseModelPatternWithContext`/`parseModelPattern` layer the selector
10
10
  * grammar on top: trailing `:level` thinking suffixes (`splitThinkingSuffix`)
11
11
  * and `@upstream` provider routing (`splitUpstreamRouting`).
@@ -107,13 +107,6 @@ function splitThinkingSuffix(
107
107
  return level ? { base: pattern.slice(0, colonIdx), level } : { base: pattern };
108
108
  }
109
109
 
110
- function hasExactModelPattern(pattern: string, availableModels: readonly Model<Api>[]): boolean {
111
- const normalized = pattern.toLowerCase();
112
- return availableModels.some(
113
- model => model.id.toLowerCase() === normalized || `${model.provider}/${model.id}`.toLowerCase() === normalized,
114
- );
115
- }
116
-
117
110
  function matchingGlobModels(pattern: string, availableModels: readonly Model<Api>[]): Model<Api>[] {
118
111
  const glob = new Bun.Glob(pattern.toLowerCase());
119
112
  return availableModels.filter(model => {
@@ -444,11 +437,8 @@ export interface ModelMatchPreferences {
444
437
  deprioritizeProviders?: string[];
445
438
  }
446
439
 
447
- export type CanonicalModelRegistry = Partial<
448
- Pick<ModelRegistry, "resolveCanonicalModel" | "getCanonicalVariants" | "getCanonicalId">
449
- >;
450
- export type ModelLookupRegistry = Pick<ModelRegistry, "getAvailable"> & Partial<CanonicalModelRegistry>;
451
- type CliModelRegistry = Pick<ModelRegistry, "getAll"> & Partial<CanonicalModelRegistry>;
440
+ export type ModelLookupRegistry = Pick<ModelRegistry, "getAvailable">;
441
+ type CliModelRegistry = Pick<ModelRegistry, "getAll">;
452
442
  type InitialModelRegistry = Pick<ModelRegistry, "getAvailable" | "find">;
453
443
  type RestorableModelRegistry = Pick<ModelRegistry, "getAvailable" | "find" | "getApiKey">;
454
444
 
@@ -579,7 +569,6 @@ function includeSyntheticAllowedModels(available: Model<Api>[], allowedModels: I
579
569
 
580
570
  /**
581
571
  * Find an exact explicit provider/model match.
582
- * Bare model ids are handled separately so canonical ids can coalesce variants.
583
572
  */
584
573
  function findExactModelReferenceMatch(modelReference: string, availableModels: Model<Api>[]): Model<Api> | undefined {
585
574
  const trimmedReference = modelReference.trim();
@@ -597,52 +586,25 @@ function findExactModelReferenceMatch(modelReference: string, availableModels: M
597
586
  }
598
587
  return undefined;
599
588
  }
600
-
601
- function findExactCanonicalModelMatch(
602
- modelReference: string,
603
- availableModels: Model<Api>[],
604
- modelRegistry: CanonicalModelRegistry | undefined,
605
- ): Model<Api> | undefined {
606
- if (!modelRegistry) {
607
- return undefined;
608
- }
609
- const trimmedReference = modelReference.trim();
610
- if (!trimmedReference || trimmedReference.includes("/")) {
611
- return undefined;
612
- }
613
- return modelRegistry.resolveCanonicalModel?.(trimmedReference, {
614
- availableOnly: false,
615
- candidates: availableModels,
616
- });
617
- }
618
-
619
589
  /**
620
590
  * The single model-matching engine. Tries, in order:
621
591
  * 1. exact `provider/id` reference (variant-alias and OpenRouter routed/date
622
592
  * fallbacks included),
623
-
624
- * 2. exact canonical id (coalesces provider variants),
625
- * 3. exact bare id (preference-ranked),
626
- * 4. retired effort-tier variant alias (collapsed catalog entries),
627
- * 5. provider-scoped fuzzy match,
628
- * 6. substring match with the alias-vs-dated pick.
593
+ * 2. exact bare id (preference-ranked),
594
+ * 3. retired effort-tier variant alias (collapsed catalog entries),
595
+ * 4. provider-scoped fuzzy match,
596
+ * 5. substring match with the alias-vs-dated pick.
629
597
  * Returns the matched model or undefined if no match found.
630
598
  */
631
599
  function matchModel(
632
600
  modelPattern: string,
633
601
  availableModels: Model<Api>[],
634
602
  context: ModelPreferenceContext,
635
- options?: { modelRegistry?: CanonicalModelRegistry },
636
603
  ): Model<Api> | undefined {
637
604
  const exactRefMatch = findExactModelReferenceMatch(modelPattern, availableModels);
638
605
  if (exactRefMatch) {
639
606
  return exactRefMatch;
640
607
  }
641
- // Exact canonical ids coalesce provider variants before bare-id matching.
642
- const exactCanonicalMatch = findExactCanonicalModelMatch(modelPattern, availableModels, options?.modelRegistry);
643
- if (exactCanonicalMatch) {
644
- return exactCanonicalMatch;
645
- }
646
608
 
647
609
  // Exact ID match (case-insensitive) — this must happen before provider-scoped
648
610
  // fuzzy matching so raw IDs that contain slashes (for example OpenRouter model
@@ -773,10 +735,10 @@ function parseModelPatternWithContext(
773
735
  pattern: string,
774
736
  availableModels: Model<Api>[],
775
737
  context: ModelPreferenceContext,
776
- options?: { allowInvalidThinkingSelectorFallback?: boolean; modelRegistry?: CanonicalModelRegistry },
738
+ options?: { allowInvalidThinkingSelectorFallback?: boolean },
777
739
  ): ParsedModelResult {
778
740
  // Try exact match first
779
- const exactMatch = matchModel(pattern, availableModels, context, options);
741
+ const exactMatch = matchModel(pattern, availableModels, context);
780
742
  if (exactMatch) {
781
743
  return { model: exactMatch, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
782
744
  }
@@ -830,7 +792,7 @@ export function parseModelPattern(
830
792
  pattern: string,
831
793
  availableModels: Model<Api>[],
832
794
  preferences?: ModelMatchPreferences,
833
- options?: { allowInvalidThinkingSelectorFallback?: boolean; modelRegistry?: CanonicalModelRegistry },
795
+ options?: { allowInvalidThinkingSelectorFallback?: boolean },
834
796
  ): ParsedModelResult {
835
797
  const context = buildPreferenceContext(availableModels, preferences);
836
798
  const direct = parseModelPatternWithContext(pattern, availableModels, context, options);
@@ -1035,7 +997,7 @@ export interface ResolvedModelRoleValue {
1035
997
  export function resolveModelRoleValue(
1036
998
  roleValue: string | undefined,
1037
999
  availableModels: Model<Api>[],
1038
- options?: { settings?: Settings; matchPreferences?: ModelMatchPreferences; modelRegistry?: CanonicalModelRegistry },
1000
+ options?: { settings?: Settings; matchPreferences?: ModelMatchPreferences },
1039
1001
  ): ResolvedModelRoleValue {
1040
1002
  if (!roleValue) {
1041
1003
  return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
@@ -1054,9 +1016,7 @@ export function resolveModelRoleValue(
1054
1016
  let warning: string | undefined;
1055
1017
  const matchPreferences = mergeModelMatchPreferences(options?.settings, options?.matchPreferences);
1056
1018
  for (const effectivePattern of effectivePatterns) {
1057
- const resolved = parseModelPattern(effectivePattern, availableModels, matchPreferences, {
1058
- modelRegistry: options?.modelRegistry,
1059
- });
1019
+ const resolved = parseModelPattern(effectivePattern, availableModels, matchPreferences);
1060
1020
  if (resolved.model) {
1061
1021
  return {
1062
1022
  model: resolved.model,
@@ -1121,7 +1081,6 @@ export function resolveModelFromString(
1121
1081
  value: string,
1122
1082
  available: Model<Api>[],
1123
1083
  matchPreferences?: ModelMatchPreferences,
1124
- modelRegistry?: CanonicalModelRegistry,
1125
1084
  ): Model<Api> | undefined {
1126
1085
  const exact = available.find(model => `${model.provider}/${model.id}` === value);
1127
1086
  if (exact) return exact;
@@ -1133,7 +1092,7 @@ export function resolveModelFromString(
1133
1092
  const parsedExact = available.find(model => model.provider === parsed.provider && model.id === parsed.id);
1134
1093
  if (parsedExact) return parsedExact;
1135
1094
  }
1136
- return parseModelPattern(value, available, matchPreferences, { modelRegistry }).model;
1095
+ return parseModelPattern(value, available, matchPreferences).model;
1137
1096
  }
1138
1097
 
1139
1098
  /**
@@ -1144,9 +1103,8 @@ export function resolveModelFromSettings(options: {
1144
1103
  availableModels: Model<Api>[];
1145
1104
  matchPreferences?: ModelMatchPreferences;
1146
1105
  roleOrder?: readonly ModelRole[];
1147
- modelRegistry?: CanonicalModelRegistry;
1148
1106
  }): Model<Api> | undefined {
1149
- const { settings, availableModels, matchPreferences, roleOrder, modelRegistry } = options;
1107
+ const { settings, availableModels, matchPreferences, roleOrder } = options;
1150
1108
  const roles = roleOrder ?? MODEL_ROLE_IDS;
1151
1109
  let sawConfiguredProviderQualifiedRole = false;
1152
1110
  for (const role of roles) {
@@ -1156,7 +1114,7 @@ export function resolveModelFromSettings(options: {
1156
1114
  if (expanded.includes("/")) {
1157
1115
  sawConfiguredProviderQualifiedRole = true;
1158
1116
  }
1159
- const resolved = resolveModelFromString(expanded, availableModels, matchPreferences, modelRegistry);
1117
+ const resolved = resolveModelFromString(expanded, availableModels, matchPreferences);
1160
1118
  if (resolved) return resolved;
1161
1119
  }
1162
1120
  return sawConfiguredProviderQualifiedRole ? undefined : availableModels[0];
@@ -1177,7 +1135,6 @@ export function resolveModelOverride(
1177
1135
  const { model, thinkingLevel, explicitThinkingLevel } = resolveModelRoleValue(pattern, availableModels, {
1178
1136
  settings,
1179
1137
  matchPreferences,
1180
- modelRegistry,
1181
1138
  });
1182
1139
  if (model) {
1183
1140
  return { model, thinkingLevel, explicitThinkingLevel };
@@ -1250,14 +1207,12 @@ export function resolveRoleSelection(
1250
1207
  roles: readonly string[],
1251
1208
  settings: Settings,
1252
1209
  availableModels: Model<Api>[],
1253
- modelRegistry?: CanonicalModelRegistry,
1254
1210
  ): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
1255
1211
  const matchPreferences = getModelMatchPreferences(settings);
1256
1212
  for (const role of roles) {
1257
1213
  const resolved = resolveModelRoleValue(settings.getModelRole(role), availableModels, {
1258
1214
  settings,
1259
1215
  matchPreferences,
1260
- modelRegistry,
1261
1216
  });
1262
1217
  if (resolved.model) {
1263
1218
  return { model: resolved.model, thinkingLevel: resolved.thinkingLevel };
@@ -1277,37 +1232,14 @@ export function resolveRoleSelection(
1277
1232
  export function resolveAdvisorRoleSelection(
1278
1233
  settings: Settings,
1279
1234
  availableModels: Model<Api>[],
1280
- modelRegistry?: CanonicalModelRegistry,
1281
1235
  ): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
1282
1236
  const resolved = resolveModelRoleValue(`${PREFIX_MODEL_ROLE}advisor`, availableModels, {
1283
1237
  settings,
1284
1238
  matchPreferences: getModelMatchPreferences(settings),
1285
- modelRegistry,
1286
1239
  });
1287
1240
  return resolved.model ? { model: resolved.model, thinkingLevel: resolved.thinkingLevel } : undefined;
1288
1241
  }
1289
1242
 
1290
- function resolveExactCanonicalScopePattern(
1291
- pattern: string,
1292
- modelRegistry: Pick<ModelRegistry, "getCanonicalVariants">,
1293
- availableModels: Model<Api>[],
1294
- ): { models: Model<Api>[]; thinkingLevel?: ThinkingLevel; explicitThinkingLevel: boolean } | undefined {
1295
- if (pattern.endsWith(":max") && hasExactModelPattern(pattern, availableModels)) {
1296
- return undefined;
1297
- }
1298
- const { base: canonicalId, level: thinkingLevel } = splitThinkingSuffix(pattern, -1, MAX_THINKING_SUFFIX_OPTIONS);
1299
- const explicitThinkingLevel = thinkingLevel !== undefined;
1300
-
1301
- const variants = modelRegistry
1302
- .getCanonicalVariants(canonicalId, { availableOnly: true, candidates: availableModels })
1303
- .map(variant => variant.model);
1304
- if (variants.length === 0) {
1305
- return undefined;
1306
- }
1307
-
1308
- return { models: variants, thinkingLevel, explicitThinkingLevel };
1309
- }
1310
-
1311
1243
  /**
1312
1244
  * Resolve model patterns to actual Model objects with optional thinking levels
1313
1245
  * Format: "pattern:level" where :level is optional
@@ -1321,7 +1253,7 @@ function resolveExactCanonicalScopePattern(
1321
1253
  */
1322
1254
  export async function resolveModelScope(
1323
1255
  patterns: string[],
1324
- modelRegistry: Pick<ModelRegistry, "getAvailable" | "getCanonicalVariants">,
1256
+ modelRegistry: Pick<ModelRegistry, "getAvailable">,
1325
1257
  preferences?: ModelMatchPreferences,
1326
1258
  ): Promise<ScopedModel[]> {
1327
1259
  const availableModels = modelRegistry.getAvailable();
@@ -1360,19 +1292,10 @@ export async function resolveModelScope(
1360
1292
  continue;
1361
1293
  }
1362
1294
 
1363
- const exactCanonical = resolveExactCanonicalScopePattern(pattern, modelRegistry, availableModels);
1364
- if (exactCanonical) {
1365
- for (const model of exactCanonical.models) {
1366
- addScopedModel(model, exactCanonical.thinkingLevel, exactCanonical.explicitThinkingLevel);
1367
- }
1368
- continue;
1369
- }
1370
-
1371
1295
  const { model, thinkingLevel, warning, explicitThinkingLevel } = parseModelPatternWithContext(
1372
1296
  pattern,
1373
1297
  availableModels,
1374
1298
  context,
1375
- { modelRegistry },
1376
1299
  );
1377
1300
 
1378
1301
  if (warning) {
@@ -1403,7 +1326,7 @@ export async function resolveModelScope(
1403
1326
  * falling back to the global default (see issue #1022).
1404
1327
  */
1405
1328
  export async function resolveAllowedModels(
1406
- modelRegistry: Pick<ModelRegistry, "getAvailable" | "getCanonicalVariants">,
1329
+ modelRegistry: Pick<ModelRegistry, "getAvailable">,
1407
1330
  settings: Settings | undefined,
1408
1331
  preferences?: ModelMatchPreferences,
1409
1332
  ): Promise<Model<Api>[]> {
@@ -1429,7 +1352,6 @@ export async function resolveAllowedModels(
1429
1352
  * `enabledModels` scope semantics as startup resolution:
1430
1353
  *
1431
1354
  * - Glob selectors match `provider/modelId` and bare model id
1432
- * - Exact canonical ids expand to all available concrete variants
1433
1355
  * - Exact `provider/modelId`, bare ids, provider-scoped fuzzy, and substring selectors
1434
1356
  * resolve through the shared model-pattern matcher
1435
1357
  * - Optional `:thinkingLevel` suffixes are stripped only when valid
@@ -1442,7 +1364,6 @@ export async function resolveAllowedModels(
1442
1364
  export function filterAvailableModelsByEnabledPatterns(
1443
1365
  available: Model<Api>[],
1444
1366
  patterns: readonly string[],
1445
- registry: Pick<ModelRegistry, "getCanonicalVariants">,
1446
1367
  ): Model<Api>[] {
1447
1368
  if (patterns.length === 0) return available;
1448
1369
 
@@ -1460,15 +1381,7 @@ export function filterAvailableModelsByEnabledPatterns(
1460
1381
  continue;
1461
1382
  }
1462
1383
 
1463
- const exactCanonical = resolveExactCanonicalScopePattern(pattern, registry, available);
1464
- if (exactCanonical) {
1465
- for (const model of exactCanonical.models) {
1466
- addAllowed(model);
1467
- }
1468
- continue;
1469
- }
1470
-
1471
- const { model } = parseModelPatternWithContext(pattern, available, context, { modelRegistry: registry });
1384
+ const { model } = parseModelPatternWithContext(pattern, available, context);
1472
1385
  if (model) {
1473
1386
  addAllowed(model);
1474
1387
  }
@@ -1533,21 +1446,6 @@ export function resolveCliModel(options: {
1533
1446
  // "zai/glm-5" on provider "vercel-ai-gateway" wins over provider "zai"
1534
1447
  // with id "glm-5", because Array.find returns the first catalog hit.
1535
1448
  let exact = findExactModelReferenceMatch(trimmedModel, availableModels);
1536
- if (!exact && !trimmedModel.includes(":")) {
1537
- // CLI flags address the full catalog, so unlike the engine's canonical
1538
- // step this lookup is unrestricted; the `:`-guard defers suffixed
1539
- // selectors (thinking levels, ollama-style ids) to the grammar below.
1540
- const canonicalMatch = modelRegistry.resolveCanonicalModel?.(trimmedModel, { availableOnly: false });
1541
- if (canonicalMatch) {
1542
- return {
1543
- model: canonicalMatch,
1544
- selector: modelRegistry.getCanonicalId?.(canonicalMatch) ?? trimmedModel,
1545
- warning: undefined,
1546
- thinkingLevel: undefined,
1547
- error: undefined,
1548
- };
1549
- }
1550
- }
1551
1449
  if (!exact) {
1552
1450
  // Flat exact id (or full selector) by catalog order: CLI resolution
1553
1451
  // stays deterministic across runs regardless of usage-based ranking.
@@ -1601,7 +1499,6 @@ export function resolveCliModel(options: {
1601
1499
  const candidates = provider ? availableModels.filter(model => model.provider === provider) : availableModels;
1602
1500
  const { model, thinkingLevel, warning, upstream } = parseModelPattern(pattern, candidates, preferences, {
1603
1501
  allowInvalidThinkingSelectorFallback: false,
1604
- modelRegistry,
1605
1502
  });
1606
1503
 
1607
1504
  if (!model) {
@@ -1616,15 +1513,6 @@ export function resolveCliModel(options: {
1616
1513
  }
1617
1514
 
1618
1515
  let selector = provider ? formatModelString(model) : undefined;
1619
- if (!provider) {
1620
- const canonicalCandidate = splitThinkingSuffix(pattern).base;
1621
- if (!canonicalCandidate.includes("/")) {
1622
- const canonicalResolved = modelRegistry.resolveCanonicalModel?.(canonicalCandidate, { availableOnly: false });
1623
- if (canonicalResolved && canonicalResolved.provider === model.provider && canonicalResolved.id === model.id) {
1624
- selector = modelRegistry.getCanonicalId?.(canonicalResolved) ?? canonicalCandidate;
1625
- }
1626
- }
1627
- }
1628
1516
  if (selector !== undefined && upstream) {
1629
1517
  selector = `${selector}@${upstream}`;
1630
1518
  }
@@ -1801,7 +1689,7 @@ export async function findSmolModel(
1801
1689
 
1802
1690
  // 1. Try saved model from settings
1803
1691
  if (savedModel) {
1804
- const match = resolveModelFromString(savedModel, availableModels, undefined, modelRegistry);
1692
+ const match = resolveModelFromString(savedModel, availableModels, undefined);
1805
1693
  if (match) return match;
1806
1694
  }
1807
1695
 
@@ -1812,7 +1700,7 @@ export async function findSmolModel(
1812
1700
  if (providerMatch) return providerMatch;
1813
1701
 
1814
1702
  // Try exact match first
1815
- const exactMatch = parseModelPattern(pattern, availableModels, undefined, { modelRegistry }).model;
1703
+ const exactMatch = parseModelPattern(pattern, availableModels, undefined).model;
1816
1704
  if (exactMatch) return exactMatch;
1817
1705
 
1818
1706
  // Try fuzzy match (substring)
@@ -1841,14 +1729,14 @@ export async function findSlowModel(
1841
1729
 
1842
1730
  // 1. Try saved model from settings
1843
1731
  if (savedModel) {
1844
- const match = resolveModelFromString(savedModel, availableModels, undefined, modelRegistry);
1732
+ const match = resolveModelFromString(savedModel, availableModels, undefined);
1845
1733
  if (match) return match;
1846
1734
  }
1847
1735
 
1848
1736
  // 2. Try priority chain
1849
1737
  for (const pattern of MODEL_PRIO.slow) {
1850
1738
  // Try exact match first
1851
- const exactMatch = parseModelPattern(pattern, availableModels, undefined, { modelRegistry }).model;
1739
+ const exactMatch = parseModelPattern(pattern, availableModels, undefined).model;
1852
1740
  if (exactMatch) return exactMatch;
1853
1741
 
1854
1742
  // Try fuzzy match (substring)
@@ -273,30 +273,8 @@ const ProviderConfigSchema = type({
273
273
  return true;
274
274
  });
275
275
 
276
- const EquivalenceConfigSchema = type({
277
- "overrides?": { "[string]": "string" },
278
- "exclude?": "string[]",
279
- }).narrow((value, ctx) => {
280
- if (value.overrides !== undefined) {
281
- for (const [, v] of Object.entries(value.overrides)) {
282
- if (typeof v === "string" && v.length === 0) {
283
- return ctx.mustBe("overrides values non-empty strings");
284
- }
285
- }
286
- }
287
- if (value.exclude !== undefined && Array.isArray(value.exclude)) {
288
- for (const item of value.exclude) {
289
- if (typeof item === "string" && item.length === 0) {
290
- return ctx.mustBe("exclude items non-empty strings");
291
- }
292
- }
293
- }
294
- return true;
295
- });
296
-
297
276
  export const ModelsConfigSchema = type({
298
277
  "providers?": { "[string]": ProviderConfigSchema },
299
- "equivalence?": EquivalenceConfigSchema,
300
278
  });
301
279
 
302
280
  export type ModelsConfig = typeof ModelsConfigSchema.infer;
@@ -29,6 +29,26 @@ prompt.registerHelper("jtdToTypeScript", (schema: unknown): string => {
29
29
  }
30
30
  });
31
31
 
32
+ /**
33
+ * Render a subagent output schema wrapped in the `yield` tool's
34
+ * `result: { data: … }` envelope so the model sees the shape it must
35
+ * actually submit, not just the user-facing payload. Without this the LLM
36
+ * pattern-matches on the bare interface and puts strings/objects directly
37
+ * in `result.data`, tripping schema validation repeatedly.
38
+ */
39
+ prompt.registerHelper("renderYieldSchema", (schema: unknown): string => {
40
+ let ts: string;
41
+ try {
42
+ ts = jtdToTypeScript(schema);
43
+ } catch {
44
+ ts = "unknown";
45
+ }
46
+ const lines = ts.split("\n");
47
+ const [first, ...rest] = lines;
48
+ const body = rest.length === 0 ? first : `${first}\n${rest.map(l => ` ${l}`).join("\n")}`;
49
+ return `result: {\n data: ${body};\n}`;
50
+ });
51
+
32
52
  const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/;
33
53
  const INLINE_ARG_TEMPLATE_PATTERN = /\{\{[\s\S]*?(?:\b(?:arguments|ARGUMENTS|args)\b|\barg\s+[^}]+)[\s\S]*?\}\}/;
34
54
 
@@ -285,6 +285,7 @@ const EMPTY_STRING_ARRAY: string[] = [];
285
285
  const EMPTY_STRING_RECORD: Record<string, string> = {};
286
286
  const EMPTY_NUMBER_RECORD: Record<string, number> = {};
287
287
  const DEFAULT_CYCLE_ORDER: string[] = ["smol", "default", "slow"];
288
+ const DEFAULT_TOOL_CALL_LOOP_EXEMPT_TOOLS: string[] = ["job", "irc"];
288
289
  const EMPTY_MODEL_TAGS_RECORD: ModelTagsSettings = {};
289
290
  const HINDSIGHT_RECALL_TYPES_DEFAULT: string[] = ["world", "experience"];
290
291
  export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
@@ -989,7 +990,7 @@ export const SETTINGS_SCHEMA = {
989
990
  tab: "model",
990
991
  group: "Thinking",
991
992
  label: "Loop Guard",
992
- description: "Enable automatic stream loop detection for Gemini and DeepSeek models",
993
+ description: "Enable automatic stream loop detection for model reasoning and prose",
993
994
  },
994
995
  },
995
996
 
@@ -1016,6 +1017,39 @@ export const SETTINGS_SCHEMA = {
1016
1017
  },
1017
1018
  },
1018
1019
 
1020
+ "model.toolCallLoopGuard.enabled": {
1021
+ type: "boolean",
1022
+ default: true,
1023
+ ui: {
1024
+ tab: "model",
1025
+ group: "Thinking",
1026
+ label: "Tool-Call Loop Guard",
1027
+ description: "Detect consecutive identical tool calls across turns and inject a corrective steer",
1028
+ },
1029
+ },
1030
+
1031
+ "model.toolCallLoopGuard.threshold": {
1032
+ type: "number",
1033
+ default: 5,
1034
+ ui: {
1035
+ tab: "model",
1036
+ group: "Thinking",
1037
+ label: "Tool-Call Loop Threshold",
1038
+ description: "Consecutive identical tool calls required before the corrective steer is injected",
1039
+ },
1040
+ },
1041
+
1042
+ "model.toolCallLoopGuard.exemptTools": {
1043
+ type: "array",
1044
+ default: DEFAULT_TOOL_CALL_LOOP_EXEMPT_TOOLS,
1045
+ ui: {
1046
+ tab: "model",
1047
+ group: "Thinking",
1048
+ label: "Tool-Call Loop Exempt Tools",
1049
+ description: "Tool names that may repeat consecutively without triggering the cross-turn loop guard",
1050
+ },
1051
+ },
1052
+
1019
1053
  inlineToolDescriptors: {
1020
1054
  type: "enum",
1021
1055
  values: ["auto", "on", "off"] as const,
@@ -172,19 +172,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
172
172
  return !!value && typeof value === "object" && !Array.isArray(value);
173
173
  }
174
174
 
175
- function shallowStringRecord(value: unknown): Record<string, string> {
176
- if (!isRecord(value)) return {};
177
-
178
- const result: Record<string, string> = {};
179
- for (const key in value) {
180
- if (!Object.hasOwn(value, key)) continue;
181
- const item = value[key];
182
- if (typeof item === "string") {
183
- result[key] = item;
184
- }
185
- }
186
- return result;
187
- }
175
+ type EditVariantEntry = {
176
+ patternLower: string;
177
+ mode: EditMode;
178
+ };
188
179
 
189
180
  function resolvePathScopedStringArray(settingPath: SettingPath, value: unknown, cwd: string): string[] | undefined {
190
181
  if (!PATH_SCOPED_ARRAY_SETTINGS.has(settingPath) || !Array.isArray(value)) return undefined;
@@ -247,6 +238,7 @@ export class Settings {
247
238
  #merged: RawSettings = {};
248
239
  /** Cached resolved values from the merged view, including defaults/path scoping */
249
240
  #resolvedCache = new Map<SettingPath, unknown>();
241
+ #editVariantCache: readonly EditVariantEntry[] | undefined;
250
242
 
251
243
  /** Paths modified during this session (for partial save) */
252
244
  #modified = new Set<string>();
@@ -536,19 +528,44 @@ export class Settings {
536
528
  */
537
529
  getEditVariantForModel(model: string | undefined): EditMode | null {
538
530
  if (!model) return null;
539
- const variants = shallowStringRecord(getByPath(this.#merged, ["edit", "modelVariants"]));
531
+ const variants = this.#getEditVariantEntries();
532
+ if (variants.length === 0) return null;
533
+
540
534
  const modelLower = model.toLowerCase();
541
- for (const pattern in variants) {
542
- if (modelLower.includes(pattern.toLowerCase())) {
543
- const value = normalizeEditMode(variants[pattern]);
544
- if (value) {
545
- return value;
546
- }
535
+
536
+ for (let i = 0; i < variants.length; i++) {
537
+ const variant = variants[i];
538
+ if (modelLower.includes(variant.patternLower)) {
539
+ return variant.mode;
547
540
  }
548
541
  }
549
542
  return null;
550
543
  }
551
544
 
545
+ #getEditVariantEntries(): readonly EditVariantEntry[] {
546
+ if (this.#editVariantCache !== undefined) return this.#editVariantCache;
547
+
548
+ const value = getByPath(this.#merged, ["edit", "modelVariants"]);
549
+ if (!isRecord(value)) {
550
+ this.#editVariantCache = [];
551
+ return this.#editVariantCache;
552
+ }
553
+
554
+ const variants: EditVariantEntry[] = [];
555
+ for (const pattern in value) {
556
+ if (!Object.hasOwn(value, pattern)) continue;
557
+ const rawMode = value[pattern];
558
+ if (typeof rawMode !== "string") continue;
559
+ const mode = normalizeEditMode(rawMode);
560
+ if (mode) {
561
+ variants.push({ patternLower: pattern.toLowerCase(), mode });
562
+ }
563
+ }
564
+
565
+ this.#editVariantCache = variants;
566
+ return variants;
567
+ }
568
+
552
569
  /**
553
570
  * Get bash interceptor rules (typed accessor for complex array config).
554
571
  */
@@ -556,11 +573,26 @@ export class Settings {
556
573
  return this.get("bashInterceptor.patterns");
557
574
  }
558
575
 
576
+ #modelRolesFromLayer(layer: RawSettings): Record<string, string> {
577
+ const value = getByPath(layer, ["modelRoles"]);
578
+ if (!isRecord(value)) return {};
579
+
580
+ const roles: Record<string, string> = {};
581
+ for (const role in value) {
582
+ if (!Object.hasOwn(value, role)) continue;
583
+ const modelId = value[role];
584
+ if (typeof modelId === "string") {
585
+ roles[role] = modelId;
586
+ }
587
+ }
588
+ return roles;
589
+ }
590
+
559
591
  /**
560
592
  * Set a model role (helper for modelRoles record).
561
593
  */
562
594
  setModelRole(role: ModelRole | string, modelId: string): void {
563
- const current = shallowStringRecord(getByPath(this.#global, ["modelRoles"]));
595
+ const current = this.#modelRolesFromLayer(this.#global);
564
596
  const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
565
597
  const updateRuntimeOverride =
566
598
  !!runtimeOverrides &&
@@ -568,10 +600,13 @@ export class Settings {
568
600
  !Array.isArray(runtimeOverrides) &&
569
601
  Object.hasOwn(runtimeOverrides, role);
570
602
 
571
- this.set("modelRoles", { ...current, [role]: modelId });
603
+ current[role] = modelId;
604
+ this.set("modelRoles", current);
572
605
 
573
606
  if (updateRuntimeOverride) {
574
- this.override("modelRoles", { ...shallowStringRecord(runtimeOverrides), [role]: modelId });
607
+ const nextRuntimeOverride = this.#modelRolesFromLayer(this.#overrides);
608
+ nextRuntimeOverride[role] = modelId;
609
+ this.override("modelRoles", nextRuntimeOverride);
575
610
  }
576
611
  }
577
612
 
@@ -594,7 +629,7 @@ export class Settings {
594
629
  * Override model roles (helper for modelRoles record).
595
630
  */
596
631
  overrideModelRoles(roles: ReadOnlyDict<string>): void {
597
- const next = shallowStringRecord(getByPath(this.#overrides, ["modelRoles"]));
632
+ const next = this.#modelRolesFromLayer(this.#overrides);
598
633
  for (const [role, modelId] of Object.entries(roles)) {
599
634
  if (modelId) {
600
635
  next[role] = modelId;
@@ -1281,6 +1316,7 @@ export class Settings {
1281
1316
  this.#merged = this.#deepMerge(this.#merged, this.#configOverlay);
1282
1317
  this.#merged = this.#deepMerge(this.#merged, this.#overrides);
1283
1318
  this.#resolvedCache.clear();
1319
+ this.#editVariantCache = undefined;
1284
1320
  }
1285
1321
 
1286
1322
  #fireAllHooks(): void {
@@ -196,15 +196,29 @@ describe("runEvalAgent", () => {
196
196
  await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "" }) })).rejects.toThrow(
197
197
  "spawns disabled",
198
198
  );
199
- await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "reviewer" }) })).rejects.toThrow(
200
- "Allowed: reviewer",
201
- );
199
+ await expect(
200
+ runEvalAgent({ prompt: "hello", agent: "task" }, { session: makeSession({ spawns: "reviewer" }) }),
201
+ ).rejects.toThrow("Allowed: reviewer");
202
202
  await expect(
203
203
  runEvalAgent({ prompt: "hello" }, { session: makeSession({ depth: EVAL_AGENT_MAX_DEPTH }) }),
204
204
  ).rejects.toThrow("maximum depth");
205
205
  expect(runSpy).not.toHaveBeenCalled();
206
206
  });
207
207
 
208
+ it("defaults to the first allowed spawn under restricted eval policies", async () => {
209
+ mockAgents();
210
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
211
+ singleResult(options, {
212
+ output: options.agent.name,
213
+ }),
214
+ );
215
+
216
+ const result = await runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "reviewer,task" }) });
217
+
218
+ expect(result.text).toBe("reviewer");
219
+ expect(runSpy.mock.calls[0]?.[0].agent.name).toBe("reviewer");
220
+ });
221
+
208
222
  it("throws instead of spawning from plan mode", async () => {
209
223
  mockAgents();
210
224
  const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));