@sayknow-cli/coding-agent 0.5.25 → 0.5.26

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.
@@ -35,6 +35,7 @@ import type { ModelProfileConfig } from "../../config/models-config-schema";
35
35
  import { getProviderAuthHealth } from "../../config/provider-auth-health";
36
36
  import { compareRankedProviders, type ProviderAuthState } from "../../config/provider-ranking";
37
37
  import type { Settings } from "../../config/settings";
38
+ import { specialtiesForRole, type TaskModelSpecialty } from "../../config/task-model-specialties";
38
39
  import { t } from "../../i18n";
39
40
  import { type ThemeColor, theme } from "../../modes/theme/theme";
40
41
  import { formatModelOnboardingInlineHint } from "../../setup/model-onboarding-guidance";
@@ -111,6 +112,18 @@ export type ModelSelectorSelection =
111
112
  profileName: string;
112
113
  setDefault: boolean;
113
114
  }
115
+ | {
116
+ kind: "specialtyAssignment";
117
+ model: Model;
118
+ role: SkcModelAssignmentTargetId;
119
+ specialty: TaskModelSpecialty;
120
+ thinkingLevel?: ThinkingLevel;
121
+ selector?: string;
122
+ }
123
+ | {
124
+ kind: "specialtyReset";
125
+ role: SkcModelAssignmentTargetId;
126
+ }
114
127
  | {
115
128
  kind: "createProfile";
116
129
  profile: ModelProfileConfig;
@@ -127,6 +140,34 @@ interface PendingThinkingChoice {
127
140
  levels: ThinkingLevel[];
128
141
  }
129
142
 
143
+ /** Role agents only — the bulk action that deliberately excludes `default`. */
144
+ const ROLE_AGENT_TARGET_IDS = ["executor", "architect", "planner", "critic"] as const;
145
+
146
+ /**
147
+ * A first-level row. Stable descriptors, never positions.
148
+ *
149
+ * `scope` distinguishes the two bulk rows rather than a boolean, because the
150
+ * difference between them is which targets they write, not a degree of anything.
151
+ */
152
+ type ModelSelectorAction =
153
+ | { kind: "role"; role: SkcModelAssignmentTargetId }
154
+ | {
155
+ kind: "bulk";
156
+ roles: readonly SkcModelAssignmentTargetId[];
157
+ scope: "roleAgents" | "allTargets";
158
+ };
159
+
160
+ /** A second-level row under one canonical role. */
161
+ type ModelSelectorDetailAction =
162
+ | { kind: "roleGeneral"; role: SkcModelAssignmentTargetId }
163
+ | { kind: "specialty"; role: SkcModelAssignmentTargetId; specialty: TaskModelSpecialty }
164
+ | { kind: "resetSpecialties"; role: SkcModelAssignmentTargetId };
165
+
166
+ interface PendingDetailChoice {
167
+ item: ModelItem | CanonicalModelItem;
168
+ role: SkcModelAssignmentTargetId;
169
+ }
170
+
130
171
  type RoleSelectCallback = (selection: ModelSelectorSelection) => void | Promise<void>;
131
172
  type CancelCallback = () => void;
132
173
 
@@ -315,6 +356,7 @@ export class ModelSelectorComponent extends Container {
315
356
  #onSelectCallback = (() => {}) as RoleSelectCallback;
316
357
  #onCancelCallback = (() => {}) as CancelCallback;
317
358
  #errorMessage?: unknown;
359
+ #catalogRefreshGeneration = 0;
318
360
  #tui: TUI;
319
361
  #scopedModels: ReadonlyArray<ScopedModelItem>;
320
362
  #temporaryOnly: boolean;
@@ -327,6 +369,8 @@ export class ModelSelectorComponent extends Container {
327
369
  #isCurrentModelFastModeActive: () => boolean = () => false;
328
370
  #pendingActionItem?: ModelItem | CanonicalModelItem;
329
371
  #selectedActionIndex: number = 0;
372
+ #pendingDetailChoice?: PendingDetailChoice;
373
+ #selectedDetailIndex: number = 0;
330
374
  #pendingThinkingChoice?: PendingThinkingChoice;
331
375
  #selectedThinkingIndex: number = 0;
332
376
 
@@ -431,19 +475,42 @@ export class ModelSelectorComponent extends Container {
431
475
  // Add bottom border
432
476
  this.addChild(new DynamicBorder());
433
477
 
434
- // Load models and do initial render
435
- this.#loadModels().then(() => {
436
- this.#buildProviderTabs();
437
- if (this.#viewMode === "presets" && (this.#modelRegistry.getModelProfiles?.().size ?? 0) === 0) {
438
- this.#viewMode = "models";
439
- }
440
- if (this.#viewMode === "presets") {
441
- void this.#refreshProviderAuth();
442
- this.#renderPresetLanding();
478
+ void this.#bootstrapCatalog();
479
+ }
480
+
481
+ async #bootstrapCatalog(): Promise<void> {
482
+ await this.#loadModels();
483
+ this.#buildProviderTabs();
484
+ if (this.#viewMode === "presets" && (this.#modelRegistry.getModelProfiles?.().size ?? 0) === 0) {
485
+ this.#viewMode = "models";
486
+ }
487
+ if (this.#viewMode === "presets") {
488
+ void this.#refreshProviderAuth();
489
+ this.#renderPresetLanding();
490
+ } else {
491
+ this.#updateTabBar();
492
+ const currentQuery = this.#searchInput.getValue();
493
+ if (currentQuery) {
494
+ this.#filterModels(currentQuery);
443
495
  } else {
496
+ this.#updateList();
497
+ }
498
+ void this.#refreshLiveCatalog();
499
+ }
500
+ this.#tui.requestRender();
501
+ }
502
+
503
+ async #refreshLiveCatalog(): Promise<void> {
504
+ if (this.#scopedModels.length > 0) return;
505
+ const generation = ++this.#catalogRefreshGeneration;
506
+ try {
507
+ await this.#modelRegistry.refresh("online");
508
+ if (generation !== this.#catalogRefreshGeneration) return;
509
+ await this.#loadModels();
510
+ if (generation !== this.#catalogRefreshGeneration) return;
511
+ this.#buildProviderTabs();
512
+ if (this.#viewMode === "models") {
444
513
  this.#updateTabBar();
445
- // Always apply the current search query — the user may have typed
446
- // while models were loading asynchronously.
447
514
  const currentQuery = this.#searchInput.getValue();
448
515
  if (currentQuery) {
449
516
  this.#filterModels(currentQuery);
@@ -451,9 +518,13 @@ export class ModelSelectorComponent extends Container {
451
518
  this.#updateList();
452
519
  }
453
520
  }
454
- // Request re-render after models are loaded
455
521
  this.#tui.requestRender();
456
- });
522
+ } catch (error) {
523
+ if (generation !== this.#catalogRefreshGeneration) return;
524
+ this.#errorMessage = error instanceof Error ? error.message : String(error);
525
+ this.#updateList();
526
+ this.#tui.requestRender();
527
+ }
457
528
  }
458
529
 
459
530
  #isActiveDefaultFallback(): boolean {
@@ -1145,6 +1216,7 @@ export class ModelSelectorComponent extends Container {
1145
1216
  this.#setSearchInputValue(seed ?? this.#searchInput.getValue());
1146
1217
  this.#updateTabBar();
1147
1218
  this.#filterModels(this.#searchInput.getValue());
1219
+ void this.#refreshLiveCatalog();
1148
1220
  }
1149
1221
 
1150
1222
  /**
@@ -1475,33 +1547,114 @@ export class ModelSelectorComponent extends Container {
1475
1547
  );
1476
1548
  if (this.#pendingThinkingChoice) {
1477
1549
  this.#renderThinkingMenu(this.#pendingThinkingChoice);
1550
+ } else if (this.#pendingDetailChoice) {
1551
+ this.#renderDetailMenu(this.#pendingDetailChoice);
1478
1552
  } else if (this.#pendingActionItem) {
1479
1553
  this.#renderActionMenu(this.#pendingActionItem);
1480
1554
  }
1481
1555
  }
1482
1556
  }
1483
1557
 
1558
+ /**
1559
+ * First-level actions, in a fixed order the tests and muscle memory both rely on.
1560
+ *
1561
+ * Built as descriptors rather than derived from an index so that adding a row
1562
+ * cannot silently renumber the ones below it — the previous index arithmetic
1563
+ * meant every new action was a chance to mis-assign a model to the wrong role.
1564
+ */
1565
+ #getActions(): ModelSelectorAction[] {
1566
+ const actions: ModelSelectorAction[] = SKC_MODEL_ASSIGNMENT_TARGET_IDS.map(role => ({ kind: "role", role }));
1567
+ actions.push({ kind: "bulk", roles: ROLE_AGENT_TARGET_IDS, scope: "roleAgents" });
1568
+ actions.push({ kind: "bulk", roles: SKC_MODEL_ASSIGNMENT_TARGET_IDS, scope: "allTargets" });
1569
+ return actions;
1570
+ }
1571
+
1572
+ /**
1573
+ * Second-level rows for one canonical role: General, then its specialties, then reset.
1574
+ *
1575
+ * `default` and the bulk actions never reach here. They write canonical role
1576
+ * models only, because a "set everything" action that also rewrote five
1577
+ * specialty overrides would be impossible to undo from this menu.
1578
+ */
1579
+ #getDetailActions(role: SkcModelAssignmentTargetId): ModelSelectorDetailAction[] {
1580
+ const rows: ModelSelectorDetailAction[] = [{ kind: "roleGeneral", role }];
1581
+ for (const specialty of specialtiesForRole(role)) rows.push({ kind: "specialty", role, specialty });
1582
+ if (rows.length > 1) rows.push({ kind: "resetSpecialties", role });
1583
+ return rows;
1584
+ }
1585
+
1586
+ #actionLabel(action: ModelSelectorAction): string {
1587
+ if (action.kind === "bulk") {
1588
+ return action.scope === "roleAgents"
1589
+ ? t("modelSelector.setForAllRoleAgents")
1590
+ : t("modelSelector.setForAllTargets");
1591
+ }
1592
+ const target = SKC_MODEL_ASSIGNMENT_TARGETS[action.role];
1593
+ const label = t("modelSelector.setAsTarget", {
1594
+ tag: target.tag ?? action.role.toUpperCase(),
1595
+ name: target.name,
1596
+ });
1597
+ const count = specialtiesForRole(action.role).length;
1598
+ return count > 0 ? `${label} ${theme.fg("muted", t("modelSelector.hasDetailedUses"))}` : label;
1599
+ }
1600
+
1601
+ #detailActionLabel(action: ModelSelectorDetailAction): string {
1602
+ if (action.kind === "roleGeneral") return t("modelSelector.generalRole");
1603
+ if (action.kind === "resetSpecialties") return t("modelSelector.resetSpecialties");
1604
+ // Read through rather than caching: a row that states what it already holds
1605
+ // is worse than useless if it states a stale value.
1606
+ const assigned = this.#settings.get("task.modelRouting.specialtyModels")?.[action.specialty];
1607
+ const current = selectorHead(assigned);
1608
+ const label = t(`modelSelector.specialty.${action.specialty}`);
1609
+ return current ? `${label} ${theme.fg("muted", `— ${current}`)}` : label;
1610
+ }
1611
+
1484
1612
  #renderActionMenu(item: ModelItem | CanonicalModelItem): void {
1485
1613
  this.#listContainer.addChild(new Spacer(1));
1486
1614
  this.#listContainer.addChild(
1487
1615
  new Text(theme.fg("muted", ` ${t("modelSelector.actionFor", { id: item.model.id })}`), 0, 0),
1488
1616
  );
