jeo-code 0.12.2 → 0.12.3

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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,21 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.12.3] - 2026-10-17
13
+ _Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover._
14
+
15
+ ### Changed
16
+ - **Anthropic claude-4.x entries removed from the static catalog** (`src/ai/model-catalog.ts`) — `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6` dropped. Static catalog is now a fallback-only claude-5.x set (`sonnet-5-5`, `opus-5-5`, `sonnet-5`, `fable-5`, `mythos-5`).
17
+ - **`sonnet` / `opus` built-in aliases updated to claude-5.5** (`src/ai/model-registry.ts`) — `sonnet` → `claude-sonnet-5-5`, `opus` → `claude-opus-5-5`.
18
+ - **Live `https://api.anthropic.com/v1/models` is the primary model source** — when logged in, the API returns the account's actual available models; the static catalog serves as fallback only.
19
+ - **`/route off` fixes the model with no failover** (`src/commands/launch.ts`, `src/commands/launch/route-slash.ts`, `src/agent/task-tool.ts`) — when routing is off (via config or `/route off`), neither main turns nor delegated subagents bail early or fail over to equivalent models on 429/refusal, and `/route` status reports `routing: off (this session) — model fixed, no failover`.
20
+
21
+ ### Fixed
22
+ - **Provider-qualified catalog entries no longer hijack bare model IDs** (`src/ai/model-catalog.ts`, `src/ai/model-catalog-compat.ts`) — `findCatalogModel`, `findCatalogEntry`, and `catalogMetadata` only match a bare `providerModel` when the catalog row itself is not provider-qualified (`!canonical.startsWith(`${provider}/`)`), preventing `antigravity/claude-sonnet-4-6` from misrouting bare `claude-sonnet-4-6` to `antigravity`.
23
+
24
+ ### Verified
25
+ - `bun run typecheck`, `bun run pack:check`, and full regression suites green.
26
+
12
27
  ## [0.12.2] - 2026-10-16
13
28
  _Select Argon through the official agy full agent, with its own live catalog and session, separately from jeo's Cloud Code Assist backend._
14
29
 
package/README.ja.md CHANGED
@@ -416,10 +416,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
416
416
 
417
417
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
418
418
  - **[Unreleased]**
419
+ - **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
419
420
  - **[0.12.2]** (2026-10-16) — Select Argon through the official agy full agent, with its own live catalog and session, separately from jeo's Cloud Code Assist backend.
420
421
  - **[0.12.1]** (2026-10-16) — Logged-in OpenAI subscription models were invisible on proxy-configured machines.
421
422
  - **[0.12.0]** (2026-10-16) — Karpathy behavioral guardrails become always-on discipline, and skill routing gains an ECC single-harness inventory plus a Jev-style advisory router.
422
- - **[0.11.6]** (2026-10-16) — Live Antigravity account model discovery now serves the actual account's available models instead of assuming static availability.
423
423
 
424
424
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
425
425
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -417,10 +417,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
417
417
 
418
418
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
419
419
  - **[Unreleased]**
420
+ - **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
420
421
  - **[0.12.2]** (2026-10-16) — Select Argon through the official agy full agent, with its own live catalog and session, separately from jeo's Cloud Code Assist backend.
421
422
  - **[0.12.1]** (2026-10-16) — Logged-in OpenAI subscription models were invisible on proxy-configured machines.
422
423
  - **[0.12.0]** (2026-10-16) — Karpathy behavioral guardrails become always-on discipline, and skill routing gains an ECC single-harness inventory plus a Jev-style advisory router.
423
- - **[0.11.6]** (2026-10-16) — Live Antigravity account model discovery now serves the actual account's available models instead of assuming static availability.
424
424
 
425
425
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
426
426
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -409,10 +409,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
409
409
 
410
410
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
411
411
  - **[Unreleased]**
412
+ - **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
412
413
  - **[0.12.2]** (2026-10-16) — Select Argon through the official agy full agent, with its own live catalog and session, separately from jeo's Cloud Code Assist backend.
413
414
  - **[0.12.1]** (2026-10-16) — Logged-in OpenAI subscription models were invisible on proxy-configured machines.
414
415
  - **[0.12.0]** (2026-10-16) — Karpathy behavioral guardrails become always-on discipline, and skill routing gains an ECC single-harness inventory plus a Jev-style advisory router.
415
- - **[0.11.6]** (2026-10-16) — Live Antigravity account model discovery now serves the actual account's available models instead of assuming static availability.
416
416
 
417
417
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
418
418
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -414,10 +414,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
414
414
 
