jeo-code 0.7.56 → 0.7.58

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
@@ -6,6 +6,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.7.58] - 2026-07-07
10
+ _Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after._
11
+
12
+ ### Added
13
+ - **`CatalogModel.releaseDate`** (`"YYYY-MM"`) on every catalog entry — deep-research sourced from each provider's own announcement (Anthropic/OpenAI/Google/xAI/Moonshot direct web_search per family; Tencent-hosted rebrands (DeepSeek/MiniMax/Zhipu GLM/Hunyuan) traced to the ORIGINAL developer's release, not Tencent's hosting-catalog appearance date). `gpt-5.4-mini` was never added to the catalog — confirmed absent both before and after this pass, per explicit request.
14
+
15
+ ### Changed
16
+ - **`MODEL_CATALOG` reordered newest-first within every provider block** for human readability (routing correctness never depended on array order — the ranking functions below now sort/tiebreak explicitly).
17
+ - **Recency tiebreak replaces the arbitrary alphabetical one** in `cheapestCredentialed`/`strongestCredentialed`/`compareStrengthAscending` (`src/agent/prompt-router.ts`) — on an exact capability/cost tie, the NEWER `releaseDate` now wins (a missing date always sorts as oldest, so an unconfirmed date can never silently outrank a verified-newer model). Fixes the confirmed opus-4-6/4-7/4-8 bug above.
18
+ - **`catalogForProvider`'s secondary sort** (`src/ai/model-catalog-compat.ts`) now orders by `releaseDate` (newest first) instead of alphabetical id — this is the actual list order `/model`'s live picker and `jeo setup` show a user, so "정렬" now reaches the real user-facing surface, not just the raw catalog array.
19
+ - **`RECOMMENDED` provider defaults refreshed** to the current (2026-07) general-availability flagship per provider: `claude-sonnet-5` (was `claude-sonnet-4-6`), `gpt-5.5` (was `gpt-4o`), `gemini-3-flash` (was `gemini-2.0-flash`), `antigravity/gpt-5.5` (was `antigravity/gemini-3-pro-low`).
20
+
21
+ ### Verified
22
+ - `bun run typecheck` clean; full suite 2710/2710 pass across 281 files (1 pre-existing unrelated flaky test — `session.test.ts`'s global-`JSON.parse`-mock count assertion, confirmed passing in isolation and on rerun — not a regression from this change).
23
+ - Live-executed the exact reported bug before AND after the fix: `strongestCredentialed(anthropicOnlyConfig, m => m.canonical.startsWith("claude-opus"))` returned `claude-opus-4-6` (oldest) before, `claude-opus-4-8` (newest, correct) after.
24
+ - Re-applied `routing.crossProviderPool: true` to the live user config (a concurrent interactive session had reset it via an unrelated `/model` command mid-turn) and reconfirmed via `jeo doctor`: all 4 tiers correctly resolve through the cross-provider pool across anthropic/gemini/antigravity.
25
+
26
+ ## [0.7.57] - 2026-07-07
27
+ _"동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in._
28
+
29
+ ### Added
30
+ - **`routing.crossProviderPool`** (opt-in, default off) — session-STABLE selection across every credentialed provider's EQUIVALENT-class model for a tier (`sizeClassFor`: provider-declared size suffixes — haiku/flash/mini=small, sonnet/pro=mid, opus/fable=large — computed live off `MODEL_CATALOG`, with a strength-tercile fallback for the ~50% of the catalog with no size-suffix naming). The same session always resolves to the same model (preserves provider-side prompt-cache warmth turn-to-turn); different sessions spread across different providers. Purely additive — unset, every existing config's behavior is byte-identical to v0.7.56.
31
+ - **`CatalogModel.limitedAvailability`** — a real bug this work surfaced: invite-only models (`claude-mythos-5`) were reachable by auto-select/pool even though a provider credential doesn't imply access to that specific model. Now excluded from all auto-select paths; still targetable explicitly by id for approved accounts.
32
+ - **`jeo doctor` routing preview** extended with an `"auto-selected: cross-provider pool"` source label when `crossProviderPool` resolved the shown model.
33
+
34
+ ### Verified
35
+ - `bun run typecheck` clean; full suite 2692/2692 pass across 280 files.
36
+ - End-to-end against the real 4-provider production config: `tierModelPool("trivial")` spans gemini/openai/anthropic/antigravity; 5 distinct sessions distribute across 3 different providers while each individual session stays stable across repeated turns.
37
+
9
38
  ## [0.7.56] - 2026-07-07
10
39
  _"서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale._
11
40
 
package/README.ja.md CHANGED
@@ -259,11 +259,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
259
259
  ## 変更履歴 (Changelog)
260
260
 
261
261
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
262
+ - **[0.7.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
263
+ - **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
262
264
  - **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
263
265
  - **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
264
266
  - **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
265
- - **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
266
- - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
267
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -260,11 +260,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
260
260
  ## 변경 이력 (Changelog)
261
261
 
262
262
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
263
+ - **[0.7.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
264
+ - **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
263
265
  - **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
264
266
  - **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
265
267
  - **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
266
- - **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
267
- - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
268
268
 
269
269
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
270
270
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -264,11 +264,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
264
264
  ## Changelog
265
265
 
266
266
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
267
+ - **[0.7.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
268
+ - **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
267
269
  - **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
268
270
  - **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
269
271
  - **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
270
- - **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
271
- - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
272
272
 
273
273
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
274
274
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -259,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
259
259
  ## 更新日志 (Changelog)
260
260
 
261
261
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
262
+ - **[0.7.58]** (2026-07-07) — Applied the previous entry's `routing.crossProviderPool` to the real live session config, then deep-researched every catalogued model's actual public release date (2026-07 sourced, per-provider parallel research with citations) to answer "모델 설정은 2026년도 기준으로 딥서치해서 모델리스트 최신순으로 정렬" — while researching, found and fixed a genuine ranking bug this surfaced: `strongestCredentialed`'s tiebreak on a full capability tie (identical thinking/output/context) fell through to a raw alphabetical canonical-id compare, which silently picked the OLDEST model in a same-spec family (`claude-opus-4-6` over `claude-opus-4-7`/`4-8`, purely because `"4-6" < "4-8"` as a string) — confirmed reproducible before the fix, confirmed corrected after.
263
+ - **[0.7.57]** (2026-07-07) — "동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — v0.7.56's tier auto-select was single-winner by design (always the one cheapest/strongest model), so a user with `roles.smol` pinned (or simply multiple credentialed providers) never saw actual distribution across equivalent-class models. Deep-research-informed redesign: production LLM routers (LiteLLM, Bifrost/VoidLLM) model this as a named "model group" spanning provider deployments with a distribution strategy, not a single deterministic pick — jeo now supports both, opt-in.
262
264
  - **[0.7.56]** (2026-07-07) — "서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — PromptRouter's `trivial`/`complex` tiers previously collapsed to `defaultModel` whenever `roles.smol`/`roles.slow`/`routing.tiers.*` were left unconfigured (the documented "safe no-op absent configuration" contract), which meant a user with multiple providers credentialed never actually got cross-provider routing without hand-picking a model per tier. Also the long-term ask: routing must stay correct as jeo's model catalog evolves, never a hand-maintained tier→model table that goes stale.
263
265
  - **[0.7.55]** (2026-07-07) — Cross-provider routing (PromptRouter, v0.7.47+) already named the routed tier in the status bar (v0.7.52), but nothing surfaced WHICH model/provider actually produced a turn's reasoning in the Thinking-block surfaces — a gap noticed while manually verifying the v0.7.52 status-bar work end-to-end.
264
266
  - **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
265
- - **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
266
- - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
267
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.56",
3
+ "version": "0.7.58",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -186,6 +186,9 @@ export const ConfigSchema = z
186
186
  roles: z
187
187
  .object({
188
188
  smol: z.string().optional(),
189
+ medium: z.string().optional(),
190
+ high: z.string().optional(),
191
+ xhigh: z.string().optional(),
189
192
  slow: z.string().optional(),
190
193
  plan: z.string().optional(),
191
194
  })
@@ -203,9 +206,11 @@ export const ConfigSchema = z
203
206
  .object({
204
207
  trivial: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
205
208
  standard: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
209
+ high: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
206
210
  complex: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
207
211
  })
208
212
  .optional(),
213
+ crossProviderPool: z.boolean().optional(),
209
214
  })
210
215
  .optional(),
211
216
  gitAutoCommit: z.boolean().optional(),
@@ -320,6 +325,15 @@ export function parseConfig(raw: unknown): { ok: true; config: ValidatedConfig }
320
325
  if (config.roles.smol) {
321
326
  config.roles.smol = normalizeModelId(config.roles.smol, aliases);
322
327
  }
328
+ if (config.roles.medium) {
329
+ config.roles.medium = normalizeModelId(config.roles.medium, aliases);
330
+ }
331
+ if (config.roles.high) {
332
+ config.roles.high = normalizeModelId(config.roles.high, aliases);
333
+ }
334
+ if (config.roles.xhigh) {
335
+ config.roles.xhigh = normalizeModelId(config.roles.xhigh, aliases);
336
+ }
323
337
  if (config.roles.slow) {
324
338
  config.roles.slow = normalizeModelId(config.roles.slow, aliases);
325
339
  }
@@ -264,6 +264,31 @@ export async function loadProjectContext(cwd = process.cwd()): Promise<ProjectCo
264
264
  const home = process.env.HOME ? path.resolve(process.env.HOME) : null;
265
265
  const collectedItems: ContextItem[] = [];
266
266
 
267
+ // 0. User-global context files (~/.jeo/agent/AGENTS.md) — lowest precedence.
268
+ // Native user files come first in the result so project files later take precedence.
269
+ // Only jeo's own user config applies; foreign-provider user files are excluded (e.g.
270
+ // ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md stay out — only ~/.jeo/* is loaded).
271
+ //
272
+ // This is optional: if the user file doesn't exist or is unreadable, we silently skip.
273
+ // Symlinks are followed (fs.stat resolves them), so ~/.jeo/agent/AGENTS.md can be a link.
274
+ if (home) {
275
+ const userAgentFile = path.join(home, ".jeo", "agent", "AGENTS.md");
276
+ try {
277
+ const stat = await fs.stat(userAgentFile);
278
+ // Check: must be a regular file (not directory) and non-empty.
279
+ // fs.stat follows symlinks, so this works transparently with linked files.
280
+ if (stat.isFile() && stat.size > 0) {
281
+ const file = await readContextFile(userAgentFile, "~/.jeo/agent/AGENTS.md", BASE_CONTEXT_CHARS - baseChars);
282
+ if (file) {
283
+ result.push(file);
284
+ baseChars += file.content.length;
285
+ }
286
+ }
287
+ } catch {
288
+ // User-global file not found, unreadable, or permission denied; silently continue.
289
+ // User config is optional; its absence is not an error condition.
290
+ }
291
+ }
267
292
  // 1. CWD 및 부모 walk
268
293
  let curr = resolvedCwd;
269
294
  let distance = 0;
@@ -12,7 +12,7 @@ import * as path from "node:path";
12
12
  import type { Message } from "./loop";
13
13
  import { extractJsonObject } from "./json";
14
14
  import { nativeToolSchemasFor, normalizeNativeToolName } from "./tool-schemas";
15
- import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, type ToolResult } from "./tools";
15
+ import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, bisectTool, type ToolResult } from "./tools";
16
16
  import { webSearchTool, setWebSearchActiveModel } from "./web-search";