1489
1617
  this.#listContainer.addChild(new Spacer(1));
1490
- const actionCount = this.#getActionCount(item.model);
1491
- for (let i = 0; i < actionCount; i++) {
1618
+ const actions = this.#getActions();
1619
+ for (let i = 0; i < actions.length; i++) {
1620
+ const action = actions[i];
1621
+ if (!action) continue;
1492
1622
  const prefix = i === this.#selectedActionIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
1493
- const role = SKC_MODEL_ASSIGNMENT_TARGET_IDS[i];
1494
- const label = role
1495
- ? `Set as ${SKC_MODEL_ASSIGNMENT_TARGETS[role].tag ?? role.toUpperCase()} (${SKC_MODEL_ASSIGNMENT_TARGETS[role].name})`
1496
- : i === SKC_MODEL_ASSIGNMENT_TARGET_IDS.length
1497
- ? "Set for all role agents"
1498
- : "Set for all targets";
1623
+ const label = this.#actionLabel(action);
1499
1624
  this.#listContainer.addChild(
1500
1625
  new Text(`${prefix}${i === this.#selectedActionIndex ? theme.fg("accent", label) : label}`, 0, 0),
1501
1626
  );
1502
1627
  }
1503
1628
  }
1504
1629
 
1630
+ #renderDetailMenu(choice: PendingDetailChoice): void {
1631
+ this.#listContainer.addChild(new Spacer(1));
1632
+ this.#listContainer.addChild(
1633
+ new Text(
1634
+ theme.fg(
1635
+ "muted",
1636
+ ` ${t("modelSelector.detailedUseFor", {
1637
+ target: SKC_MODEL_ASSIGNMENT_TARGETS[choice.role].name,
1638
+ id: choice.item.model.id,
1639
+ })}`,
1640
+ ),
1641
+ 0,
1642
+ 0,
1643
+ ),
1644
+ );
1645
+ this.#listContainer.addChild(new Spacer(1));
1646
+ const rows = this.#getDetailActions(choice.role);
1647
+ for (let i = 0; i < rows.length; i++) {
1648
+ const action = rows[i];
1649
+ if (!action) continue;
1650
+ const prefix = i === this.#selectedDetailIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
1651
+ const label = this.#detailActionLabel(action);
1652
+ this.#listContainer.addChild(
1653
+ new Text(`${prefix}${i === this.#selectedDetailIndex ? theme.fg("accent", label) : label}`, 0, 0),
1654
+ );
1655
+ }
1656
+ }
1657
+
1505
1658
  #renderThinkingMenu(choice: PendingThinkingChoice): void {
