@oh-my-pi/pi-coding-agent 16.2.11 → 16.2.13

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 (89) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/cli.js +2982 -2825
  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 +12 -6
  10. package/dist/types/config/models-config.d.ts +9 -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/lsp/client.d.ts +9 -3
  15. package/dist/types/modes/controllers/tool-args-reveal.d.ts +0 -5
  16. package/dist/types/task/spawn-policy.d.ts +17 -0
  17. package/dist/types/task/spawn-policy.test.d.ts +1 -0
  18. package/dist/types/task/types.d.ts +8 -2
  19. package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
  20. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
  21. package/dist/types/tools/browser/registry.d.ts +2 -0
  22. package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
  23. package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
  24. package/dist/types/tools/eval.d.ts +3 -6
  25. package/dist/types/tools/write.d.ts +1 -1
  26. package/dist/types/utils/image-vision-fallback.d.ts +1 -1
  27. package/package.json +12 -12
  28. package/src/advisor/__tests__/advisor.test.ts +1 -1
  29. package/src/auto-thinking/classifier.ts +1 -1
  30. package/src/cli/bench-cli.ts +3 -12
  31. package/src/cli/dry-balance-cli.ts +1 -6
  32. package/src/cli/models-cli.ts +3 -81
  33. package/src/commands/models.ts +1 -2
  34. package/src/commit/model-selection.ts +4 -4
  35. package/src/config/inline-tool-descriptors-mode.ts +1 -2
  36. package/src/config/model-discovery.ts +32 -6
  37. package/src/config/model-registry.ts +11 -225
  38. package/src/config/model-resolver.ts +23 -135
  39. package/src/config/models-config-schema.ts +13 -22
  40. package/src/config/prompt-templates.ts +20 -0
  41. package/src/config/settings-schema.ts +35 -1
  42. package/src/config/settings.ts +60 -24
  43. package/src/eval/__tests__/agent-bridge.test.ts +17 -3
  44. package/src/eval/agent-bridge.ts +7 -9
  45. package/src/eval/completion-bridge.ts +1 -1
  46. package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
  47. package/src/eval/py/runner.py +169 -18
  48. package/src/extensibility/extensions/model-api.ts +1 -3
  49. package/src/extensibility/extensions/runner.ts +62 -5
  50. package/src/lsp/client.ts +162 -45
  51. package/src/lsp/index.ts +31 -21
  52. package/src/main.ts +0 -1
  53. package/src/mcp/transports/http.ts +19 -17
  54. package/src/mcp/transports/stdio.test.ts +51 -1
  55. package/src/mcp/transports/stdio.ts +19 -9
  56. package/src/memories/index.ts +0 -1
  57. package/src/mnemopi/backend.ts +1 -1
  58. package/src/modes/components/model-selector.ts +32 -186
  59. package/src/modes/controllers/event-controller.ts +8 -39
  60. package/src/modes/controllers/selector-controller.ts +0 -1
  61. package/src/modes/controllers/tool-args-reveal.ts +0 -12
  62. package/src/prompts/system/subagent-system-prompt.md +2 -2
  63. package/src/prompts/system/tool-call-loop-redirect.md +8 -0
  64. package/src/prompts/tools/eval.md +2 -2
  65. package/src/prompts/tools/ssh.md +1 -0
  66. package/src/prompts/tools/task.md +1 -1
  67. package/src/sdk.ts +2 -9
  68. package/src/session/agent-session.ts +89 -8
  69. package/src/session/session-context.ts +2 -1
  70. package/src/session/session-manager.ts +2 -1
  71. package/src/session/unexpected-stop-classifier.ts +1 -1
  72. package/src/task/index.ts +23 -27
  73. package/src/task/spawn-policy.test.ts +63 -0
  74. package/src/task/spawn-policy.ts +58 -0
  75. package/src/task/types.ts +77 -6
  76. package/src/tools/__tests__/eval-description.test.ts +19 -0
  77. package/src/tools/browser/cmux/cmux-tab.ts +66 -24
  78. package/src/tools/browser/registry.ts +25 -0
  79. package/src/tools/browser/run-cancellation.ts +47 -0
  80. package/src/tools/browser/tab-supervisor.ts +120 -7
  81. package/src/tools/browser/tab-worker.ts +22 -10
  82. package/src/tools/browser.ts +1 -0
  83. package/src/tools/eval.ts +15 -10
  84. package/src/tools/inspect-image.ts +1 -1
  85. package/src/tools/ssh.ts +8 -1
  86. package/src/tools/write.ts +49 -9
  87. package/src/utils/commit-message-generator.ts +0 -1
  88. package/src/utils/image-vision-fallback.ts +2 -3
  89. package/src/utils/title-generator.ts +1 -1