415
415
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
416
416
  - **[Unreleased]**
417
+ - **[0.12.3]** (2026-10-17) — Anthropic static model catalog trimmed to claude-5.x with live API discovery, and `/route off` strictly fixes the active model with no failover.
417
418
  - **[0.12.2]** (2026-10-16) — Select Argon through the official agy full agent, with its own live catalog and session, separately from jeo's Cloud Code Assist backend.
418
419
  - **[0.12.1]** (2026-10-16) — Logged-in OpenAI subscription models were invisible on proxy-configured machines.
419
420
  - **[0.12.0]** (2026-10-16) — Karpathy behavioral guardrails become always-on discipline, and skill routing gains an ECC single-harness inventory plus a Jev-style advisory router.
420
- - **[0.11.6]** (2026-10-16) — Live Antigravity account model discovery now serves the actual account's available models instead of assuming static availability.
421
421
 
422
422
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
423
423
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -433,6 +433,10 @@ export async function runSubagentOnce(
433
433
  // "false positive -> try a different model" design this implements.
434
434
  const safetyFallbackAvailable = (): boolean => safetyFallbackCandidates().length > 0;
435
435
 
436
+ // Failover (reroute to an equivalent model) is part of routing: it only runs when
437
+ // `routing.enabled` is on. `launch.ts` passes the parent session's EFFECTIVE state
438
+ // (`/route on|off` override ?? config) in `config.routing.enabled`.
439
+ const failoverEnabled = !!opts.config.routing?.enabled;
436
440
  let activeModel = initialModel;
437
441
  emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model: activeModel });