1506
1659
  const targetLabel = choice.roles
1507
1660
  ? choice.roles.includes("default")
@@ -1537,7 +1690,7 @@ export class ModelSelectorComponent extends Container {
1537
1690
  return this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit;
1538
1691
  }
1539
1692
  #getActionCount(_model: Model): number {
1540
- return SKC_MODEL_ASSIGNMENT_TARGET_IDS.length + 2;
1693
+ return this.#getActions().length;
1541
1694
  }
1542
1695
 
1543
1696
  #getSelectedItem(): ModelItem | CanonicalModelItem | undefined {
@@ -1551,6 +1704,10 @@ export class ModelSelectorComponent extends Container {
1551
1704
  this.#handleThinkingMenuInput(keyData);
1552
1705
  return;
1553
1706
  }
1707
+ if (this.#pendingDetailChoice) {
1708
+ this.#handleDetailMenuInput(keyData);
1709
+ return;
1710
+ }
1554
1711
  if (this.#pendingActionItem) {
1555
1712
  this.#handleActionMenuInput(keyData);
1556
1713
  return;
@@ -1783,17 +1940,24 @@ export class ModelSelectorComponent extends Container {
1783
1940
  return;
1784
1941
  }
1785
1942
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
1786
- this.#pendingActionItem = undefined;
1787
- const role = SKC_MODEL_ASSIGNMENT_TARGET_IDS[this.#selectedActionIndex];
1788
- if (role) {
1789
- this.#handleSelect(item, role);
1943
+ const action = this.#getActions()[this.#selectedActionIndex];
1944
+ if (!action) return;
1945
+ if (action.kind === "bulk") {
1946
+ this.#pendingActionItem = undefined;
1947
+ this.#handleSelect(item, "default", undefined, action.roles);
1948
+ return;
1949
+ }
1950
+ // A role with specialties opens the detail level instead of assigning
1951
+ // straight away; a role without any keeps the old one-keystroke path.
1952
+ if (this.#getDetailActions(action.role).length > 1) {
1953
+ this.#pendingActionItem = undefined;
1954
+ this.#pendingDetailChoice = { item, role: action.role };
1955
+ this.#selectedDetailIndex = 0;
1956
+ this.#updateList();
1790
1957
  return;
1791
1958
  }
1792
- const roles =
1793
- this.#selectedActionIndex === SKC_MODEL_ASSIGNMENT_TARGET_IDS.length
1794
- ? (["executor", "architect", "planner", "critic"] as const)
1795
- : SKC_MODEL_ASSIGNMENT_TARGET_IDS;
1796
- this.#handleSelect(item, "default", undefined, roles);
1959
+ this.#pendingActionItem = undefined;
1960
+ this.#handleSelect(item, action.role);
1797
1961
  return;
1798
1962
  }
1799
1963
  if (getKeybindings().matches(keyData, "tui.select.cancel")) {
@@ -1801,6 +1965,74 @@ export class ModelSelectorComponent extends Container {
1801
1965
  this.#updateList();
1802
1966
  }
1803
1967
  }
1968
+ /**
1969
+ * Second level: General, the role's specialties, and reset.
1970
+ *
1971
+ * Cancel steps back to the first level rather than closing outright, so a
1972
+ * wrong turn costs one keystroke instead of restarting the whole selection.
1973
+ */
1974
+ #handleDetailMenuInput(keyData: string): void {
1975
+ const choice = this.#pendingDetailChoice;
1976
+ if (!choice) return;
1977
+ const rows = this.#getDetailActions(choice.role);
1978
+ if (matchesKey(keyData, "up")) {
1979
+ this.#selectedDetailIndex = this.#selectedDetailIndex === 0 ? rows.length - 1 : this.#selectedDetailIndex - 1;
1980
+ this.#updateList();
1981
+ return;
1982
+ }
1983
+ if (matchesKey(keyData, "down")) {
1984
+ this.#selectedDetailIndex = (this.#selectedDetailIndex + 1) % rows.length;
1985
+ this.#updateList();
1986
+ return;
1987
+ }
1988
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
1989
+ const action = rows[this.#selectedDetailIndex];
1990
+ if (!action) return;
1991
+ this.#pendingDetailChoice = undefined;
1992
+ if (action.kind === "roleGeneral") {
1993
+ this.#handleSelect(choice.item, action.role);
1994
+ return;
1995
+ }
1996
+ if (action.kind === "resetSpecialties") {
1997
+ this.#onSelectCallback({ kind: "specialtyReset", role: action.role });
1998
+ return;
1999
+ }
2000
+ this.#handleSpecialtySelect(choice.item, action.role, action.specialty);
2001
+ return;
2002
+ }
2003
+ if (getKeybindings().matches(keyData, "tui.select.cancel")) {
2004
+ this.#pendingDetailChoice = undefined;
2005
+ this.#pendingActionItem = choice.item;
2006
+ this.#selectedActionIndex = Math.max(
2007
+ 0,
2008
+ this.#getActions().findIndex(action => action.kind === "role" && action.role === choice.role),
2009
+ );
2010
+ this.#updateList();
2011
+ }
2012
+ }
2013
+
2014
+ /**
2015
+ * Assign a model to one specialty.
2016
+ *
2017
+ * Reuses the same explicit-thinking gate as a canonical assignment, because a
2018
+ * specialty override is dispatched exactly like a role model and would hit
2019
+ * the same "which effort?" ambiguity if it skipped it.
2020
+ */
2021
+ #handleSpecialtySelect(
2022
+ item: ModelItem | CanonicalModelItem,
2023
+ role: SkcModelAssignmentTargetId,
2024
+ specialty: TaskModelSpecialty,
2025
+ ): void {
2026
+ const thinkingLevel = item.thinkingLevel ?? this.#getCurrentRoleThinkingLevel(role);
2027
+ this.#onSelectCallback({
2028
+ kind: "specialtyAssignment",
2029
+ model: item.model,
2030
+ role,
2031
+ specialty,
2032
+ thinkingLevel,
2033
+ selector: formatModelSelectorValue(item.selector, thinkingLevel),
2034
+ });
2035
+ }
1804
2036
 