@@ -575,17 +575,27 @@ export class StdioTransport implements MCPTransport {
575
575
 
576
576
  const stdin = this.#process.stdin;
577
577
  const message = `${JSON.stringify(request)}\n`;
578
- try {
579
- // Await both: Bun's FileSink can surface a broken pipe either as a
580
- // synchronous throw or as a rejected Promise (the EPIPE arrives on a
581
- // processTicksAndRejections tick). Awaiting funnels both into this catch
582
- // so the request rejects cleanly instead of leaving a floating rejected
583
- // promise that crashes the process via the unhandledRejection handler.
584
- await stdin.write(message);
585
- await stdin.flush();
586
- } catch (error: unknown) {
578
+ const failFromSend = (error: unknown) => {
579
+ if (settled) return;
587
580
  cleanup();
588
581
  reject(error instanceof Error ? error : new Error(String(error)));
582
+ };
583
+ try {
584
+ // Never `await` write/flush. Bun's FileSink returns a pending Promise
585
+ // once the OS pipe buffer fills (default ~64 KB on POSIX), and a
586
+ // subprocess that stops draining stdin will park those awaits forever.
587
+ // Awaiting here would keep the async fn stuck above `return promise`,
588
+ // past the timeout timer and the abort handler, orphaning the deferred
589
+ // rejection and hanging the caller (#3945). Route sync throws (Windows
590
+ // EPIPE) and async rejections (POSIX EPIPE on processTicksAndRejections)
591
+ // into `reject()` while leaving the returned promise free to settle
592
+ // from the response, timer, abort signal, or read-loop transport-close.
593
+ const wrote = stdin.write(message);
594
+ if (isThenable(wrote)) wrote.then(undefined, failFromSend);
595
+ const flushed = stdin.flush();
596
+ if (isThenable(flushed)) flushed.then(undefined, failFromSend);
597
+ } catch (error) {
598
+ failFromSend(error);
589
599
  }
590
600
 
591
601
  return promise;
@@ -1219,7 +1219,6 @@ async function resolveMemoryModel(options: {
1219
1219
  const resolved = resolveModelRoleValue(requestedModel, modelRegistry.getAll(), {
1220
1220
  settings: session.settings,
1221
1221
  matchPreferences: getModelMatchPreferences(session.settings),
1222
- modelRegistry,
1223
1222
  });
1224
1223
  if (resolved.model) return resolved.model;
1225
1224
  }
@@ -511,7 +511,7 @@ async function resolveMnemopiProviderOptions(
511
511
  }
512
512
 
513
513
  try {
514
- const resolved = resolveRoleSelection(["tiny", "smol"], settings, modelRegistry.getAvailable(), modelRegistry);
514
+ const resolved = resolveRoleSelection(["tiny", "smol"], settings, modelRegistry.getAvailable());
515
515
  const model = resolved?.model;
516
516
  if (!model) {
517
517
  logger.warn("Mnemopi: llmMode=smol but no tiny/smol model resolved; continuing without LLM.");
@@ -58,21 +58,6 @@ function makeRoleBadgeToken(label: string, color: ThemeColor, assigned: RoleAssi
58
58
  return `${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`;
59
59
  }
60
60
 
61
- function normalizeSearchText(value: string): string {
62
- return value
63
- .toLowerCase()
64
- .replace(/[^a-z0-9]+/g, " ")
65
- .trim();
66
- }
67
-
68
- function compactSearchText(value: string): string {
69
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
70
- }
71
-
72
- function getAlphaSearchTokens(query: string): string[] {
73
- return [...normalizeSearchText(query).matchAll(/[a-z]+/g)].map(match => match[0]).filter(token => token.length > 0);
74
- }
75
-
76
61
  function computeModelRank(model: Model, roles: Record<string, RoleAssignment | undefined>): number {
77
62
  let i = 0;
78
63
  while (i < MODEL_ROLE_IDS.length) {
@@ -94,17 +79,6 @@ interface ModelItem {
94
79
  selector: string;
95
80
  }
96
81
 
97
- interface CanonicalModelItem {
98
- kind: "canonical";
99
- id: string;
100
- model: Model;
101
- selector: string;
102
- variantCount: number;
103
- searchText: string;
104
- normalizedSearchText: string;
105
- compactSearchText: string;
106
- }
107
-
108
82
  interface ScopedModelItem {
109
83
  model: Model;
110
84
  thinkingLevel?: string;
@@ -134,12 +108,8 @@ interface ProviderTabState {
134
108
  providerId?: string;
135
109
  }
136
110
  const ALL_TAB = "ALL";
137
- const CANONICAL_TAB = "CANONICAL";
138
111
 
139
- const STATIC_PROVIDER_TABS: ProviderTabState[] = [
140
- { id: ALL_TAB, label: ALL_TAB },
141
- { id: CANONICAL_TAB, label: CANONICAL_TAB },
142
- ];
112
+ const STATIC_PROVIDER_TABS: ProviderTabState[] = [{ id: ALL_TAB, label: ALL_TAB }];
143
113
 
144
114
  const MODEL_TAB_REFRESH_DEBOUNCE_MS = 120;
145
115
 
@@ -168,8 +138,6 @@ export class ModelSelectorComponent extends Container {
168
138
  #menuContainer: Container;
169
139
  #allModels: ModelItem[] = [];
170
140
  #filteredModels: ModelItem[] = [];
171
- #canonicalModels: CanonicalModelItem[] = [];
172
- #filteredCanonicalModels: CanonicalModelItem[] = [];
173
141
  #selectedIndex: number = 0;
174
142
  #roles = {} as Record<string, RoleAssignment | undefined>;
175
143
  #settings = null as unknown as Settings;
@@ -297,8 +265,7 @@ export class ModelSelectorComponent extends Container {
297
265
  // Hydrate synchronously from the current registry snapshot so the first
298
266
  // Enter after opening the selector acts on cached models instead of being
299
267
  // dropped while the offline refresh promise is still pending. This stays
300
- // on the open path, so it must remain cheap — heavy lifting lives in the
301
- // registry's one-pass getCanonicalModelSelections.
268
+ // on the open path, so it must remain cheap.
302
269
  this.#syncFromRegistryState();
303
270
 
304
271
  // Reconcile with cached discovery state in the background. A --models
@@ -342,7 +309,6 @@ export class ModelSelectorComponent extends Container {
342
309
  const resolved = resolveModelRoleValue(roleValue, allModels, {
343
310
  settings: this.#settings,
344
311
  matchPreferences,
345
- modelRegistry: this.#modelRegistry,
346
312
  });
347
313
  if (resolved.model) {
348
314
  nextRoles[role] = {
@@ -360,7 +326,6 @@ export class ModelSelectorComponent extends Container {
360
326
  const resolved = resolveModelRoleValue(`pi/${role}`, candidates, {
361
327
  settings: this.#settings,
362
328
  matchPreferences,
363
- modelRegistry: this.#modelRegistry,
364
329
  });
365
330
  if (!resolved.model) continue;
366
331
  nextRoles[role] = {
@@ -444,30 +409,6 @@ export class ModelSelectorComponent extends Container {
444
409
  });
445
410
  }
446
411
 
447
- #sortCanonicalModels(models: CanonicalModelItem[], { skipRoleRank = false }: { skipRoleRank?: boolean } = {}): void {
448
- const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? [];
449
- const mruIndex = new Map(mruOrder.map((key, i) => [key, i]));
450
-
451
- const modelRank = (item: CanonicalModelItem) => computeModelRank(item.model, this.#roles);
452
-
453
- models.sort((a, b) => {
454
- if (!skipRoleRank) {
455
- const aRank = modelRank(a);
456
- const bRank = modelRank(b);
457
- if (aRank !== bRank) return aRank - bRank;
458
- }
459
-
460
- const aMru = mruIndex.get(`${a.model.provider}/${a.model.id}`) ?? Number.MAX_SAFE_INTEGER;
461
- const bMru = mruIndex.get(`${b.model.provider}/${b.model.id}`) ?? Number.MAX_SAFE_INTEGER;
462
- if (aMru !== bMru) return aMru - bMru;
463
-
464
- const providerCmp = a.model.provider.localeCompare(b.model.provider);
465
- if (providerCmp !== 0) return providerCmp;
466
-
467
- return a.id.localeCompare(b.id);
468
- });
469
- }
470
-
471
412
  #loadModelsFromCurrentRegistryState(): void {
472
413
  let models: ModelItem[];
473
414
  if (this.#scopedModels.length > 0) {
@@ -498,8 +439,6 @@ export class ModelSelectorComponent extends Container {
498
439
  } catch (error) {
499
440
  this.#allModels = [];
500
441
  this.#filteredModels = [];
501
- this.#canonicalModels = [];
502
- this.#filteredCanonicalModels = [];
503
442
  this.#errorMessage = error instanceof Error ? error.message : String(error);
504
443
  return;
505
444
  }
@@ -507,42 +446,14 @@ export class ModelSelectorComponent extends Container {
507
446
 
508
447
  const candidates = models.map(item => item.model);
509
448
  this.#loadRoleModels(candidates);
510
- const canonicalSelections = this.#modelRegistry.getCanonicalModelSelections({
511
- availableOnly: this.#scopedModels.length === 0,
512
- candidates,
513
- });
514
- const canonicalModels = canonicalSelections.map(({ record, model: selectedModel }): CanonicalModelItem => {
515
- const searchText = [
516
- record.id,
517
- record.name,
518
- selectedModel.provider,
519
- selectedModel.id,
520
- selectedModel.name,
521
- ...record.variants.flatMap(variant => [variant.selector, variant.model.name]),
522
- ].join(" ");
523
- return {
524
- kind: "canonical",
525
- id: record.id,
526
- model: selectedModel,
527
- selector: record.id,
528
- variantCount: record.variants.length,
529
- searchText,
530
- normalizedSearchText: normalizeSearchText(searchText),
531
- compactSearchText: compactSearchText(searchText),
532
- };
533
- });
534
449
 
535
450
  this.#sortModels(models);
536
- this.#sortCanonicalModels(canonicalModels);
537
451
 
538
452
  this.#allModels = models;
539
453
  this.#filteredModels = models;
540
- this.#canonicalModels = canonicalModels;
541
- this.#filteredCanonicalModels = canonicalModels;
542
- const visibleModels = this.#isCanonicalTab() ? canonicalModels : models;
543
454
  this.#selectedIndex = this.#coerceSelectedIndex(
544
- Math.min(this.#selectedIndex, Math.max(0, visibleModels.length - 1)),
545
- visibleModels,
455
+ Math.min(this.#selectedIndex, Math.max(0, models.length - 1)),
456
+ models,
546
457
  );
547
458
  }
548
459
 
@@ -717,10 +628,6 @@ export class ModelSelectorComponent extends Container {
717
628
  return this.#getActiveTab().providerId;
718
629
  }
719
630
 
720
- #isCanonicalTab(): boolean {
721
- return this.#getActiveTabId() === CANONICAL_TAB;
722
- }
723
-
724
631
  #isModelOverCurrentContext(model: Model): boolean {
725
632
  const contextWindow = model.contextWindow ?? 0;
726
633
  return this.#currentContextTokens > 0 && contextWindow > 0 && this.#currentContextTokens > contextWindow;
@@ -734,7 +641,7 @@ export class ModelSelectorComponent extends Container {
734
641
  return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow ?? 0).toLowerCase()}`;
735
642
  }
736
643
 
737
- #isItemDisabled(item: ModelItem | CanonicalModelItem): boolean {
644
+ #isItemDisabled(item: ModelItem): boolean {
738
645
  return this.#isModelOverContextLimit(item.model);
739
646
  }
740
647
 
@@ -745,14 +652,11 @@ export class ModelSelectorComponent extends Container {
745
652
  return this.#formatCurrentContextLimitSuffix(model);
746
653
  }
747
654
 
748
- #getVisibleItems(): ReadonlyArray<ModelItem | CanonicalModelItem> {
749
- return this.#isCanonicalTab() ? this.#filteredCanonicalModels : this.#filteredModels;
655
+ #getVisibleItems(): ReadonlyArray<ModelItem> {
656
+ return this.#filteredModels;
750
657
  }
751
658
 
752
- #coerceSelectedIndex(
753
- index: number,
754
- visibleItems: ReadonlyArray<ModelItem | CanonicalModelItem> = this.#getVisibleItems(),
755
- ): number {
659
+ #coerceSelectedIndex(index: number, visibleItems: ReadonlyArray<ModelItem> = this.#getVisibleItems()): number {
756
660
  const maxIndex = visibleItems.length - 1;
757
661
  if (maxIndex < 0) {
758
662
  return 0;
@@ -798,21 +702,16 @@ export class ModelSelectorComponent extends Container {
798
702
  }
799
703
 
800
704
  #filterModels(query: string): void {
801
- const activeTabId = this.#getActiveTabId();
802
705
  const activeProviderId = this.#getActiveProviderId();
803
- const isCanonicalTab = activeTabId === CANONICAL_TAB;
804
706
 
805
- // Start with all models or filter by provider/canonical view
806
707
  let baseModels = this.#allModels;
807
- const baseCanonicalModels = this.#canonicalModels;
808
708
  if (activeProviderId) {
809
709
  baseModels = this.#allModels.filter(m => m.provider === activeProviderId);
810
710
  }
811
711
 
812
- // Apply fuzzy filter if query is present
813
712
  if (query.trim()) {
814
713
  // If user is searching from a provider tab, auto-switch to ALL to show global provider results.
815
- if (activeProviderId && !isCanonicalTab) {
714
+ if (activeProviderId) {
816
715
  this.#activeTabIndex = 0;
817
716
  if (this.#tabBar && this.#tabBar.getActiveIndex() !== 0) {
818
717
  this.#tabBar.setActiveIndex(0);
@@ -822,55 +721,22 @@ export class ModelSelectorComponent extends Container {
822
721
  baseModels = this.#allModels;
823
722
  }
824
723
 
825
- if (isCanonicalTab) {
826
- const alphaTokens = getAlphaSearchTokens(query);
827
- const alphaFiltered =
828
- alphaTokens.length === 0
829
- ? baseCanonicalModels
830
- : baseCanonicalModels.filter(item =>
831
- alphaTokens.every(token => item.normalizedSearchText.includes(token)),
832
- );
833
- const compactQuery = compactSearchText(query);
834
- const substringFiltered =
835
- compactQuery.length === 0
836
- ? alphaFiltered
837
- : alphaFiltered.filter(item => item.compactSearchText.includes(compactQuery));
838
- const fuzzySource =
839
- substringFiltered.length > 0
840
- ? substringFiltered
841
- : alphaFiltered.length > 0
842
- ? alphaFiltered
843
- : baseCanonicalModels;
844
- // Fuzzy provides the candidate set, but `${provider}/${id}` scoring
845
- // is biased by provider-prefix length (e.g. `openai/X` beats
846
- // `openai-codex/X` purely because the prefix is shorter). Re-sort by
847
- // affinity — MRU then version — so the user's actually-used model
848
- // wins. Role rank is skipped: when narrowing by query, a weakly
849
- // matching default model should not be promoted above a stronger
850
- // non-default match.
851
- const fuzzyMatches = fuzzyFilter(fuzzySource, query, ({ searchText }) => searchText);
852
- this.#sortCanonicalModels(fuzzyMatches, { skipRoleRank: true });
853
- this.#filteredCanonicalModels = fuzzyMatches;
854
- } else {
855
- // Match against the displayed "provider/id" string so the user can
856
- // type what they see: bare names (`mimo`, `kimi`), provider prefixes
857
- // (`openrouter`), or scoped queries (`openrouter/mimo`) all flow
858
- // through the same fuzzy matcher. The score is biased by provider-
859
- // prefix length, so re-sort by MRU/version afterwards; skip role
860
- // rank so a weakly matching default doesn't trump a stronger match.
861
- const fuzzyMatches = fuzzyFilter(baseModels, query, ({ id, provider }) => `${provider}/${id}`);
862
- this.#sortModels(fuzzyMatches, { skipRoleRank: true });
863
- this.#filteredModels = fuzzyMatches;
864
- }
724
+ // Match against the displayed "provider/id" string so the user can
725
+ // type what they see: bare names (`mimo`, `kimi`), provider prefixes
726
+ // (`openrouter`), or scoped queries (`openrouter/mimo`) all flow
727
+ // through the same fuzzy matcher. The score is biased by provider-
728
+ // prefix length, so re-sort by MRU/version afterwards; skip role
729
+ // rank so a weakly matching default doesn't trump a stronger match.
730
+ const fuzzyMatches = fuzzyFilter(baseModels, query, ({ id, provider }) => `${provider}/${id}`);
731
+ this.#sortModels(fuzzyMatches, { skipRoleRank: true });
732
+ this.#filteredModels = fuzzyMatches;
865
733
  } else {
866
734
  this.#filteredModels = baseModels;
867
- this.#filteredCanonicalModels = baseCanonicalModels;
868
735
  }
869
736
 
870
- const visibleItems = isCanonicalTab ? this.#filteredCanonicalModels : this.#filteredModels;
871
737
  this.#selectedIndex = this.#coerceSelectedIndex(
872
- Math.min(this.#selectedIndex, Math.max(0, visibleItems.length - 1)),
873
- visibleItems,
738
+ Math.min(this.#selectedIndex, Math.max(0, this.#filteredModels.length - 1)),
739
+ this.#filteredModels,
874
740
  );
875
741
  this.#updateList();
876
742
  }
@@ -940,8 +806,7 @@ export class ModelSelectorComponent extends Container {
940
806
 
941
807
  #updateList(): void {
942
808
  this.#listContainer.clear();
943
- const isCanonicalTab = this.#isCanonicalTab();
944
- const visibleItems = isCanonicalTab ? this.#filteredCanonicalModels : this.#filteredModels;
809
+ const visibleItems = this.#filteredModels;
945
810
 
946
811
  const maxVisible = 10;
947
812
  const startIndex = Math.max(
@@ -959,8 +824,6 @@ export class ModelSelectorComponent extends Container {
959
824
  for (let i = startIndex; i < endIndex; i++) {
960
825
  const item = visibleItems[i];
961
826
  if (!item) continue;
962
- const canonicalItem = isCanonicalTab ? (item as CanonicalModelItem) : undefined;
963
- const providerItem = isCanonicalTab ? undefined : (item as ModelItem);
964
827
 
965
828
  const isSelected = i === this.#selectedIndex;
966
829
  const isDisabled = this.#isItemDisabled(item);
@@ -988,25 +851,17 @@ export class ModelSelectorComponent extends Container {
988
851
  let line = "";
989
852
  if (isSelected) {
990
853
  const prefix = theme.fg("accent", `${theme.nav.cursor} `);
991
- if (isCanonicalTab) {
992
- const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
993
- const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
994
- line = `${prefix}${theme.fg("accent", item.id)}${variants}${backing}${badgeText}${disabledSuffix}`;
995
- } else if (showProvider) {
996
- const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
997
- line = `${prefix}${providerPrefix}${theme.fg("accent", providerItem?.id ?? item.id)}${badgeText}${disabledSuffix}`;
854
+ if (showProvider) {
855
+ const providerPrefix = theme.fg("dim", `${item.provider}/`);
856
+ line = `${prefix}${providerPrefix}${theme.fg("accent", item.id)}${badgeText}${disabledSuffix}`;
998
857
  } else {
999
858
  line = `${prefix}${theme.fg("accent", item.id)}${badgeText}${disabledSuffix}`;
1000
859
  }
1001
860
  } else {
1002
861
  const prefix = " ";
1003
- if (isCanonicalTab) {
1004
- const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
1005
- const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
1006
- line = `${prefix}${item.id}${variants}${backing}${badgeText}${disabledSuffix}`;
1007
- } else if (showProvider) {
1008
- const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
1009
- line = `${prefix}${providerPrefix}${providerItem?.id ?? item.id}${badgeText}${disabledSuffix}`;
862
+ if (showProvider) {
863
+ const providerPrefix = theme.fg("dim", `${item.provider}/`);
864
+ line = `${prefix}${providerPrefix}${item.id}${badgeText}${disabledSuffix}`;
1010
865
  } else {
1011
866
  line = `${prefix}${item.id}${badgeText}${disabledSuffix}`;
1012
867
  }
@@ -1044,9 +899,6 @@ export class ModelSelectorComponent extends Container {
1044
899
  return;
1045
900
  }
1046
901
  this.#listContainer.addChild(new Spacer(1));
1047
- const suffix = isCanonicalTab
1048
- ? ` (${selected.model.provider}/${selected.model.id}, ${(selected as CanonicalModelItem).variantCount} variants)`
1049
- : "";
1050
902
  const limitWarning = this.#isItemDisabled(selected)
1051
903
  ? theme.fg(
1052
904
  "dim",
@@ -1054,7 +906,7 @@ export class ModelSelectorComponent extends Container {
1054
906
  )
1055
907
  : "";
1056
908
  this.#listContainer.addChild(
1057
- new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`) + limitWarning, 0, 0),
909
+ new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`) + limitWarning, 0, 0),
1058
910
  );
1059
911
  }
1060
912
  }
@@ -1086,10 +938,8 @@ export class ModelSelectorComponent extends Container {
1086
938
  return foundIndex >= 0 ? foundIndex : 0;
1087
939
  }
1088
940
 
1089
- #getSelectedItem(): ModelItem | CanonicalModelItem | undefined {
1090
- return this.#isCanonicalTab()
1091
- ? this.#filteredCanonicalModels[this.#selectedIndex]
1092
- : this.#filteredModels[this.#selectedIndex];
941
+ #getSelectedItem(): ModelItem | undefined {
942
+ return this.#filteredModels[this.#selectedIndex];
1093
943
  }
1094
944
 
1095
945
  #coerceMenuSelectedIndex(index: number): number {
@@ -1100,7 +950,7 @@ export class ModelSelectorComponent extends Container {
1100
950
  return Math.max(0, Math.min(index, maxIndex));
1101
951
  }
1102
952
 
1103
- #moveMenuSelection(delta: number, _selectedItem: ModelItem | CanonicalModelItem, optionCount: number): void {
953
+ #moveMenuSelection(delta: number, _selectedItem: ModelItem, optionCount: number): void {
1104
954
  this.#menuSelectedIndex = (this.#menuSelectedIndex + delta + optionCount) % optionCount;
1105
955
  this.#updateMenu();
1106
956
  }
@@ -1375,11 +1225,7 @@ export class ModelSelectorComponent extends Container {
1375
1225
  }
1376
1226
  }
1377
1227
 
1378
- #handleSelect(
1379
- item: ModelItem | CanonicalModelItem,
1380
- role: string | null,
1381
- thinkingLevel?: ConfiguredThinkingLevel,
1382
- ): void {
1228
+ #handleSelect(item: ModelItem, role: string | null, thinkingLevel?: ConfiguredThinkingLevel): void {
1383
1229
  if (this.#isItemDisabled(item)) {
1384
1230
  return;
1385
1231
  }
@@ -72,11 +72,6 @@ export class EventController {
72
72
  // emits one read per completion — does not break it, so a run of consecutive
73
73
  // reads collapses into one group even across completion boundaries.
74
74
  #lastVisibleBlockCount = 0;
75
- // Bumped on each assistant message_start. Scopes the placeholder key for a
76
- // not-yet-id'd streamed tool block (see the toolCall loop) so a sealed,
77
- // undeleted pending entry from an aborted message can never be reused by the
78
- // next message's same content index.
79
- #streamTurnNonce = 0;
80
75
  #renderedCustomMessages = new Set<string>();
81
76
  #lastIntent: string | undefined = undefined;
82
77
  #backgroundToolCallIds = new Set<string>();
@@ -406,7 +401,6 @@ export class EventController {
406
401
  this.ctx.ui.requestRender();
407
402
  } else if (event.message.role === "assistant") {
408
403
  this.#lastVisibleBlockCount = 0;
409
- this.#streamTurnNonce++;
410
404
  this.ctx.streamingComponent = createAssistantMessageComponent(this.ctx);
411
405
  this.ctx.streamingMessage = event.message;
412
406
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
@@ -613,14 +607,9 @@ export class EventController {
613
607
  if (this.ctx.streamingMessage.content.some(content => content.type === "toolCall")) {
614
608
  this.ctx.streamingComponent.markTranscriptBlockFinalized();
615
609
  }
616
- for (const [contentIndex, content] of this.ctx.streamingMessage.content.entries()) {
610
+ for (const content of this.ctx.streamingMessage.content) {
617
611
  if (content.type !== "toolCall") continue;
618
612
  if (content.name === "read") {
619
- // Read groups key by the real id (one group spans several reads),
620
- // and the owned-dialect parser delivers id + args together when
621
- // the call closes, so a parseable target without an id never
622
- // occurs here. Defer if it somehow does.
623
- if (!content.id) continue;
624
613
  if (!readArgsHaveTarget(content.arguments)) {
625
614
  // Args still streaming — defer until path is parseable so we can route to the
626
615
  // read group (regular files) vs ToolExecutionComponent (internal URLs).
@@ -643,26 +632,6 @@ export class EventController {
643
632
  // Internal URL read falls through to ToolExecutionComponent below.
644
633
  }
645
634
 
646
- // The owned-dialect tool parser (text-based tool calls for OAuth
647
- // Anthropic / OpenAI) appends a tool block when it detects the call
648
- // opening and only fills `id` + arguments once the call's text
649
- // closes — so the live preview must stream while `content.id` is "".
650
- // Key the preview by stable content position until the id lands,
651
- // then migrate the pending entry + reveal state onto the real id
652
- // (so `tool_execution_*`, which always carries the real id, matches
653
- // the same component instead of orphaning a blank card). Native
654
- // structured tool calls — every Gemini call, and Anthropic/OpenAI
655
- // function calls — carry the id from the first frame, so
656
- // `pendingKey === content.id` throughout and the migration no-ops.
657
- const streamKey = `\u0000stream:${this.#streamTurnNonce}:${contentIndex}`;
658
- const pendingKey = content.id || streamKey;
659
- if (content.id && this.ctx.pendingTools.has(streamKey)) {
660
- const migrated = this.ctx.pendingTools.get(streamKey);
661
- this.ctx.pendingTools.delete(streamKey);
662
- if (migrated) this.ctx.pendingTools.set(pendingKey, migrated);
663
- this.#toolArgsReveal.rekey(streamKey, pendingKey);
664
- }
665
-
666
635
  // Preserve the raw partial JSON only for renderers that need to surface fields before the JSON object closes.
667
636
  // Bash uses this to show inline env assignments during streaming instead of popping them in at completion.
668
637
  // While the JSON is still open, ToolArgsRevealController paces the
@@ -674,16 +643,16 @@ export class EventController {
674
643
  const rawInput = content.customWireName !== undefined;
675
644
  const tool = this.ctx.viewSession.getToolByName(content.name);
676
645
  if (partialJson) {
677
- renderArgs = this.#toolArgsReveal.setTarget(pendingKey, partialJson, {
646
+ renderArgs = this.#toolArgsReveal.setTarget(content.id, partialJson, {
678
647
  rawInput,
679
648
  exposeRawPartialJson: exposesRawPartialJson(content.name, rawInput, tool),
680
649
  fullArgs: content.arguments,
681
650
  });
682
651
  } else {
683
- this.#toolArgsReveal.finish(pendingKey);
652
+ this.#toolArgsReveal.finish(content.id);
684
653
  renderArgs = content.arguments;
685
654
  }
686
- if (!this.ctx.pendingTools.has(pendingKey)) {
655
+ if (!this.ctx.pendingTools.has(content.id)) {
687
656
  this.#resolveDisplaceablePoll(content.name);
688
657
  this.#resetReadGroup();
689
658
  const component = new ToolExecutionComponent(
@@ -702,13 +671,13 @@ export class EventController {
702
671
  );
703
672
  component.setExpanded(this.ctx.toolOutputExpanded);
704
673
  this.ctx.chatContainer.addChild(component);
705
- this.ctx.pendingTools.set(pendingKey, component);
706
- this.#toolArgsReveal.bind(pendingKey, component);
674
+ this.ctx.pendingTools.set(content.id, component);
675
+ this.#toolArgsReveal.bind(content.id, component);
707
676
  } else {
708
- const component = this.ctx.pendingTools.get(pendingKey);
677
+ const component = this.ctx.pendingTools.get(content.id);
709
678
  if (component) {
710
679
  component.updateArgs(renderArgs, content.id);
711
- this.#toolArgsReveal.bind(pendingKey, component);
680
+ this.#toolArgsReveal.bind(content.id, component);
712
681
  }
713
682
  }
714
683
  }
@@ -248,7 +248,6 @@ export class SelectorController {
248
248
  const advisorRoleSel = resolveAdvisorRoleSelection(
249
249
  this.ctx.settings,
250
250
  this.ctx.session.modelRegistry.getAvailable(),
251
- this.ctx.session.modelRegistry,
252
251
  );
253
252
  const defaultAdvisorModel = advisorRoleSel?.model;
254
253
  const deps: AdvisorConfigDeps = {
@@ -177,18 +177,6 @@ export class ToolArgsRevealController {
177
177
  if (entry) entry.component = component;
178
178
  }
179
179
 
180
- /** Migrate a live reveal entry from a placeholder key onto the real
181
- * tool-call id once the owned-dialect parser assigns it. No-op when no
182
- * entry exists under `from` (smoothing disabled, or the JSON already
183
- * closed and `finish` cleared it). */
184
- rekey(from: string, to: string): void {
185
- if (from === to) return;
186
- const entry = this.#entries.get(from);
187
- if (!entry) return;
188
- this.#entries.delete(from);
189
- this.#entries.set(to, entry);
190
- }
191
-
192
180
  /** Final arguments arrived (the JSON closed): drop the reveal so the
193
181
  * caller's final-args render wins immediately, mirroring how assistant
194
182
  * text snaps to the full message at message_end. */
@@ -62,9 +62,9 @@ Yield protocol:
62
62
  This is your only way to return a final result. For structured results, you NEVER put JSON in plain text or substitute a text summary for `result.data`.
63
63
 
64
64
  {{#if outputSchema}}
65
- Your result MUST match this TypeScript interface:
65
+ Your terminal `yield` MUST use exactly this shape — the schema fields go inside `result.data`, NEVER at the top level and NEVER as a stringified summary:
66
66
  ```ts
67
- {{jtdToTypeScript outputSchema}}
67
+ {{renderYieldSchema outputSchema}}
68
68
  ```
69
69
  {{/if}}
70
70
 
@@ -0,0 +1,8 @@
1
+ <system-interrupt reason="tool_call_loop_detected">
2
+ You called `{{tool_name}}` {{count}} consecutive times with identical arguments:
3
+ `{{arguments_summary}}`
4
+
5
+ Last result (truncated): `{{result_summary}}`
6
+
7
+ NEVER call `{{tool_name}}` with those arguments again this turn. Use different arguments, choose another tool, or summarize findings and yield if complete.
8
+ </system-interrupt>
@@ -39,8 +39,8 @@ tool.<name>(args) → unknown
39
39
  Invoke any session tool; `args` = its parameter object.
40
40
  completion(prompt, model?="default", system?=None, schema?=None) → str | dict
41
41
  Oneshot, stateless (no history/tools). `model`: "smol" fast | "default" session | "slow" most capable. `schema` (JSON-Schema) → structured output, parsed object.
42
- {{#if spawns}}agent(prompt, agent?="task", model?=None, label?=None, schema?=None, handle?=False) → str | dict
43
- Run a subagent → final output. `agent` picks another discovered agent; `schema` as in completion(). Background via `local://` files named in the prompt. `handle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
42
+ {{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, label?=None, schema?=None, handle?=False) → str | dict
43
+ Run a subagent → final output. `agent` picks another discovered agent; omit it to use `{{spawnDefaultAgent}}`.{{#if spawnAllowedAgentsText}} Allowed agents: {{spawnAllowedAgentsText}}.{{/if}} `schema` as in completion(). Background via `local://` files named in the prompt. `handle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
44
44
  {{#if js}} JS: options are ONE trailing object — agent(prompt, { agent, schema, handle }).
45
45
  {{/if}}
46
46
  {{/if}}
@@ -19,4 +19,5 @@ Runs commands on remote hosts.
19
19
 
20
20
  <critical>
21
21
  You MUST verify the shell type from "Available hosts" and use matching commands.
22
+ You SHOULD omit `cwd` unless required. `cwd` MUST be an explicit remote path; NEVER use `~` or `~/…`.
22
23
  </critical>
@@ -10,7 +10,7 @@ Execution blocks your turn: the call only returns once the work is completely fi
10
10
  - **One-pass agents:** Prefer agents that investigate **and** edit in a single pass; only spin a read-only discovery step (e.g. `explore`) when the affected files are genuinely unknown.
11
11
 
12
12
  # Inputs
13
- - `agent` (optional): The base agent type to use (e.g., `explore`, `reviewer`). Defaults to `task` (the general-purpose worker) — omit it for the default worker instead of passing `agent: "task"`.
13
+ - `agent` (optional): The base agent type to use (e.g., `explore`, `reviewer`). Defaults to `{{defaultAgent}}`{{#if defaultAgentIsGeneric}} (the general-purpose worker){{/if}} — omit it for the default worker instead of passing `agent: "{{defaultAgent}}"`.{{#if allowedAgentsText}} Current spawn policy allows: {{allowedAgentsText}}.{{/if}}
14
14
  {{#if batchEnabled}}
15
15
  - `context`: Shared project state, constraints, and contracts. Applies to the entire batch; do not duplicate this background into individual tasks.
16
16
  - `tasks[]`: Array of subagents to spawn.