438
442
  const runOnce = () => {
@@ -448,8 +452,7 @@ export async function runSubagentOnce(
448
452
  signal,
449
453
  steer,
450
454
  tools: subagentToolset(role),
451
- rateLimitFallbackAvailable,
452
- safetyFallbackAvailable,
455
+ ...(failoverEnabled ? { rateLimitFallbackAvailable, safetyFallbackAvailable } : {}),
453
456
  events: {
454
457
  onStep: n => { currentStep = n; },
455
458
  // Live reasoning preview (native extended-thinking models only — the JSON-protocol
@@ -494,7 +497,10 @@ export async function runSubagentOnce(
494
497
  };
495
498
 
496
499
  let result = await runOnce();
497
- for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
500
+ // `routing.enabled` off = the model is FIXED: no failover reroute at all (the
501
+ // engine bail predicates above are withheld too, so a 429 rides its normal
502
+ // backoff ladder instead of bailing early with nowhere to go).
503
+ for (let attempt = 0; failoverEnabled && attempt < MAX_SUBAGENT_REROUTES; attempt++) {
498
504
  const rateLimited = !result.done && isRateLimitError(new Error(result.doneReason ?? ""));
499
505
  const safetyBail = !result.done && !rateLimited && isSafetyFallbackBail(result.doneReason);
500
506
  if (!rateLimited && !safetyBail) break;
@@ -55,7 +55,11 @@ export function findCatalogEntry(id: string): ModelCatalogEntry | undefined {
55
55
  const direct = findCatalogModel(id);
56
56
  if (direct) return adapt(direct);
57
57
  const n = normalizeModelId(id);
58
- const hit = MODEL_CATALOG.find(m => normalizeModelId(m.canonical) === n || normalizeModelId(m.providerModel) === n);
58
+ const hit = MODEL_CATALOG.find(m =>
59
+ normalizeModelId(m.canonical) === n
60
+ || normalizeModelId(`${m.provider}/${m.providerModel}`) === n
61
+ || (!m.canonical.startsWith(`${m.provider}/`) && normalizeModelId(m.providerModel) === n),
62
+ );
59
63
  return hit ? adapt(hit) : undefined;
60
64
  }
61
65
 
@@ -80,7 +80,9 @@ const STD: ThinkLevel[] = ["low", "medium", "high"];
80
80
  * (`cheapestCredentialed`/`strongestCredentialed`/`compareStrengthAscending`)
81
81
  * now tiebreak on `releaseDate` directly rather than relying on position. */
82
82
  export const MODEL_CATALOG: readonly CatalogModel[] = [
83
- // Anthropic (newest first: sonnet-5-5 > opus-5-5 > sonnet-5 > fable-5 > opus-4-8 > opus-4-7 > sonnet-4-6 > opus-4-6). 4.5-and-below dropped from jeo's exposed model list per user direction.
83
+ // Anthropic (newest first: sonnet-5-5 > opus-5-5 > sonnet-5 > fable-5 > mythos-5).
84
+ // claude-4.x and below removed: live API discovery (https://api.anthropic.com/v1/models)
85
+ // returns the account's actual available models; the static catalog is fallback-only.
84
86
  // 5.5 generation (platform.claude.com/docs/en/models/{opus-5-5,sonnet-5-5}/overview): dateless pinned wire ids
85
87
  // `claude-opus-5-5` (released 2026-09-22) / `claude-sonnet-5-5` (2026-09-28), 1M ctx / 128k sync max output, adaptive
86
88
  // thinking (Opus 5.5 always on; Sonnet 5.5 on by default), text+image input. Canonical == wire id; anthropic.ts already
@@ -92,17 +94,7 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
92
94
  // Mythos 5 is limited-availability (Project Glasswing) but callable by id for approved accounts.
93
95
  { canonical: "claude-fable-5", provider: "anthropic", providerModel: "claude-fable-5", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-06" },
94
96
  { canonical: "claude-mythos-5", provider: "anthropic", providerModel: "claude-mythos-5", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true, limitedAvailability: true, releaseDate: "2026-06" },
95
- // NOTE: opus 4.6+ use Anthropic ADAPTIVE thinking (type:"adaptive" + output_config.effort).
96
- // opus 4.7/4.8 OMIT visible thought unless the request opts into `display: "summarized"` —
97
- // anthropic.ts sets that on the adaptive transport so reasoning streams again (gjc parity).
98
- // The nativizable path still replays signature-only thinking blocks for cross-turn continuity.
99
- // The 4.6 generation onward is 1M context / 128k sync max output (Anthropic docs:
100
- // Opus 4.8 & Sonnet 5 comparison table; Opus 4.6/4.7, Sonnet 4.6 share the gen's
101
- // dateless-snapshot 1M/128k + 300k batch-output beta).
102
- { canonical: "claude-opus-4-8", provider: "anthropic", providerModel: "claude-opus-4-8", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-05" },
103
- { canonical: "claude-opus-4-7", provider: "anthropic", providerModel: "claude-opus-4-7", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-04" },
104
- { canonical: "claude-sonnet-4-6", provider: "anthropic", providerModel: "claude-sonnet-4-6", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-02" },
105
- { canonical: "claude-opus-4-6", provider: "anthropic", providerModel: "claude-opus-4-6", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-02" },
97
+
106
98
  // OpenAI (newest first: gpt-5.6 > gpt-5.5 > gpt-5.4 > o4-mini/o3/gpt-4.1 > o3-mini > gpt-4o-mini > gpt-4o)
107
99
  { canonical: "gpt-5.6", provider: "openai", providerModel: "gpt-5.6", contextTokens: 400_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-06" },
108
100
  { canonical: "gpt-5.5", provider: "openai", providerModel: "gpt-5.5", contextTokens: 400_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-04" },
@@ -526,7 +518,11 @@ export function formatTokens(n: number): string {
526
518
  /** Exact lookup by canonical id or provider model id. */
527
519
  export function findCatalogModel(idOrModel: string): CatalogModel | undefined {
528
520
  const q = idOrModel.trim();
529
- return MODEL_CATALOG.find(m => m.canonical === q || m.providerModel === q || `${m.provider}/${m.providerModel}` === q);
521
+ return MODEL_CATALOG.find(m =>
522
+ m.canonical === q
523
+ || `${m.provider}/${m.providerModel}` === q
524
+ || (!m.canonical.startsWith(`${m.provider}/`) && m.providerModel === q),
525
+ );
530
526
  }
531
527
 
532
528
  /**
@@ -653,7 +649,10 @@ export function catalogMetadata(modelId: string): CatalogModel | undefined {
653
649
  if (direct) return direct;
654
650
  // Tolerate provider-prefixed or bare provider model ids.
655
651
  const bare = modelId.replace(/^[a-z-]+\//, "");
656
- const hit = MODEL_CATALOG.find(m => m.providerModel === bare || m.providerModel.endsWith(`/${bare}`) || m.canonical === bare);
652
+ const hit = MODEL_CATALOG.find(m =>
653
+ m.canonical === bare
654
+ || (!m.canonical.startsWith(`${m.provider}/`) && (m.providerModel === bare || m.providerModel.endsWith(`/${bare}`))),
655
+ );
657
656
  if (hit) return hit;
658
657
  // Last resort: infer capabilities from the model family so a brand-new
659
658
  // revision still surfaces reasoning/thinking like its catalogued siblings.
@@ -9,18 +9,18 @@ export interface ModelAliases {
9
9
  export const BUILTIN_ALIASES: ModelAliases = {
10
10
  fast: "ollama/qwen2.5:0.5b",
11
11
  local: "ollama/qwen2.5:0.5b",
12
- sonnet: "claude-sonnet-4-6",
13
- opus: "claude-opus-4-6",
12
+ sonnet: "claude-sonnet-5-5",
13
+ opus: "claude-opus-5-5",
14
14
  gpt: "gpt-5.5",
15
15
  flash: "gemini-2.5-flash",
16
16
  grok: "grok-4.3",
17
17
  kimi: "kimi-k2-0711-preview",
18
- // Anthropic 5.5 generation: spellings that name the version. Bare `opus`/`sonnet` above stay on the
19
- // 4.6 ids (pinned backward-compat); users opt into 5.5 by naming the version.
18
+ // Anthropic 5.5 generation: versioned spellings as convenience aliases.
20
19
  "opus-5.5": "claude-opus-5-5",
21
20
  "sonnet-5.5": "claude-sonnet-5-5",
22
21
  "opus5.5": "claude-opus-5-5",
23
22
  "sonnet5.5": "claude-sonnet-5-5",
23
+
24
24
  // Antigravity's backend DEPRECATED this wire id (deprecatedModelIds:
25
25
  // { "gemini-3.1-pro-high": { newModelId: "gemini-pro-agent" } }) — keep
26
26
  // existing configs/roles pinned to the old id working on the successor.
@@ -44,6 +44,11 @@ export interface RouteSlashResult {
44
44
  lines: string[];
45
45
  }
46
46
 
47
+ /** Off means a FIXED model: no prompt routing AND no equivalent-model failover. */
48
+ function stateLabel(enabled: boolean): string {
49
+ return enabled ? "routing: on (this session)" : "routing: off (this session) — model fixed, no failover";
50
+ }
51
+
47
52
  const USAGE = "Usage: /route [status|on|off|why|history [n]|save|on save|off save]";
48
53
 
49
54
  function isRealDecision(decision: RouteDecision | { note: string } | null): decision is RouteDecision {
@@ -92,7 +97,7 @@ export async function runRouteSlash(input: string, ctx: RouteSlashCtx): Promise<
92
97
  const effective = ctx.sessionRouteOverride ?? ctx.routingConfigEnabled;
93
98
 
94
99
  if (!sub || sub === "status") {
95
- const lines = [`routing: ${effective ? "on" : "off"} (this session)`];
100
+ const lines = [stateLabel(effective)];
96
101
  if (isRealDecision(ctx.lastRouteDecision)) {
97
102
  lines.push(`last decision: ${ctx.lastRouteDecision.tier} → ${ctx.lastRouteDecision.model} (${ctx.lastRouteDecision.source}: ${ctx.lastRouteDecision.signals.join(", ") || "none"}, confidence ${ctx.lastRouteDecision.confidence.toFixed(2)})`);
98
103
  }
@@ -113,9 +118,9 @@ export async function runRouteSlash(input: string, ctx: RouteSlashCtx): Promise<
113
118
  const enabled = sub === "on";
114
119
  if (arg === "save") {
115
120
  await saveConfigPatch(raw => ({ routing: { ...raw.routing, enabled } }));
116
- return { sessionRouteOverride: enabled, lines: [`routing: ${enabled ? "on" : "off"} (this session) — saved to ~/.jeo/config.json`] };
121
+ return { sessionRouteOverride: enabled, lines: [`${stateLabel(enabled)} — saved to ~/.jeo/config.json`] };
117
122
  }
118
- return { sessionRouteOverride: enabled, lines: [`routing: ${enabled ? "on" : "off"} (this session)`] };
123
+ return { sessionRouteOverride: enabled, lines: [stateLabel(enabled)] };
119
124
  }
120
125
 
121
126
  // Bare `/route save` persists whatever is CURRENTLY effective (a prior
@@ -1659,7 +1659,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1659
1659
  const fullTools = {
1660
1660
  ...DEFAULT_TOOLS,
1661
1661
  task: createTaskTool({
1662
- config: { ...turnConfig, defaultModel: activeModel },
1662
+ // Subagents inherit the parent's EFFECTIVE routing state (`/route on|off`
1663
+ // override ?? config) — task-tool.ts gates its failover reroute on it.
1664
+ config: { ...turnConfig, defaultModel: activeModel, routing: { ...turnConfig.routing, enabled: routingEnabled } },
1665
+
1663
1666
  signal: ac.signal,
1664
1667
  steer: drainSteer,
1665
1668
  registry: subagentRegistry,
@@ -1710,12 +1713,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1710
1713
  // mutated in place (`.add`) as the fallback loop below tries more models — this
1711
1714
  // closure always reads its live contents, never a stale snapshot.
1712
1715
  const attemptedRouteModels = new Set<string>([activeModel]);
1713
- // A NON-routed turn (routing disabled, or the user pinned a model via /model)
1714
- // still needs the equivalent-pool fallback on a usage-limit/rate-limit/credential
1715
- // failure — the exact "5-hour usage window exhausted, model just stops working"
1716
- // case this loop exists to fix. Synthesize a same-size-class RouteDecision
1717
- // (inferTierForModel) so `equivalentRouteFallback` has a tier/pool to search even
1718
- // with routing off; a real `routed` decision (routing enabled) is used unchanged.
1716
+ // The synthesized decision below is only the BASE for `equivalentRouteFallback`'s
1717
+ // tier/pool search when the pre-call router produced no decision (routing on but
1718
+ // fail-open, or a /model pin with `/route on`). It is inert when routing is off:
1719
+ // the reroute loop and the engine's bail predicates are both gated on
1720
+ // `routingEnabled`, so `/route off` means a FIXED model with no failover.
1721
+ // (`inferTierForModel` gives the same-size-class tier/pool to search.)
1722
+
1719
1723
  let fallbackBaseDecision: RouteDecision = routed ?? {
1720
1724
  model: activeModel,
1721
1725
  thinking: activeThinking,
@@ -1785,8 +1789,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1785
1789
  signal: ac.signal,
1786
1790
  sessionKey: cacheKey,
1787
1791
  steer: drainSteer,
1788
- rateLimitFallbackAvailable,
1789
- safetyFallbackAvailable,
1792
+ // Withheld when routing is off: the bail predicates only make sense paired
1793
+ // with the reroute loop below, which `routingEnabled` also gates.
1794
+ ...(routingEnabled ? { rateLimitFallbackAvailable, safetyFallbackAvailable } : {}),
1795
+
1790
1796
  events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
1791
1797
  });
1792
1798
  const routeFailureReason = (doneReason: unknown): string | null => {
@@ -1866,7 +1872,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1866
1872
  const routeFallbackMax = Number.isFinite(routeFallbackMaxRaw) && routeFallbackMaxRaw >= 0
1867
1873
  ? Math.floor(routeFallbackMaxRaw)
1868
1874
  : 3;
1869
- for (let routeFallbackAttempt = 0; routeFallbackAttempt < routeFallbackMax; routeFallbackAttempt++) {
1875
+ for (let routeFallbackAttempt = 0; routingEnabled && routeFallbackAttempt < routeFallbackMax; routeFallbackAttempt++) {
1876
+
1870
1877
  // A loop guard is a local safety boundary, not a provider failure. A
1871
1878
  // fresh loop on an equivalent model loses the guard state and could
1872
1879
  // re-execute a previously skipped mutation.
@@ -1962,10 +1969,20 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1962
1969
  }
1963
1970
  // A completed turn with an empty done-reason must NOT masquerade as a step-limit
1964
1971
  // failure ("reached the 3-step limit" after the model called done at step 3).
1965
- const reply = stripSafetyFallbackTag(result.doneReason ?? "")
1972
+ const baseReply = stripSafetyFallbackTag(result.doneReason ?? "")
1966
1973
  || (result.done
1967
1974
  ? `(done in ${result.steps} step${result.steps === 1 ? "" : "s"} — the model returned no summary)`
1968
1975
  : `(reached the ${result.steps}-step limit without signaling done)`);
1976
+ // Routing off = the model is fixed and nothing fails over. When the turn died on
1977
+ // an account-level limit, say so and name the one switch that re-enables failover.
1978
+ const failoverHint = !routingEnabled && !result.done && (() => {
1979
+ const failure = new Error(result.doneReason ?? "");
1980
+ return isRateLimitError(failure) || isUsageLimitError(failure);
1981
+ })()
1982
+ ? "\n(routing is off — the model is fixed and no equivalent-model failover was attempted. Run '/route on' to allow failover.)"
1983
+ : "";
1984
+ const reply = `${baseReply}${failoverHint}`;
1985
+
1969
1986
  if (sessionNotifyEndpoint) {
1970
1987
  const usageStr = result.usage ? formatUsage(result.usage) : undefined;
1971
1988
  if (notifyRedact) {