1805
2037
  #handleThinkingMenuInput(keyData: string): void {
1806
2038
  const choice = this.#pendingThinkingChoice;
@@ -1826,10 +2058,19 @@ export class ModelSelectorComponent extends Container {
1826
2058
  if (getKeybindings().matches(keyData, "tui.select.cancel")) {
1827
2059
  this.#pendingThinkingChoice = undefined;
1828
2060
  if (choice.role !== null) {
2061
+ // Step back one level, landing on the row that got us here. Located by
2062
+ // descriptor rather than computed from lengths, so the two bulk rows
2063
+ // cannot drift apart from the list that renders them.
1829
2064
  this.#pendingActionItem = choice.item;
1830
- this.#selectedActionIndex = choice.roles
1831
- ? SKC_MODEL_ASSIGNMENT_TARGET_IDS.length + (choice.roles.includes("default") ? 1 : 0)
1832
- : Math.max(0, SKC_MODEL_ASSIGNMENT_TARGET_IDS.indexOf(choice.role));
2065
+ const actions = this.#getActions();
2066
+ const bulkRoles = choice.roles;
2067
+ const wanted = bulkRoles
2068
+ ? actions.findIndex(
2069
+ action =>
2070
+ action.kind === "bulk" && action.roles.includes("default") === bulkRoles.includes("default"),
2071
+ )
2072
+ : actions.findIndex(action => action.kind === "role" && action.role === choice.role);
2073
+ this.#selectedActionIndex = Math.max(0, wanted);
1833
2074
  }
1834
2075
  this.#updateList();
1835
2076
  }
@@ -14,9 +14,10 @@ import {
14
14
  import { formatModelProfileDisplayLabel, recommendModelProfileForProvider } from "../../config/model-profiles";
15
15
  import { SKC_MODEL_ASSIGNMENT_TARGETS, type SkcModelAssignmentTargetId } from "../../config/model-registry";
16
16
  import { formatModelSelectorValue } from "../../config/model-resolver";
17
- import { selectorHead } from "../../config/model-selector-value";
17
+ import { type ModelSelectorValue, selectorHead } from "../../config/model-selector-value";
18
18
  import type { ModelProfileConfig } from "../../config/models-config-schema";
19
19
  import { type Settings, settings } from "../../config/settings";
20
+ import { specialtiesForRole } from "../../config/task-model-specialties";
20
21
  import { DebugSelectorComponent } from "../../debug";
21
22
  import { disableProvider, enableProvider } from "../../discovery";
22
23
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
@@ -28,6 +29,7 @@ import {
28
29
  MarketplaceManager,
29
30
  } from "../../extensibility/plugins/marketplace";
30
31
  import { INTERACTIVE_SELECTOR_RESUME_ORIGIN } from "../../extensibility/shared-events";
32
+ import { t } from "../../i18n";
31
33
  import {
32
34
  getAvailableThemes,
33
35
  getCurrentThemeName,
@@ -1589,6 +1591,52 @@ export class SelectorController {
1589
1591
  this.ctx.ui.requestRender();
1590
1592
  return;
1591
1593
  }
1594
+ if (selection.kind === "specialtyAssignment") {
1595
+ const value =
1596
+ selection.selector ??
1597
+ formatModelSelectorValue(
1598
+ `${selection.model.provider}/${selection.model.id}`,
1599
+ selection.thinkingLevel,
1600
+ );
1601
+ const current = this.ctx.settings.get("task.modelRouting.specialtyModels") ?? {};
1602
+ this.ctx.settings.set("task.modelRouting.specialtyModels", {
1603
+ ...current,
1604
+ [selection.specialty]: value,
1605
+ });
1606
+ this.ctx.settings
1607
+ .getStorage()
1608
+ ?.recordModelUsage(`${selection.model.provider}/${selection.model.id}`);
1609
+ await this.ctx.notifyConfigChanged?.();
1610
+ // The assignment is live for declared work as soon as it is saved. The
1611
+ // routing switch only governs auto-detection, so say that rather than
1612
+ // auto-enabling a classifier the user did not ask for.
1613
+ const specialtyLabel = t(`modelSelector.specialty.${selection.specialty}`);
1614
+ this.ctx.showStatus(
1615
+ this.ctx.settings.get("task.modelRouting.enabled")
1616
+ ? t("modelSelector.specialtySaved", { specialty: specialtyLabel, value })
1617
+ : t("modelSelector.specialtySavedRoutingOff", { specialty: specialtyLabel, value }),
1618
+ );
1619
+ done();
1620
+ this.ctx.ui.requestRender();
1621
+ return;
1622
+ }
1623
+ if (selection.kind === "specialtyReset") {
1624
+ const current = this.ctx.settings.get("task.modelRouting.specialtyModels") ?? {};
1625
+ const remaining: Record<string, ModelSelectorValue> = {};
1626
+ for (const [key, value] of Object.entries(current)) {
1627
+ if (!specialtiesForRole(selection.role).includes(key as never)) remaining[key] = value;
1628
+ }
1629
+ this.ctx.settings.set("task.modelRouting.specialtyModels", remaining);
1630
+ await this.ctx.notifyConfigChanged?.();
1631
+ this.ctx.showStatus(
1632
+ t("modelSelector.specialtyCleared", {
1633
+ target: SKC_MODEL_ASSIGNMENT_TARGETS[selection.role].name,
1634
+ }),
1635
+ );
1636
+ done();
1637
+ this.ctx.ui.requestRender();
1638
+ return;
1639
+ }
1592
1640
  const { model, role, thinkingLevel, selector: selectedSelector } = selection;
1593
1641
  if (role === null) {
1594
1642
  // Temporary: update agent state but don't persist to settings
@@ -2306,7 +2354,7 @@ export class SelectorController {
2306
2354
  },
2307
2355
  onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
2308
2356
  });
2309
- await this.ctx.session.modelRegistry.refresh();
2357
+ await this.ctx.session.modelRegistry.refresh("online");
2310
2358
  this.ctx.chatContainer.addChild(new Spacer(1));
2311
2359
  this.ctx.chatContainer.addChild(
2312
2360
  new Text(theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0),
@@ -454,7 +454,7 @@ export async function dispatchRpcCommand(
454
454
  return new Promise<string>(() => {});
455
455
  },
456
456
  });
457
- await session.modelRegistry.refresh();
457
+ await session.modelRegistry.refresh("online");
458
458
  return rpcSuccess(id, "login", { providerId: command.providerId });
459
459
  } catch (err: unknown) {
460
460
  return rpcError(id, "login", err instanceof Error ? err.message : String(err));
@@ -21,6 +21,7 @@ Subagents have no conversation history. Every fact, file path, and direction the
21
21
  - `.id`: filesystem-safe, ≤48 chars, matching `[A-Za-z0-9][A-Za-z0-9_-]*`; prefer CamelCase
22
22
  - `.description`: UI label only — subagent never sees it
23
23
  - `.assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED
24
+ - `.specialty` (optional): the kind of work — `backendArchitecture`, `frontendDesign`, `implementation`, `testing`, or `review`. When the user assigned a model to that specialty under `/model`, this child runs on it, deterministically: no classifier, no routing switch. It only leaves that model on a transport error (429, 5xx, auth, quota), falling back to the agent's role model. Declare it whenever the work clearly is one of these — a frontend build is `frontendDesign` even for `executor`. Omit to keep the role model or let auto-detection decide.
24
25
  {{#if contextEnabled}}- `context`: shared background prepended to every assignment; session-specific only{{/if}}
25
26
  {{#if contextEnabled}}
26
27
  - `.inheritContext` (optional): fork-context mode for seeding the subagent with sanitized parent conversation. Omit it or set `"none"` for no copied context. `"receipt"` copies a minimal receipt-sized snapshot, `"last-turn"` copies only the latest exchange, `"bounded"` copies the bounded default snapshot, and `"full"` copies a larger snapshot up to the configured/model token cap. Non-`none` modes work only when global `task.forkContext.enabled` is true and the target agent declares `forkContext: allowed`; otherwise the call is rejected. Bundled agents that support it: `executor`, `architect`. Use inherited context only when the subagent's value depends on parent context; cloned tokens are billed to the child as fresh input and surfaced in task receipts as fork-context cloned-token accounting.
@@ -360,9 +360,8 @@ async function resolveModelCommandSelection(
360
360
  }
361
361
 
362
362
  const providerRef = parseProviderQualifiedSelector(selector);
363
- const discoverableProviders = runtime.session.modelRegistry?.getDiscoverableProviders?.() ?? [];
364
- if (providerRef && discoverableProviders.includes(providerRef.provider)) {
365
- await runtime.session.modelRegistry.refreshProvider?.(providerRef.provider, "online");
363
+ if (providerRef && runtime.session.modelRegistry?.refreshProvider) {
364
+ await runtime.session.modelRegistry.refreshProvider(providerRef.provider, "online");
366
365
  availableModels = runtime.session.getAvailableModels?.() ?? [];
367
366
  const refreshedSelection = resolveModelCommandSelectionFromAvailable(
368
367
  runtime,
@@ -372,12 +371,15 @@ async function resolveModelCommandSelection(
372
371
  if (refreshedSelection) {
373
372
  return { ok: true, selection: refreshedSelection };
374
373
  }
375
- return {
376
- ok: false,
377
- failure: {
378
- message: formatDiscoverableProviderFailure(selector, providerRef.provider, providerRef.modelId, runtime),
379
- },
380
- };
374
+ const discoverableProviders = runtime.session.modelRegistry.getDiscoverableProviders?.() ?? [];
375
+ if (discoverableProviders.includes(providerRef.provider)) {
376
+ return {
377
+ ok: false,
378
+ failure: {
379
+ message: formatDiscoverableProviderFailure(selector, providerRef.provider, providerRef.modelId, runtime),
380
+ },
381
+ };
382
+ }
381
383
  }
382
384
 
383
385
  return {