17
17
  import { executeComputerAction } from "../commands/computer";
18
18
  import { computerSupervisor } from "./computer-supervisor";
@@ -105,6 +105,7 @@ export const DEFAULT_TOOLS: Record<string, ToolHandler> = {
105
105
  lsp_rename: createLspRenameTool(),
106
106
  debug: createDebugTool(),
107
107
  browser: createBrowserTool(),
108
+ bisect: (a, cwd) => bisectTool(a.good, a.bad, a.run, !!a.invert, a.maxSteps, a.stepTimeoutMs, cwd),
108
109
  };
109
110
  /** Tool-protocol description injected into the system prompt. */
110
111
  export const TOOL_PROTOCOL = [
@@ -126,7 +127,8 @@ export const TOOL_PROTOCOL = [
126
127
  "15. lsp_rename {file, line, symbol?, new_name, apply?} — cross-file TypeScript/JavaScript rename (apply defaults true; apply:false previews only)",
127
128
  "16. debug {action, ...} — Node.js debugging (launch/set_breakpoint/continue/step_over/step_in/step_out/pause/evaluate/stack_trace/scopes/variables/threads/output/terminate); one active session, 'launch' spawns 'node --inspect-brk' and pauses at start",
128
129
  "17. browser {action:\"open\"|\"close\"|\"run\"|\"act\", name?, url?, actions?} — headless Chromium automation (Playwright); 'act' runs structured steps (navigate/click/type/fill/select/press/scroll/back/wait/observe/extract/screenshot) addressing elements by numeric id from 'observe' or a CSS selector; 'run' executes JS with page/browser/tab/display/assert/wait in scope",
129
- "18. done {reason?} — call when the task is fully implemented AND verified",
130
+ "18. bisect {good, bad?, run, invert?, maxSteps?, stepTimeoutMs?} — binary-search git history to find the commit that introduced a regression; 'good' is a known-good commit, 'bad' defaults to HEAD, 'run' is a test command (exit 0=good, non-zero=bad, 125=skip), 'invert' finds a FIX instead of a regression",
131
+ "19. done {reason?} — call when the task is fully implemented AND verified",
130
132
  "",
131
133
  "Reply with STRICT JSON only — no code fences. You MAY include an optional leading",
132
134
  '"reasoning" string (one short sentence on your plan) before "tool":',
@@ -18,17 +18,34 @@
18
18
  * `config.roles`/`config.routing.tiers` so a user who already configured
19
19
  * `roles.smol`/`roles.slow` (for `--smol`/`--slow` role-tier resolution
20
20
  * elsewhere) gets working routing with zero additional config. An unconfigured
21
- * trivial/complex tier does NOT simply collapse to `defaultModel` (that would
22
- * defeat cross-provider routing for every user who hasn't hand-tuned
23
- * `routing.tiers`) — it auto-selects the cheapest (trivial) / most capable
24
- * (complex) model jeo has a stored credential for, computed LIVE off
25
- * `MODEL_CATALOG`/`pricing.ts` on every call. This is the "long-term" fix for
26
- * catalog drift: when jeo ships a new/cheaper/stronger model, auto-select
27
- * picks it up on the next call automatically — no user config edit, no
28
- * hand-maintained tier→model table to go stale. "standard" is the one
29
- * exception: it always falls through to `defaultModel` unless explicitly
30
- * configured, since routine day-to-day work should stay on the user's
31
- * deliberately chosen default rather than being silently reassigned.
21
+ * trivial/high/complex tier does NOT simply collapse to `defaultModel` (that
22
+ * would defeat cross-provider routing for every user who hasn't hand-tuned
23
+ * `routing.tiers`) — it auto-selects the cheapest (trivial) / a strong
24
+ * mid-class model (high) / the most capable (complex) model jeo has a stored
25
+ * credential for, computed LIVE off `MODEL_CATALOG`/`pricing.ts` on every
26
+ * call. This is the "long-term" fix for catalog drift: when jeo ships a
27
+ * new/cheaper/stronger model, auto-select picks it up on the next call
28
+ * automatically — no user config edit, no hand-maintained tier→model table to
29
+ * go stale. "standard" is the one exception: it always falls through to
30
+ * `defaultModel` unless explicitly configured, since routine day-to-day work
31
+ * should stay on the user's deliberately chosen default rather than being
32
+ * silently reassigned (unless `routing.crossProviderPool` opts it into the
33
+ * session-stable cross-provider pool below).
34
+ *
35
+ * Four content-based tiers map onto the four `roles.*` price points
36
+ * (smol/medium/high/xhigh) so automatic routing can actually reach all of
37
+ * them: `trivial`→smol, `standard`→medium, `high`→roles.high (a single
38
+ * borderline-complex signal — e.g. one deep-work keyword alone — no longer
39
+ * jumps straight to the most expensive `complex`/xhigh tier), `complex`→xhigh.
40
+ *
41
+ * `routing.crossProviderPool` (opt-in, default off) activates
42
+ * `tierModelPool`/`selectFromPool` for the `standard`/`high` tiers: instead of
43
+ * a single deterministic pick, a SESSION-STABLE hash spreads different
44
+ * sessions across every credentialed provider's equivalent-class model, so
45
+ * jeo actually captures different providers' respective strengths on
46
+ * comparably-priced work instead of converging every session onto one
47
+ * provider. Off by default to preserve the "safe no-op absent configuration"
48
+ * contract for users who never opted in.
32
49
  */
33
50
  import { callLlm } from "./loop";
34
51
  import { resolveRoleModel } from "../ai/model-manager";
@@ -40,7 +57,7 @@ import type { ThinkLevel } from "../ai/model-catalog";
40
57
  import type { ProviderName } from "../ai/types";
41
58
  import type { AuthProvider } from "../auth";
42
59
 
43
- export type PromptTier = "trivial" | "standard" | "complex";
60
+ export type PromptTier = "trivial" | "standard" | "high" | "complex";
44
61
 
45
62
  export interface HeuristicResult {
46
63
  tier: PromptTier;
@@ -62,6 +79,12 @@ export interface RouteDecision {
62
79
  export interface RoutePromptOptions {
63
80
  hasImages?: boolean;
64
81
  signal?: AbortSignal;
82
+ /** Threads through to `selectFromPool` for session-stable cross-provider pool
83
+ * selection — the same session always lands on the same pool member (keeps
84
+ * provider-side prompt caching warm turn-to-turn), while different sessions
85
+ * spread across the pool. Omitted (e.g. a one-shot `jeo chat` call) ->
86
+ * deterministic index 0. */
87
+ sessionId?: string;
65
88
  }
66
89
 
67
90
  // --- Frozen, bilingual-validated classification signals (do not re-derive). ---
@@ -143,7 +166,10 @@ export function classifyPromptHeuristically(prompt: string): HeuristicResult {
143
166
  return { tier: "standard", confidence: 0.35, signals }; // conflicting signals -> let escalation decide
144
167
  }
145
168
  if (complexScore >= 2) return { tier: "complex", confidence: 0.85, signals };
146
- if (complexScore === 1) return { tier: "complex", confidence: 0.65, signals };
169
+ // A single complex signal alone is treated as "high" (elevated but not full complex) —
170
+ // price efficiency: one weak/borderline signal must not jump straight to the priciest
171
+ // xhigh tier (that's reserved for >=2 corroborating complex signals).
172
+ if (complexScore === 1) return { tier: "high", confidence: 0.65, signals };
147
173
  if (trivialScore >= 2) return { tier: "trivial", confidence: 0.85, signals };
148
174
  if (trivialScore === 1) return { tier: "trivial", confidence: 0.65, signals };
149
175
  return { tier: "standard", confidence: 0.9, signals }; // no signals at all -> common case, high confidence
@@ -185,12 +211,114 @@ function isCloudProvider(p: ProviderName): p is AuthProvider {
185
211
  return p !== "ollama" && p !== "lmstudio";
186
212
  }
187
213
 
214
+ /** Size class jeo infers for a catalog model, used to group EQUIVALENT models
215
+ * across DIFFERENT providers for pool-based routing (see `tierModelPool`) — the
216
+ * cross-provider counterpart to `cheapestCredentialed`/`strongestCredentialed`'s
217
+ * single-winner selection. Matches PROVIDER-DECLARED size-tier suffixes
218
+ * (Anthropic's haiku/sonnet/opus, Google's flash/pro, OpenAI's mini) against
219
+ * hyphen/dot-delimited SEGMENTS of the canonical id — NOT a raw substring match,
220
+ * which would false-positive on "gemini" containing "mini" as a substring.
221
+ * `null` for ids with no size-tier suffix at all (gpt-5.5, o3, grok-4.3, kimi-*,
222
+ * glm-*, deepseek-*, minimax-* — providers whose naming doesn't encode size);
223
+ * `tierModelPool` falls those back to a strength-tercile split instead. */
224
+ function sizeClassFor(canonical: string): "small" | "mid" | "large" | null {
225
+ const id = canonical.toLowerCase();
226
+ const bare = id.includes("/") ? id.slice(id.indexOf("/") + 1) : id;
227
+ const segments = bare.split(/[-.]/);
228
+ if (segments.includes("haiku") || segments.includes("flash") || segments.includes("mini") || segments.includes("nano")) return "small";
229
+ if (segments.includes("opus") || segments.includes("ultra") || segments.includes("fable") || segments.includes("mythos")) return "large";
230
+ if (segments.includes("sonnet") || segments.includes("pro")) return "mid";
231
+ return null;
232
+ }
233
+
234
+ const TIER_TO_SIZE_CLASS: Record<PromptTier, "small" | "mid" | "large"> = { trivial: "small", standard: "mid", high: "mid", complex: "large" };
235
+
236
+ /** Compares `releaseDate` ("YYYY-MM", lexicographically sortable). `>0` when `a`
237
+ * is NEWER than `b`. A missing date sorts as OLDEST on that side — an
238
+ * unconfirmed date can never silently outrank a verified-newer model. Shared
239
+ * recency tiebreak for `compareStrengthAscending`/`cheapestCredentialed`/
240
+ * `strongestCredentialed`, replacing the arbitrary alphabetical canonical-id
241
+ * tiebreak those used before (which, on a full capability tie, picked the
242
+ * OLDEST id alphabetically — e.g. `claude-opus-4-6` beating `claude-opus-4-8`
243
+ * as "strongest" purely because "4-6" < "4-8" as a string). */
244
+ function compareRecency(a: CatalogModel, b: CatalogModel): number {
245
+ if (a.releaseDate === b.releaseDate) return 0;
246
+ if (!a.releaseDate) return -1;
247
+ if (!b.releaseDate) return 1;
248
+ return a.releaseDate < b.releaseDate ? -1 : 1;
249
+ }
250
+
251
+ /** Multi-key strength comparator (ascending: weakest first) — same ranking signals
252
+ * as `strongestCredentialed`'s single-winner tiebreak, reused here to bucket
253
+ * UNCLASSIFIED (no size-suffix) models into a tercile fallback. */
254
+ function compareStrengthAscending(a: CatalogModel, b: CatalogModel): number {
255
+ const xhighA = a.thinking.includes("xhigh") ? 1 : 0;
256
+ const xhighB = b.thinking.includes("xhigh") ? 1 : 0;
257
+ if (xhighA !== xhighB) return xhighA - xhighB;
258
+ if (a.maxOutputTokens !== b.maxOutputTokens) return a.maxOutputTokens - b.maxOutputTokens;
259
+ if (a.contextTokens !== b.contextTokens) return a.contextTokens - b.contextTokens;
260
+ const recency = compareRecency(a, b);
261
+ if (recency !== 0) return recency;
262
+ return a.canonical < b.canonical ? -1 : a.canonical > b.canonical ? 1 : 0;
263
+ }
264
+
265
+ /** Cross-provider EQUIVALENCE pool for `tier` — every credentialed cloud model
266
+ * jeo classifies as the same size class (trivial→small, standard→mid,
267
+ * complex→large), computed LIVE off `MODEL_CATALOG` so it stays correct as the
268
+ * catalog evolves. Models with no size-tier-suffix (gpt-5.5, o3, grok-4.3,
269
+ * kimi-*, glm-*, …) fall back to a strength-tercile bucket instead of being
270
+ * silently excluded from every pool. Returns `[]` when zero credentialed
271
+ * models qualify (caller falls back to `defaultModel`). Sorted by canonical id
272
+ * for deterministic pool ordering (required by `selectFromPool`'s index math). */
273
+ export function tierModelPool(tier: PromptTier, config: RoutingConfig): string[] {
274
+ const targetClass = TIER_TO_SIZE_CLASS[tier];
275
+ const credentialed = MODEL_CATALOG.filter(m => isCloudProvider(m.provider) && !m.limitedAvailability && (config.providers?.[m.provider] || config.oauth?.[m.provider]));
276
+
277
+ const suffixPool = credentialed.filter(m => sizeClassFor(m.canonical) === targetClass);
278
+
279
+ const unclassified = credentialed.filter(m => sizeClassFor(m.canonical) === null).sort(compareStrengthAscending);
280
+ const third = Math.ceil(unclassified.length / 3);
281
+ // "high" shares "standard"'s unclassified-model tercile share: the catalog's
282
+ // size-suffix classification only has 3 real buckets (small/mid/large — see
283
+ // sizeClassFor), so there is no distinct 4th tercile to carve out for "high".
284
+ // resolveTierModel's "high" branch does not currently rely on this tercile
285
+ // share (it prefers a deterministic strongest-mid-class pick — see
286
+ // strongestMidTierCredentialed below); kept here only so this `Record<PromptTier,…>`
287
+ // stays exhaustive for external callers of `tierModelPool("high", …)`.
288
+ const tercileByTier: Record<PromptTier, CatalogModel[]> = {
289
+ trivial: unclassified.slice(0, third),
290
+ standard: unclassified.slice(third, third * 2),
291
+ high: unclassified.slice(third, third * 2),
292
+ complex: unclassified.slice(third * 2),
293
+ };
294
+
295
+ const pool = [...suffixPool, ...tercileByTier[tier]];
296
+ return pool.map(m => m.canonical).sort();
297
+ }
298
+
299
+ /** Deterministic, SESSION-STABLE pick from `pool` — the same `sessionId` always
300
+ * resolves to the same index within one pool (so a session's provider-side
301
+ * prompt cache stays warm turn-to-turn, per this file's `deriveCacheSessionKey`
302
+ * cache-correlation contract), while DIFFERENT sessions spread across the pool's
303
+ * providers (the actual cross-provider distribution this feature exists for).
304
+ * No `sessionId` (e.g. a one-shot `jeo chat` call) deterministically picks index
305
+ * 0 (the pool is canonical-id-sorted, so this is still stable, just unvaried). */
306
+ export function selectFromPool(pool: readonly string[], sessionId: string | undefined): string {
307
+ if (pool.length === 0) throw new Error("selectFromPool: empty pool");
308
+ if (pool.length === 1 || !sessionId) return pool[0];
309
+ let h = 0x811c9dc5 | 0;
310
+ for (let i = 0; i < sessionId.length; i++) h = Math.imul(h ^ sessionId.charCodeAt(i), 0x01000193);
311
+ return pool[(h >>> 0) % pool.length];
312
+ }
313
+
188
314
  /** Cheapest cloud model jeo has a credential for, computed LIVE off
189
315
  * `MODEL_CATALOG`/`priceForModel` — picks up new/repriced catalog entries
190
316
  * automatically, never a hand-maintained id. `null` when no credentialed
191
- * model has a known price (caller falls back to `defaultModel`). Tiebreak:
192
- * canonical id (deterministic across otherwise-equal candidates). */
193
- function cheapestCredentialed(config: RoutingConfig): string | null {
317
+ * model has a known price (caller falls back to `defaultModel`). Tiebreak on
318
+ * an exact cost tie: NEWER `releaseDate` wins (a same-priced newer model is
319
+ * likely a refined successor), then canonical id (deterministic fallback
320
+ * when recency also ties or is unconfirmed on both sides). */
321
+ export function cheapestCredentialed(config: RoutingConfig): string | null {
194
322
  let best: CatalogModel | null = null;
195
323
  let bestCost = Infinity;
196
324
  for (const m of MODEL_CATALOG) {
@@ -199,13 +327,16 @@ function cheapestCredentialed(config: RoutingConfig): string | null {
199
327
  // wins (unlike provider-status.ts's `configuredCredential` precedence). The
200
328
  // real veto gate in launch.ts's `runTurn` re-verifies full readiness (including
201
329
  // live OAuth expiry) before a turn actually uses the model.
202
- if (!isCloudProvider(m.provider) || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
330
+ if (!isCloudProvider(m.provider) || m.limitedAvailability || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
203
331
  const price = priceForModel(m.canonical);
204
332
  if (!price) continue;
205
333
  const cost = price.inPerM + price.outPerM;
206
- if (cost < bestCost || (cost === bestCost && best && m.canonical < best.canonical)) {
334
+ if (cost < bestCost) {
207
335
  best = m;
208
336
  bestCost = cost;
337
+ } else if (cost === bestCost && best) {
338
+ const recency = compareRecency(m, best);
339
+ if (recency > 0 || (recency === 0 && m.canonical < best.canonical)) best = m;
209
340
  }
210
341
  }
211
342
  return best?.canonical ?? null;
@@ -213,46 +344,91 @@ function cheapestCredentialed(config: RoutingConfig): string | null {
213
344
 
214
345
  /** Most capable cloud model jeo has a credential for, computed LIVE off
215
346
  * `MODEL_CATALOG` — ranks by full (xhigh) thinking support first, then max
216
- * output tokens, then context window, so a newly catalogued frontier model
217
- * is picked up automatically without a hand-maintained id. `null` when no
218
- * credentialed model qualifies (caller falls back to `defaultModel`).
219
- * Tiebreak: canonical id (deterministic; also happens to prefer widely-
220
- * available ids like `claude-fable-5` alphabetically ahead of limited-
221
- * availability siblings such as `claude-mythos-5`). */
222
- function strongestCredentialed(config: RoutingConfig): string | null {
347
+ * output tokens, then context window, then NEWER `releaseDate` (a same-specs
348
+ * model with a later release date is presumptively a refined successor — e.g.
349
+ * `claude-opus-4-8` over `claude-opus-4-6`, which the OLD alphabetical
350
+ * canonical-id tiebreak got backwards, since `"4-6" < "4-8"` picked the
351
+ * OLDEST opus as "strongest"), then canonical id (final deterministic
352
+ * fallback). `null` when no credentialed model qualifies (caller falls back
353
+ * to `defaultModel`). Excludes `limitedAvailability` models (e.g.
354
+ * `claude-mythos-5`) — a provider credential doesn't imply access to a
355
+ * specific invite-only model; explicit `/model`/`routing.tiers` can still
356
+ * target one by id for approved accounts. */
357
+ export function strongestCredentialed(
358
+ config: RoutingConfig,
359
+ filter?: (m: CatalogModel) => boolean
360
+ ): string | null {
223
361
  let best: CatalogModel | null = null;
224
362
  for (const m of MODEL_CATALOG) {
225
363
  // "ANY stored credential" is enough (see cheapestCredentialed's comment above).
226
- if (!isCloudProvider(m.provider) || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
364
+ if (!isCloudProvider(m.provider) || m.limitedAvailability || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
365
+ if (filter && !filter(m)) continue;
227
366
  if (!best) { best = m; continue; }
228
367
  const xhighM = m.thinking.includes("xhigh") ? 1 : 0;
229
368
  const xhighBest = best.thinking.includes("xhigh") ? 1 : 0;
230
369
  if (xhighM !== xhighBest) { if (xhighM > xhighBest) best = m; continue; }
231
370
  if (m.maxOutputTokens !== best.maxOutputTokens) { if (m.maxOutputTokens > best.maxOutputTokens) best = m; continue; }
232
371
  if (m.contextTokens !== best.contextTokens) { if (m.contextTokens > best.contextTokens) best = m; continue; }
372
+ const recency = compareRecency(m, best);
373
+ if (recency !== 0) { if (recency > 0) best = m; continue; }
233
374
  if (m.canonical < best.canonical) best = m;
234
375
  }
235
376
  return best?.canonical ?? null;
236
377
  }
237
378
 
379
+ /** Strongest credentialed model restricted to the SAME "mid" size class
380
+ * `tierModelPool` uses for `standard` (sonnet/pro-suffixed families) — the
381
+ * auto-select fallback for the `high` tier. Deliberately narrower than
382
+ * `strongestCredentialed`'s catalog-wide search: an unrestricted search would
383
+ * frequently tie with (or even prefer) `complex`'s own pick, since several
384
+ * "unclassified" frontier ids (gpt-5.5, grok-4.3, …) publish flagship-tier
385
+ * specs under a name with no size-suffix at all and would otherwise win over
386
+ * a genuinely mid-class model like `claude-sonnet-4-6`. `null` when no
387
+ * mid-class-suffixed model is credentialed (caller falls through to
388
+ * `roles.medium`/`defaultModel` — never to `complex`'s flagship pick). */
389
+ function strongestMidTierCredentialed(config: RoutingConfig): string | null {
390
+ const midClass = MODEL_CATALOG.filter(
391
+ m => isCloudProvider(m.provider) && !m.limitedAvailability && sizeClassFor(m.canonical) === "mid" && (config.providers?.[m.provider] || config.oauth?.[m.provider]),
392
+ );
393
+ if (midClass.length === 0) return null;
394
+ return midClass.sort(compareStrengthAscending).at(-1)!.canonical;
395
+ }
396
+
397
+ /** `routing.crossProviderPool` (opt-in, default off) fallback for ALL FOUR tiers:
398
+ * a SESSION-STABLE pick from `tierModelPool(tier, config)` instead of each
399
+ * tier's deterministic single-winner pick (`cheapestCredentialed`/
400
+ * `strongestMidTierCredentialed`/`strongestCredentialed`). Off (or an empty
401
+ * pool) returns `null` so the caller's next fallback applies unchanged — this
402
+ * is purely additive: a user who has not set the flag keeps v0.7.56's exact
403
+ * single-winner behavior; setting it distributes qualifying tiers across
404
+ * every credentialed provider's equivalent-class model instead of always
405
+ * resolving to the one deterministic winner. */
406
+ function crossProviderPoolPick(tier: PromptTier, config: RoutingConfig, sessionId: string | undefined): string | null {
407
+ if (!config.routing?.crossProviderPool) return null;
408
+ const pool = tierModelPool(tier, config);
409
+ return pool.length > 0 ? selectFromPool(pool, sessionId) : null;
410
+ }
411
+
238
412
  // --- Tier -> model/thinking resolution (inline off config.roles/config.routing.tiers; no static map). ---
239
413
 
240
414
  /** Exported for `jeo doctor`'s routing-preview diagnostic (same resolution the real
241
415
  * `routePrompt` uses — no duplicated logic between "what WILL routing pick" and
242
416
  * "what does doctor SHOW the user routing will pick"). */
243
- export function resolveTierModel(tier: PromptTier, config: RoutingConfig): string {
417
+ export function resolveTierModel(tier: PromptTier, config: RoutingConfig, sessionId?: string): string {
244
418
  const configured = config.routing?.tiers?.[tier]?.model;
245
419
  if (configured) return configured;
246
- if (tier === "trivial") return config.roles?.smol || cheapestCredentialed(config) || config.defaultModel;
247
- if (tier === "complex") return config.roles?.slow || strongestCredentialed(config) || config.defaultModel;
248
- return config.defaultModel; // "standard" always falls through to defaultModel unless explicitly configured
420
+ if (tier === "trivial") return config.roles?.smol || crossProviderPoolPick(tier, config, sessionId) || cheapestCredentialed(config) || config.defaultModel;
421
+ if (tier === "standard") return config.roles?.medium || config.roles?.high || crossProviderPoolPick(tier, config, sessionId) || config.defaultModel;
422
+ if (tier === "high") return config.roles?.high || config.roles?.medium || crossProviderPoolPick(tier, config, sessionId) || strongestMidTierCredentialed(config) || config.defaultModel;
423
+ if (tier === "complex") return config.roles?.xhigh || config.roles?.slow || crossProviderPoolPick(tier, config, sessionId) || strongestCredentialed(config) || config.defaultModel;
424
+ return config.defaultModel;
249
425
  }
250
426
 
251
427
  function resolveTierThinking(tier: PromptTier, config: RoutingConfig): ThinkLevel | undefined {
252
428
  return config.routing?.tiers?.[tier]?.thinking; // undefined = no override, caller keeps session/global level
253
429
  }
254
430
 
255
- const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "complex"];
431
+ const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "high", "complex"];
256
432
  function isPromptTier(value: unknown): value is PromptTier {
257
433
  return typeof value === "string" && (VALID_TIERS as readonly string[]).includes(value);
258
434
  }
@@ -266,8 +442,9 @@ function buildClassifierPrompt(prompt: string): string {
266
442
  return (
267
443
  `Classify the complexity of the following user request for a coding assistant into exactly one ` +
268
444
  `tier: "trivial" (a quick factual lookup, no repo work), "standard" (routine coding/editing work), ` +
269
- `or "complex" (multi-file, architectural, or root-cause debugging work). ` +
270
- `Respond with ONLY a JSON object of the shape {"tier":"trivial"|"standard"|"complex"}.\n\n` +
445
+ `"high" (a single moderately complex change — one non-trivial refactor/debug in one area, not yet ` +
446
+ `multi-file or architectural), or "complex" (multi-file, architectural, or root-cause debugging work). ` +
447
+ `Respond with ONLY a JSON object of the shape {"tier":"trivial"|"standard"|"high"|"complex"}.\n\n` +
271
448
  `Request:\n${capped}`
272
449
  );
273
450
  }
@@ -336,7 +513,7 @@ export async function routePrompt(
336
513
  }
337
514
  }
338
515
 
339
- const model = resolveTierModel(tier, config);
516
+ const model = resolveTierModel(tier, config, opts.sessionId);
340
517
  if (opts.hasImages && catalogMetadata(model)?.images === false) return null;
341
518
 
342
519
  const decision: RouteDecision = {