jeo-code 0.7.55 → 0.7.57

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,29 @@ 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.57] - 2026-07-07
10
+ _"동급모델간 크로스 프로바이더 라우팅이 동작하게 개선" — 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._
11
+
12
+ ### Added
13
+ - **`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.
14
+ - **`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.
15
+ - **`jeo doctor` routing preview** extended with an `"auto-selected: cross-provider pool"` source label when `crossProviderPool` resolved the shown model.
16
+
17
+ ### Verified
18
+ - `bun run typecheck` clean; full suite 2692/2692 pass across 280 files.
19
+ - 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.
20
+
21
+ ## [0.7.56] - 2026-07-07
22
+ _"서로다른 프로바이더로 급에 따라 나눠서 설정하도록 개선" — 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._
23
+
24
+ ### Added
25
+ - **Cross-provider tier auto-select** (`src/agent/prompt-router.ts`) — an unconfigured `trivial` tier now auto-selects the CHEAPEST model jeo has a stored credential for; an unconfigured `complex` tier auto-selects the most CAPABLE one (full/xhigh thinking support, then max output tokens, then context window). Both are computed LIVE off `MODEL_CATALOG`/`pricing.ts` on every call — a new/repriced/stronger catalog entry is picked up automatically on the next turn, with zero user config edit and no hand-maintained id table to go stale. `standard` is unchanged (always `defaultModel` unless explicitly configured — routine work stays on the user's deliberate default). Precedence unchanged and fully backward-compatible: explicit `routing.tiers.*.model` > legacy `roles.smol`/`roles.slow` > auto-select > `defaultModel`. Verified end-to-end against the real 4-provider production config: `trivial` → `gemini-2.0-flash` (gemini, cheapest), `complex` → `claude-fable-5` (anthropic, strongest available — matches current external SWE-Bench Pro agentic-benchmark leadership).
26
+ - **`jeo doctor` routing preview** (`--json`'s `routing.preview` / human output's "Routing preview" block) — surfaces what each tier ACTUALLY resolves to right now (model, provider, and source: `configured` / legacy role / `auto-selected: cheapest credentialed` / `auto-selected: strongest credentialed` / `defaultModel`), so the cross-provider split is visible without reading source. Purely informational, same as the existing routing notes — never affects `ready`/`--strict`.
27
+
28
+ ### Verified
29
+ - `bun run typecheck` clean; full suite 2684/2684 pass across 280 files (+9 new tests: 7 in `prompt-router.test.ts` covering cheapest/strongest selection, single-provider constraint, explicit-config precedence, legacy-role precedence, zero-credential fallback, and OAuth-credential recognition; 2 in `doctor.test.ts` proving the preview surfaces a real cross-provider split in both `--json` and human output).
30
+ - A genuine tie case was found and deliberately left unresolved rather than "fixed" with a regression: `gemini-2.5-flash` vs `gemini-2.5-pro` have identical catalogued thinking/output/context metadata, so the canonical-id tiebreak (ascending, chosen specifically to never prefer a limited-availability sibling like `claude-mythos-5` over the widely-available `claude-fable-5`) picks `flash`. A price-descending tiebreak was prototyped and rejected: it fixed the synthetic gemini-only tie but broke the real 4-provider production case by preferring `claude-opus-4-6` (uncatalogued price, falls to a generic-family price higher than `claude-fable-5`'s dedicated price) over `claude-fable-5` — the actual current agentic-benchmark leader per external verification. Documented as a known limitation rather than silently traded for a worse regression.
31
+
9
32
  ## [0.7.55] - 2026-07-07
10
33
  _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._
11
34
 
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.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
+ - **[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.
262
264
  - **[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.
263
265
  - **[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.
264
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.
265
- - **[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.
266
- - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
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.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.
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
267
  - **[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
- - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
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.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.
268
+ - **[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.
267
269
  - **[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.
268
270
  - **[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.
269
271
  - **[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.
270
- - **[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.
271
- - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
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.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
+ - **[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.
262
264
  - **[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.
263
265
  - **[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.
264
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.
265
- - **[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.
266
- - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
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.55",
3
+ "version": "0.7.57",
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":',
@@ -14,21 +14,50 @@
14
14
  * failures and falls back to a safe default, and the top-level export wraps
15
15
  * everything again as a final backstop.
16
16
  *
17
- * Why no static tier→model map: `resolveTierModel` computes inline off
17
+ * Why no STATIC tier→model map: `resolveTierModel` computes inline off
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
- * elsewhere) gets working routing with zero additional config, and an
21
- * unconfigured role falls through to `defaultModel` exactly like every other
22
- * role-tier consumer in this codebase (see `resolveRoleModel`).
20
+ * elsewhere) gets working routing with zero additional config. An unconfigured
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.
23
49
  */
24
50
  import { callLlm } from "./loop";
25
51
  import { resolveRoleModel } from "../ai/model-manager";
26
- import { catalogMetadata } from "../ai/model-catalog";
52
+ import { catalogMetadata, MODEL_CATALOG, type CatalogModel } from "../ai/model-catalog";
53
+ import { priceForModel } from "../ai/pricing";
27
54
  import { tryExtractJsonObject } from "./json";
28
55
  import type { Config } from "./state";
29
56
  import type { ThinkLevel } from "../ai/model-catalog";
57
+ import type { ProviderName } from "../ai/types";
58
+ import type { AuthProvider } from "../auth";
30
59
 
31
- export type PromptTier = "trivial" | "standard" | "complex";
60
+ export type PromptTier = "trivial" | "standard" | "high" | "complex";
32
61
 
33
62
  export interface HeuristicResult {
34
63
  tier: PromptTier;
@@ -50,6 +79,12 @@ export interface RouteDecision {
50
79
  export interface RoutePromptOptions {
51
80
  hasImages?: boolean;
52
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;
53
88
  }
54
89
 
55
90
  // --- Frozen, bilingual-validated classification signals (do not re-derive). ---
@@ -131,7 +166,10 @@ export function classifyPromptHeuristically(prompt: string): HeuristicResult {
131
166
  return { tier: "standard", confidence: 0.35, signals }; // conflicting signals -> let escalation decide
132
167
  }
133
168
  if (complexScore >= 2) return { tier: "complex", confidence: 0.85, signals };
134
- 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 };
135
173
  if (trivialScore >= 2) return { tier: "trivial", confidence: 0.85, signals };
136
174
  if (trivialScore === 1) return { tier: "trivial", confidence: 0.65, signals };
137
175
  return { tier: "standard", confidence: 0.9, signals }; // no signals at all -> common case, high confidence
@@ -156,21 +194,214 @@ export function resetPromptRouterWarnings(): void {
156
194
  warnedKeys.clear();
157
195
  }
158
196
 
197
+ /** `resolveTierModel`'s config requirement: `providers`/`oauth` are OPTIONAL (not
198
+ * just a `Pick`) so callers/tests that never touch credentials (e.g. this file's
199
+ * own tests, which only exercise heuristic/escalation logic) keep compiling
200
+ * unchanged — omitting the keys entirely still satisfies this type, and
201
+ * `config.providers?.[p]` / `config.oauth?.[p]` are safe on the resulting
202
+ * `undefined`. Production callers (`readGlobalConfig()`'s real `Config`) always
203
+ * provide both. */
204
+ export type RoutingConfig = Pick<Config, "defaultModel" | "roles" | "routing"> &
205
+ Partial<Pick<Config, "providers" | "oauth">>;
206
+
207
+ /** Cloud providers eligible for cross-provider auto-select (excludes local
208
+ * ollama/lmstudio, which are never a sensible "route to the cheapest/strongest"
209
+ * target — a local model's cost/capability isn't comparable to a hosted one). */
210
+ function isCloudProvider(p: ProviderName): p is AuthProvider {
211
+ return p !== "ollama" && p !== "lmstudio";
212
+ }
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
+ /** Multi-key strength comparator (ascending: weakest first) — same ranking signals
237
+ * as `strongestCredentialed`'s single-winner tiebreak, reused here to bucket
238
+ * UNCLASSIFIED (no size-suffix) models into a tercile fallback. */
239
+ function compareStrengthAscending(a: CatalogModel, b: CatalogModel): number {
240
+ const xhighA = a.thinking.includes("xhigh") ? 1 : 0;
241
+ const xhighB = b.thinking.includes("xhigh") ? 1 : 0;
242
+ if (xhighA !== xhighB) return xhighA - xhighB;
243
+ if (a.maxOutputTokens !== b.maxOutputTokens) return a.maxOutputTokens - b.maxOutputTokens;
244
+ if (a.contextTokens !== b.contextTokens) return a.contextTokens - b.contextTokens;
245
+ return a.canonical < b.canonical ? -1 : a.canonical > b.canonical ? 1 : 0;
246
+ }
247
+
248
+ /** Cross-provider EQUIVALENCE pool for `tier` — every credentialed cloud model
249
+ * jeo classifies as the same size class (trivial→small, standard→mid,
250
+ * complex→large), computed LIVE off `MODEL_CATALOG` so it stays correct as the
251
+ * catalog evolves. Models with no size-tier-suffix (gpt-5.5, o3, grok-4.3,
252
+ * kimi-*, glm-*, …) fall back to a strength-tercile bucket instead of being
253
+ * silently excluded from every pool. Returns `[]` when zero credentialed
254
+ * models qualify (caller falls back to `defaultModel`). Sorted by canonical id
255
+ * for deterministic pool ordering (required by `selectFromPool`'s index math). */
256
+ export function tierModelPool(tier: PromptTier, config: RoutingConfig): string[] {
257
+ const targetClass = TIER_TO_SIZE_CLASS[tier];
258
+ const credentialed = MODEL_CATALOG.filter(m => isCloudProvider(m.provider) && !m.limitedAvailability && (config.providers?.[m.provider] || config.oauth?.[m.provider]));
259
+
260
+ const suffixPool = credentialed.filter(m => sizeClassFor(m.canonical) === targetClass);
261
+
262
+ const unclassified = credentialed.filter(m => sizeClassFor(m.canonical) === null).sort(compareStrengthAscending);
263
+ const third = Math.ceil(unclassified.length / 3);
264
+ // "high" shares "standard"'s unclassified-model tercile share: the catalog's
265
+ // size-suffix classification only has 3 real buckets (small/mid/large — see
266
+ // sizeClassFor), so there is no distinct 4th tercile to carve out for "high".
267
+ // resolveTierModel's "high" branch does not currently rely on this tercile
268
+ // share (it prefers a deterministic strongest-mid-class pick — see
269
+ // strongestMidTierCredentialed below); kept here only so this `Record<PromptTier,…>`
270
+ // stays exhaustive for external callers of `tierModelPool("high", …)`.
271
+ const tercileByTier: Record<PromptTier, CatalogModel[]> = {
272
+ trivial: unclassified.slice(0, third),
273
+ standard: unclassified.slice(third, third * 2),
274
+ high: unclassified.slice(third, third * 2),
275
+ complex: unclassified.slice(third * 2),
276
+ };
277
+
278
+ const pool = [...suffixPool, ...tercileByTier[tier]];
279
+ return pool.map(m => m.canonical).sort();
280
+ }
281
+
282
+ /** Deterministic, SESSION-STABLE pick from `pool` — the same `sessionId` always
283
+ * resolves to the same index within one pool (so a session's provider-side
284
+ * prompt cache stays warm turn-to-turn, per this file's `deriveCacheSessionKey`
285
+ * cache-correlation contract), while DIFFERENT sessions spread across the pool's
286
+ * providers (the actual cross-provider distribution this feature exists for).
287
+ * No `sessionId` (e.g. a one-shot `jeo chat` call) deterministically picks index
288
+ * 0 (the pool is canonical-id-sorted, so this is still stable, just unvaried). */
289
+ export function selectFromPool(pool: readonly string[], sessionId: string | undefined): string {
290
+ if (pool.length === 0) throw new Error("selectFromPool: empty pool");
291
+ if (pool.length === 1 || !sessionId) return pool[0];
292
+ let h = 0x811c9dc5 | 0;
293
+ for (let i = 0; i < sessionId.length; i++) h = Math.imul(h ^ sessionId.charCodeAt(i), 0x01000193);
294
+ return pool[(h >>> 0) % pool.length];
295
+ }
296
+
297
+ /** Cheapest cloud model jeo has a credential for, computed LIVE off
298
+ * `MODEL_CATALOG`/`priceForModel` — picks up new/repriced catalog entries
299
+ * automatically, never a hand-maintained id. `null` when no credentialed
300
+ * model has a known price (caller falls back to `defaultModel`). Tiebreak:
301
+ * canonical id (deterministic across otherwise-equal candidates). */
302
+ export function cheapestCredentialed(config: RoutingConfig): string | null {
303
+ let best: CatalogModel | null = null;
304
+ let bestCost = Infinity;
305
+ for (const m of MODEL_CATALOG) {
306
+ // "ANY stored credential" (API key OR OAuth) is enough here — auto-select only
307
+ // needs "can this provider serve a request at all", not WHICH credential kind
308
+ // wins (unlike provider-status.ts's `configuredCredential` precedence). The
309
+ // real veto gate in launch.ts's `runTurn` re-verifies full readiness (including
310
+ // live OAuth expiry) before a turn actually uses the model.
311
+ if (!isCloudProvider(m.provider) || m.limitedAvailability || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
312
+ const price = priceForModel(m.canonical);
313
+ if (!price) continue;
314
+ const cost = price.inPerM + price.outPerM;
315
+ if (cost < bestCost || (cost === bestCost && best && m.canonical < best.canonical)) {
316
+ best = m;
317
+ bestCost = cost;
318
+ }
319
+ }
320
+ return best?.canonical ?? null;
321
+ }
322
+
323
+ /** Most capable cloud model jeo has a credential for, computed LIVE off
324
+ * `MODEL_CATALOG` — ranks by full (xhigh) thinking support first, then max
325
+ * output tokens, then context window, so a newly catalogued frontier model
326
+ * is picked up automatically without a hand-maintained id. `null` when no
327
+ * credentialed model qualifies (caller falls back to `defaultModel`).
328
+ * Tiebreak: canonical id (deterministic). Excludes `limitedAvailability`
329
+ * models (e.g. `claude-mythos-5`) — a provider credential doesn't imply
330
+ * access to a specific invite-only model; explicit `/model`/`routing.tiers`
331
+ * can still target one by id for approved accounts. */
332
+ export function strongestCredentialed(
333
+ config: RoutingConfig,
334
+ filter?: (m: CatalogModel) => boolean
335
+ ): string | null {
336
+ let best: CatalogModel | null = null;
337
+ for (const m of MODEL_CATALOG) {
338
+ // "ANY stored credential" is enough (see cheapestCredentialed's comment above).
339
+ if (!isCloudProvider(m.provider) || m.limitedAvailability || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
340
+ if (filter && !filter(m)) continue;
341
+ if (!best) { best = m; continue; }
342
+ const xhighM = m.thinking.includes("xhigh") ? 1 : 0;
343
+ const xhighBest = best.thinking.includes("xhigh") ? 1 : 0;
344
+ if (xhighM !== xhighBest) { if (xhighM > xhighBest) best = m; continue; }
345
+ if (m.maxOutputTokens !== best.maxOutputTokens) { if (m.maxOutputTokens > best.maxOutputTokens) best = m; continue; }
346
+ if (m.contextTokens !== best.contextTokens) { if (m.contextTokens > best.contextTokens) best = m; continue; }
347
+ if (m.canonical < best.canonical) best = m;
348
+ }
349
+ return best?.canonical ?? null;
350
+ }
351
+
352
+ /** Strongest credentialed model restricted to the SAME "mid" size class
353
+ * `tierModelPool` uses for `standard` (sonnet/pro-suffixed families) — the
354
+ * auto-select fallback for the `high` tier. Deliberately narrower than
355
+ * `strongestCredentialed`'s catalog-wide search: an unrestricted search would
356
+ * frequently tie with (or even prefer) `complex`'s own pick, since several
357
+ * "unclassified" frontier ids (gpt-5.5, grok-4.3, …) publish flagship-tier
358
+ * specs under a name with no size-suffix at all and would otherwise win over
359
+ * a genuinely mid-class model like `claude-sonnet-4-6`. `null` when no
360
+ * mid-class-suffixed model is credentialed (caller falls through to
361
+ * `roles.medium`/`defaultModel` — never to `complex`'s flagship pick). */
362
+ function strongestMidTierCredentialed(config: RoutingConfig): string | null {
363
+ const midClass = MODEL_CATALOG.filter(
364
+ m => isCloudProvider(m.provider) && !m.limitedAvailability && sizeClassFor(m.canonical) === "mid" && (config.providers?.[m.provider] || config.oauth?.[m.provider]),
365
+ );
366
+ if (midClass.length === 0) return null;
367
+ return midClass.sort(compareStrengthAscending).at(-1)!.canonical;
368
+ }
369
+
370
+ /** `routing.crossProviderPool` (opt-in, default off) fallback for ALL FOUR tiers:
371
+ * a SESSION-STABLE pick from `tierModelPool(tier, config)` instead of each
372
+ * tier's deterministic single-winner pick (`cheapestCredentialed`/
373
+ * `strongestMidTierCredentialed`/`strongestCredentialed`). Off (or an empty
374
+ * pool) returns `null` so the caller's next fallback applies unchanged — this
375
+ * is purely additive: a user who has not set the flag keeps v0.7.56's exact
376
+ * single-winner behavior; setting it distributes qualifying tiers across
377
+ * every credentialed provider's equivalent-class model instead of always
378
+ * resolving to the one deterministic winner. */
379
+ function crossProviderPoolPick(tier: PromptTier, config: RoutingConfig, sessionId: string | undefined): string | null {
380
+ if (!config.routing?.crossProviderPool) return null;
381
+ const pool = tierModelPool(tier, config);
382
+ return pool.length > 0 ? selectFromPool(pool, sessionId) : null;
383
+ }
384
+
159
385
  // --- Tier -> model/thinking resolution (inline off config.roles/config.routing.tiers; no static map). ---
160
386
 
161
- function resolveTierModel(tier: PromptTier, config: Pick<Config, "defaultModel" | "roles" | "routing">): string {
387
+ /** Exported for `jeo doctor`'s routing-preview diagnostic (same resolution the real
388
+ * `routePrompt` uses — no duplicated logic between "what WILL routing pick" and
389
+ * "what does doctor SHOW the user routing will pick"). */
390
+ export function resolveTierModel(tier: PromptTier, config: RoutingConfig, sessionId?: string): string {
162
391
  const configured = config.routing?.tiers?.[tier]?.model;
163
392
  if (configured) return configured;
164
- if (tier === "trivial") return config.roles?.smol || config.defaultModel;
165
- if (tier === "complex") return config.roles?.slow || config.defaultModel;
166
- return config.defaultModel; // "standard" always falls through to defaultModel unless explicitly configured
393
+ if (tier === "trivial") return config.roles?.smol || crossProviderPoolPick(tier, config, sessionId) || cheapestCredentialed(config) || config.defaultModel;
394
+ if (tier === "standard") return config.roles?.medium || config.roles?.high || crossProviderPoolPick(tier, config, sessionId) || config.defaultModel;
395
+ if (tier === "high") return config.roles?.high || config.roles?.medium || crossProviderPoolPick(tier, config, sessionId) || strongestMidTierCredentialed(config) || config.defaultModel;
396
+ if (tier === "complex") return config.roles?.xhigh || config.roles?.slow || crossProviderPoolPick(tier, config, sessionId) || strongestCredentialed(config) || config.defaultModel;
397
+ return config.defaultModel;
167
398
  }
168
399
 
169
- function resolveTierThinking(tier: PromptTier, config: Pick<Config, "defaultModel" | "roles" | "routing">): ThinkLevel | undefined {
400
+ function resolveTierThinking(tier: PromptTier, config: RoutingConfig): ThinkLevel | undefined {
170
401
  return config.routing?.tiers?.[tier]?.thinking; // undefined = no override, caller keeps session/global level
171
402
  }
172
403
 
173
- const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "complex"];
404
+ const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "high", "complex"];
174
405
  function isPromptTier(value: unknown): value is PromptTier {
175
406
  return typeof value === "string" && (VALID_TIERS as readonly string[]).includes(value);
176
407
  }
@@ -184,8 +415,9 @@ function buildClassifierPrompt(prompt: string): string {
184
415
  return (
185
416
  `Classify the complexity of the following user request for a coding assistant into exactly one ` +
186
417
  `tier: "trivial" (a quick factual lookup, no repo work), "standard" (routine coding/editing work), ` +
187
- `or "complex" (multi-file, architectural, or root-cause debugging work). ` +
188
- `Respond with ONLY a JSON object of the shape {"tier":"trivial"|"standard"|"complex"}.\n\n` +
418
+ `"high" (a single moderately complex change — one non-trivial refactor/debug in one area, not yet ` +
419
+ `multi-file or architectural), or "complex" (multi-file, architectural, or root-cause debugging work). ` +
420
+ `Respond with ONLY a JSON object of the shape {"tier":"trivial"|"standard"|"high"|"complex"}.\n\n` +
189
421
  `Request:\n${capped}`
190
422
  );
191
423
  }
@@ -224,7 +456,7 @@ const SMOL_UNCONFIGURED_WARNING_KEY = "prompt-router:smol-unconfigured";
224
456
  */
225
457
  export async function routePrompt(
226
458
  prompt: string,
227
- config: Pick<Config, "defaultModel" | "roles" | "routing">,
459
+ config: RoutingConfig,
228
460
  opts: RoutePromptOptions = {},
229
461
  ): Promise<RouteDecision | null> {
230
462
  try {
@@ -254,7 +486,7 @@ export async function routePrompt(
254
486
  }
255
487
  }
256
488
 
257
- const model = resolveTierModel(tier, config);
489
+ const model = resolveTierModel(tier, config, opts.sessionId);
258
490
  if (opts.hasImages && catalogMetadata(model)?.images === false) return null;
259
491
 
260
492
  const decision: RouteDecision = {
@@ -130,7 +130,14 @@ export interface Config {
130
130
  * Model role tiers (gjc `--smol`/`--slow`/`--plan` parity). Each falls back to
131
131
  * `defaultModel`. Env `JEO_SMOL_MODEL`/`JEO_SLOW_MODEL`/`JEO_PLAN_MODEL` fill gaps.
132
132
  */
133
- roles?: { smol?: string; slow?: string; plan?: string };
133
+ roles?: {
134
+ smol?: string;
135
+ medium?: string;
136
+ high?: string;
137
+ xhigh?: string;
138
+ slow?: string;
139
+ plan?: string;
140
+ };
134
141
  /** Prompt-content-based per-turn model routing (PromptRouter) — see prompt-router.ts.
135
142
  * Opt-in (undefined/false = off). Never affects subagent/role-tier model resolution. */
136
143
  routing?: {
@@ -139,8 +146,19 @@ export interface Config {
139
146
  tiers?: {
140
147
  trivial?: { model?: string; thinking?: "low" | "medium" | "high" | "xhigh" };
141
148
  standard?: { model?: string; thinking?: "low" | "medium" | "high" | "xhigh" };
149
+ /** A single borderline-complex signal (see prompt-router.ts's `classifyPromptHeuristically`)
150
+ * — routes to `roles.high` by default instead of jumping straight to `complex`/xhigh. */
151
+ high?: { model?: string; thinking?: "low" | "medium" | "high" | "xhigh" };
142
152
  complex?: { model?: string; thinking?: "low" | "medium" | "high" | "xhigh" };
143
153
  };
154
+ /** Opt-in (default off): for `standard`/`high` tiers left unconfigured (no
155
+ * `routing.tiers.*.model`, no `roles.medium`/`roles.high`), pick a SESSION-STABLE
156
+ * model from the cross-provider equivalence pool (`tierModelPool`/`selectFromPool`)
157
+ * instead of falling straight to `defaultModel` — spreads different sessions across
158
+ * every credentialed provider's comparable-tier model so jeo actually captures each
159
+ * provider's respective strengths on comparably-priced work, rather than every
160
+ * session converging on one provider. */
161
+ crossProviderPool?: boolean;
144
162
  };
145
163
  gitAutoCommit?: boolean;
146
164
  hooks?: HookConfig;
@@ -284,6 +302,9 @@ function withEnvOverlay(cfg: Config): Config {
284
302
  lmstudioBaseUrl: cfg.lmstudioBaseUrl || process.env.LMSTUDIO_BASE_URL || "http://localhost:1234/v1",
285
303
  roles: {
286
304
  smol: cfg.roles?.smol || jeoEnv("SMOL_MODEL"),
305
+ medium: cfg.roles?.medium || jeoEnv("MEDIUM_MODEL"),
306
+ high: cfg.roles?.high || jeoEnv("HIGH_MODEL"),
307
+ xhigh: cfg.roles?.xhigh || jeoEnv("XHIGH_MODEL"),
287
308
  slow: cfg.roles?.slow || jeoEnv("SLOW_MODEL"),
288
309
  plan: cfg.roles?.plan || jeoEnv("PLAN_MODEL"),
289
310
  },
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { DEFAULT_TOOLS, TOOL_PROTOCOL, READONLY_TOOL_PROTOCOL, WORKING_DISCIPLINE, type ToolHandler } from "./engine";
13
13
  import type { Config } from "./state";
14
+ import type { CatalogModel } from "../ai/model-catalog";
14
15
  import architectPrompt from "../prompts/agents/architect.md" with { type: "text" };
15
16
  import criticPrompt from "../prompts/agents/critic.md" with { type: "text" };
16
17
  import executorPrompt from "../prompts/agents/executor.md" with { type: "text" };
@@ -158,9 +159,42 @@ export function defaultSubagentRole(): SubagentRole {
158
159
  export type SubagentConfig = NonNullable<Config["subagents"]>;
159
160
 
160
161
  /** Per-role model override → falls back to the global default model. */
161
- export function resolveSubagentModel(roleId: string, config: Pick<Config, "defaultModel" | "subagents">): string {
162
- const entry = config.subagents?.[normalizeRoleId(roleId)];
163
- return entry?.model || config.defaultModel;
162
+ export function resolveSubagentModel(
163
+ roleId: string,
164
+ config: {
165
+ defaultModel: string;
166
+ subagents?: Config["subagents"];
167
+ roles?: Config["roles"];
168
+ providers?: Config["providers"];
169
+ oauth?: Config["oauth"];
170
+ },
171
+ ): string {
172
+ const normalized = normalizeRoleId(roleId);
173
+ const entry = config.subagents?.[normalized];
174
+ if (entry?.model) return entry.model;
175
+
176
+ // Exception: load strongestCredentialed dynamically using require to prevent eager loading and circular dependency issues that break test mocking.
177
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
178
+ const { strongestCredentialed } = require("./prompt-router");
179
+ if (normalized === "executor") {
180
+ return config.roles?.xhigh || config.roles?.slow || strongestCredentialed(config) || config.defaultModel;
181
+ }
182
+ if (normalized === "architect") {
183
+ return config.roles?.xhigh || strongestCredentialed(config, (m: CatalogModel) => m.thinking.includes("xhigh") || m.thinking.includes("high")) || config.defaultModel;
184
+ }
185
+ if (normalized === "planner") {
186
+ return config.roles?.high || strongestCredentialed(config) || config.defaultModel;
187
+ }
188
+ if (normalized === "critic") {
189
+ return (
190
+ config.roles?.medium ||
191
+ config.roles?.smol ||
192
+ strongestCredentialed(config, (m: CatalogModel) => m.canonical === "o3-mini") ||
193
+ strongestCredentialed(config, (m: CatalogModel) => m.canonical === "gemini-2.5-flash") ||
194
+ config.defaultModel
195
+ );
196
+ }
197
+ return config.defaultModel;
164
198
  }
165
199
 
166
200
  /** Per-role step budget → config override, else the role default, else 15. */
@@ -1130,3 +1130,172 @@ export async function deleteTool(
1130
1130
  return { success: false, output: "", error: err.message };
1131
1131
  }
1132
1132
  }
1133
+
1134
+
1135
+ /**
1136
+ * Binary-search git history to find the commit that introduced a regression.
1137
+ * Uses `git bisect run <predicate>` to automate the search.
1138
+ * Exit codes: 0=good, non-zero=bad, 125=skip.
1139
+ */
1140
+ export async function bisectTool(
1141
+ good: string,
1142
+ bad: string = "HEAD",
1143
+ run: string = "",
1144
+ invert: boolean = false,
1145
+ maxSteps: number = 40,
1146
+ stepTimeoutMs: number = 600000,
1147
+ cwd: string = process.cwd()
1148
+ ): Promise<ToolResult> {
1149
+ try {
1150
+ if (!good || !good.trim()) {
1151
+ return { success: false, output: "", error: 'bisect requires "good" (a known-good commit)' };
1152
+ }
1153
+ if (!run || !run.trim()) {
1154
+ return { success: false, output: "", error: 'bisect requires "run" (a shell command to test)' };
1155
+ }
1156
+ if (maxSteps <= 0) {
1157
+ return { success: false, output: "", error: 'maxSteps must be > 0' };
1158
+ }
1159
+ if (stepTimeoutMs <= 0) {
1160
+ return { success: false, output: "", error: 'stepTimeoutMs must be > 0' };
1161
+ }
1162
+
1163
+ // Helper to run a command with timeout support and resilient error handling
1164
+ const runCmd = async (args: string[], timeoutMs?: number): Promise<{ code: number; out: string; err: string }> => {
1165
+ const timeout = timeoutMs || stepTimeoutMs;
1166
+ let timeoutOccurred = false;
1167
+ const timeoutHandle = setTimeout(() => {
1168
+ timeoutOccurred = true;
1169
+ }, timeout);
1170
+
1171
+ try {
1172
+ const proc = Bun.spawn(args, {
1173
+ cwd,
1174
+ stdout: "pipe",
1175
+ stderr: "pipe",
1176
+ });
1177
+
1178
+ let out = "";
1179
+ let err = "";
1180
+
1181
+ const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
1182
+ const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
1183
+
1184
+ // Read stdout with resilience: stream errors don't fail the entire call.
1185
+ // If a reader encounters an error, we continue with partial output, as
1186
+ // git bisect can still function with partial stdout/stderr.
1187
+ try {
1188
+ while (!timeoutOccurred) {
1189
+ const { done, value } = await stdoutReader.read();
1190
+ if (done) break;
1191
+ out += new TextDecoder().decode(value);
1192
+ }
1193
+ } catch (e) {
1194
+ // Stream read error; continue with partial output.
1195
+ // This is acceptable because git bisect may still be usable.
1196
+ }
1197
+
1198
+ // Read stderr with resilience: same strategy as stdout.
1199
+ try {
1200
+ while (!timeoutOccurred) {
1201
+ const { done, value } = await stderrReader.read();
1202
+ if (done) break;
1203
+ err += new TextDecoder().decode(value);
1204
+ }
1205
+ } catch (e) {
1206
+ // Stream read error; continue with partial output.
1207
+ }
1208
+
1209
+ if (timeoutOccurred) {
1210
+ return { code: 124, out, err: `Command timeout after ${timeout}ms` };
1211
+ }
1212
+
1213
+ const code = await proc.exited;
1214
+ return { code: typeof code === 'number' ? code : 0, out, err };
1215
+ } finally {
1216
+ clearTimeout(timeoutHandle);
1217
+ }
1218
+ };
1219
+
1220
+ // Validate commits exist (quick validation with shorter timeout)
1221
+ const goodRes = await runCmd(["git", "rev-parse", good], 10000);
1222
+ const badRes = await runCmd(["git", "rev-parse", bad], 10000);
1223
+
1224
+ if (goodRes.code !== 0) {
1225
+ return { success: false, output: "", error: `Invalid or non-existent commit: ${good}` };
1226
+ }
1227
+ if (badRes.code !== 0) {
1228
+ return { success: false, output: "", error: `Invalid or non-existent commit: ${bad}` };
1229
+ }
1230
+
1231
+ const goodSha = goodRes.out.trim();
1232
+ const badSha = badRes.out.trim();
1233
+
1234
+ // Start bisect session
1235
+ const initRes = await runCmd(["sh", "-c", `git bisect start "${badSha}" "${goodSha}"`], 10000);
1236
+ if (initRes.code !== 0) {
1237
+ return { success: false, output: "", error: `Failed to start bisect: ${initRes.err || initRes.out}` };
1238
+ }
1239
+
1240
+ // Run bisect with the predicate command.
1241
+ // Shell escaping: single quotes in sh -c context need special care.
1242
+ // 'text' -> '\''text'\'' (end quote, escaped quote, start quote, content, end quote, escaped quote, start quote)
1243
+ const escapedRun = run.replace(/'/g, "'\''");
1244
+ const runCmd2 = invert
1245
+ ? `git bisect run sh -c '${escapedRun} && exit 1 || exit 0'`
1246
+ : `git bisect run sh -c '${escapedRun}'`;
1247
+
1248
+ const runRes = await runCmd(["sh", "-c", runCmd2]);
1249
+
1250
+ // Parse result: look for "XXXX is the first bad/good commit"
1251
+ const badCommitMatch = runRes.out.match(/^([0-9a-f]{7,40}) is the first (bad|good) commit$/m);
1252
+ let culprit: string | null = null;
1253
+ let concluded = false;
1254
+
1255
+ if (badCommitMatch) {
1256
+ culprit = badCommitMatch[1];
1257
+ concluded = true;
1258
+ } else if (runRes.out.includes("only 'skip'ped commits left")) {
1259
+ concluded = false;
1260
+ }
1261
+
1262
+ // Cleanup: always attempt to reset bisect and working tree (best-effort).
1263
+ // Errors here are non-critical; if reset fails, the user can manually run
1264
+ // 'git bisect reset' to recover. We don't throw on cleanup errors.
1265
+ try {
1266
+ await runCmd(["git", "bisect", "reset"], 10000);
1267
+ } catch {}
1268
+ try {
1269
+ await runCmd(["git", "reset", "--hard"], 10000);
1270
+ } catch {}
1271
+
1272
+ if (!concluded) {
1273
+ return {
1274
+ success: false,
1275
+ output: `Bisect did not converge. Output:\n${runRes.out}`,
1276
+ error: "",
1277
+ };
1278
+ }
1279
+
1280
+ if (culprit) {
1281
+ // Get commit details (use shorter timeout for metadata lookup)
1282
+ const detailsRes = await runCmd(["git", "show", "-s", "--format=%aN|%ai|%s", culprit], 10000);
1283
+ const [author, date, subject] = detailsRes.out.trim().split("|");
1284
+ const message = [
1285
+ invert ? `First fixing commit: ${culprit}` : `First bad commit: ${culprit}`,
1286
+ author ? `Author: ${author}` : "",
1287
+ date ? `Date: ${date}` : "",
1288
+ subject ? `Subject: ${subject}` : "",
1289
+ ]
1290
+ .filter(Boolean)
1291
+ .join("\n");
1292
+
1293
+ return { success: true, output: message };
1294
+ }
1295
+
1296
+ return { success: false, output: "Bisect completed but found no culprit.", error: "" };
1297
+ } catch (err: any) {
1298
+ return { success: false, output: "", error: err.message || String(err) };
1299
+ }
1300
+ }
1301
+
@@ -29,6 +29,13 @@ export interface CatalogModel {
29
29
  images: boolean;
30
30
  /** Optional company override. */
31
31
  company?: string;
32
+ /** True for models requiring separate approval/waitlist beyond the provider's
33
+ * base credential (e.g. Anthropic's invite-only Project Glasswing models) — a
34
+ * user with a valid API key/OAuth for the PROVIDER may still lack access to
35
+ * this SPECIFIC model. Auto-select (prompt-router.ts's pool/single-winner
36
+ * selection) excludes these; explicit `/model` or `routing.tiers.*.model`
37
+ * can still target them by id for approved accounts. */
38
+ limitedAvailability?: boolean;
32
39
  }
33
40
 
34
41
  const FULL: ThinkLevel[] = ["low", "medium", "high", "xhigh"];
@@ -73,7 +80,7 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
73
80
  // Fable 5 = Anthropic's most capable widely-released model (adaptive thinking always on);
74
81
  // Mythos 5 is limited-availability (Project Glasswing) but callable by id for approved accounts.
75
82
  { canonical: "claude-fable-5", provider: "anthropic", providerModel: "claude-fable-5", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true },
76
- { canonical: "claude-mythos-5", provider: "anthropic", providerModel: "claude-mythos-5", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true },
83
+ { canonical: "claude-mythos-5", provider: "anthropic", providerModel: "claude-mythos-5", contextTokens: 1_000_000, maxOutputTokens: 128_000, thinking: FULL, images: true, limitedAvailability: true },
77
84
  // OpenAI
78
85
  { canonical: "gpt-4o", provider: "openai", providerModel: "gpt-4o", contextTokens: 128_000, maxOutputTokens: 16_384, thinking: [], images: true },
79
86
  { canonical: "gpt-4o-mini", provider: "openai", providerModel: "gpt-4o-mini", contextTokens: 128_000, maxOutputTokens: 16_384, thinking: [], images: true },
@@ -60,7 +60,11 @@ const ANTIGRAVITY_MODEL_DENYLIST = new Set([
60
60
 
61
61
  function anthropicHeaders(cred: Credential): Record<string, string> {
62
62
  if (cred.kind === "oauth") {
63
- return { authorization: `Bearer ${cred.token}`, "anthropic-version": "2023-06-01", "anthropic-beta": "oauth-2025-04-20" };
63
+ // NOTE: The oauth-2025-04-20 beta is for the messages endpoint, not the models endpoint.
64
+ // The models endpoint does not support this beta, so we omit it here to avoid
65
+ // potential 400/403 errors. The fallback to catalog ensures Anthropic models
66
+ // are always available even if the live endpoint fails.
67
+ return { authorization: `Bearer ${cred.token}`, "anthropic-version": "2023-06-01" };
64
68
  }
65
69
  if (cred.kind === "api_key") {
66
70
  return { "x-api-key": cred.token, "anthropic-version": "2023-06-01" };
@@ -146,10 +146,23 @@ export async function describeModel(
146
146
  return { input, resolved, provider: resolveProvider(resolved) };
147
147
  }
148
148
 
149
- export type ModelRole = "smol" | "slow" | "plan";
150
-
151
- /** Resolve a model role tier (smol/slow/plan) → configured tier model, else defaultModel. */
152
- export function resolveRoleModel(role: ModelRole, config: { defaultModel: string; roles?: { smol?: string; slow?: string; plan?: string } }): string {
149
+ export type ModelRole = "smol" | "medium" | "high" | "xhigh" | "slow" | "plan";
150
+
151
+ /** Resolve a model role tier (smol/medium/high/xhigh/slow/plan) → configured tier model, else defaultModel. */
152
+ export function resolveRoleModel(
153
+ role: ModelRole,
154
+ config: {
155
+ defaultModel: string;
156
+ roles?: {
157
+ smol?: string;
158
+ medium?: string;
159
+ high?: string;
160
+ xhigh?: string;
161
+ slow?: string;
162
+ plan?: string;
163
+ };
164
+ },
165
+ ): string {
153
166
  return config.roles?.[role] || config.defaultModel;
154
167
  }
155
168
 
@@ -58,13 +58,15 @@ h1.ok{color:#22d3ee}h1.fail{color:#f472b6}
58
58
  p{margin:.25rem 0;color:#8b8b96;font-size:.92rem}
59
59
  button{margin-top:1.5rem;padding:.55rem 1.6rem;border-radius:999px;border:1px solid #2a2a35;background:transparent;color:#e7e7ec;font:inherit;font-size:.88rem;cursor:pointer}
60
60
  button:hover{background:#1b1c27}
61
- .hint{margin-top:.9rem;font-size:.78rem;color:#5f5f6b}</style></head>
61
+ .hint{margin-top:.9rem;font-size:.78rem;color:#5f5f6b}
62
+ .closed{display:none}</style></head>
62
63
  <body><div class="card">
63
64
  <img class="wordmark" alt="jeo" src="${JEO_WORDMARK_DATA_URI}">
64
65
  <h1 class="__STATUS__">__TITLE__</h1>
65
66
  <p>__MSG__</p>
66
67
  <button id="jeo-close" type="button">Close</button>
67
68
  <p class="hint">Closing automatically in <span id="jeo-countdown">__SECONDS__</span>s…</p>
69
+ <p class="hint closed" id="jeo-closed-msg">Window closed. You can close this tab.</p>
68
70
  </div>
69
71
  <script>__SCRIPT__</script>
70
72
  </body></html>`;
@@ -78,8 +80,16 @@ const AUTO_CLOSE_SECONDS = 5;
78
80
 
79
81
  function renderHtml(ok: boolean, msg: string): string {
80
82
  const script = `(function(){
81
- var n=${AUTO_CLOSE_SECONDS},el=document.getElementById("jeo-countdown"),btn=document.getElementById("jeo-close"),t;
82
- function closeNow(){clearInterval(t);window.close();}
83
+ var n=${AUTO_CLOSE_SECONDS},el=document.getElementById("jeo-countdown"),btn=document.getElementById("jeo-close"),closedMsg=document.getElementById("jeo-closed-msg"),t;
84
+ function closeNow(){
85
+ clearInterval(t);
86
+ var closed=window.close();
87
+ if(!closed){
88
+ btn.style.display="none";
89
+ el.parentElement.style.display="none";
90
+ closedMsg.classList.remove("closed");
91
+ }
92
+ }
83
93
  t=setInterval(function(){n-=1;if(el)el.textContent=String(Math.max(n,0));if(n<=0)closeNow();},1000);
84
94
  if(btn)btn.addEventListener("click",closeNow);
85
95
  })();`;
@@ -8,6 +8,7 @@ import { meter } from "../tui/components/meter";
8
8
  import { size } from "../tui/terminal";
9
9
  import chalk from "chalk";
10
10
  import { extractChatgptAccountId, CODEX_RESPONSES_URL } from "../ai/providers/openai-responses";
11
+ import { resolveTierModel, tierModelPool } from "../agent/prompt-router";
11
12
 
12
13
  interface ProbeResult {
13
14
  status: "ok" | "fail" | "skipped";
@@ -196,8 +197,9 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
196
197
  if (routingEnabled) {
197
198
  const tierCandidates: { tier: string; model: string | undefined }[] = [
198
199
  { tier: "trivial", model: config.routing?.tiers?.trivial?.model || config.roles?.smol },
199
- { tier: "standard", model: config.routing?.tiers?.standard?.model },
200
- { tier: "complex", model: config.routing?.tiers?.complex?.model || config.roles?.slow },
200
+ { tier: "standard", model: config.routing?.tiers?.standard?.model || config.roles?.medium || config.roles?.high },
201
+ { tier: "high", model: config.routing?.tiers?.high?.model || config.roles?.high || config.roles?.medium },
202
+ { tier: "complex", model: config.routing?.tiers?.complex?.model || config.roles?.xhigh || config.roles?.slow },
201
203
  ];
202
204
  for (const { tier, model } of tierCandidates) {
203
205
  if (!model) continue;
@@ -211,6 +213,48 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
211
213
  }
212
214
  }
213
215
 
216
+ // Routing PREVIEW (long-term visibility for cross-provider auto-select, v0.7.56):
217
+ // for each tier, show what resolveTierModel() ACTUALLY resolves to right now —
218
+ // explicitly configured, legacy role-tier, auto-selected (cheapest/strongest
219
+ // credentialed, computed live off MODEL_CATALOG), or the defaultModel fallback
220
+ // (no credentialed alternative, or "standard" which never auto-selects). Purely
221
+ // informational — never affects ready/--strict, same contract as routingNotes.
222
+ const routingPreview: { tier: string; model: string; provider: string; source: string }[] = [];
223
+ if (routingEnabled) {
224
+ // Legacy role-tier fallback per tier, mirroring resolveTierModel's OWN precedence
225
+ // (not just the id it happens to already show up under `routing.tiers.*.model`) —
226
+ // so e.g. a `roles.medium`-only config correctly shows "configured", not
227
+ // "auto-selected", for `standard`.
228
+
229
+ for (const tier of ["trivial", "standard", "high", "complex"] as const) {
230
+ const legacyRole =
231
+ tier === "trivial" ? config.roles?.smol
232
+ : tier === "standard" ? config.roles?.medium || config.roles?.high
233
+ : tier === "high" ? config.roles?.high || config.roles?.medium
234
+ : config.roles?.xhigh || config.roles?.slow;
235
+ const explicit = config.routing?.tiers?.[tier]?.model || legacyRole;
236
+ const model = resolveTierModel(tier, config);
237
+ const provider = resolveProvider(model);
238
+ // crossProviderPoolPick (prompt-router.ts) is tried BEFORE every tier's own
239
+ // cheapest/strongest/mid-tier fallback, so a non-empty pool always wins over
240
+ // those once the flag is on — `.length > 0` alone is enough to know the pool,
241
+ // not the tier-specific fallback, produced `model`.
242
+ const pooled = !explicit && model !== config.defaultModel && !!config.routing?.crossProviderPool && tierModelPool(tier, config).length > 0;
243
+ const source = explicit
244
+ ? "configured"
245
+ : model === config.defaultModel
246
+ ? "defaultModel"
247
+ : pooled
248
+ ? "auto-selected: cross-provider pool"
249
+ : tier === "trivial"
250
+ ? "auto-selected: cheapest credentialed"
251
+ : tier === "high"
252
+ ? "auto-selected: strongest mid-tier credentialed"
253
+ : "auto-selected: strongest credentialed";
254
+ routingPreview.push({ tier, model, provider, source });
255
+ }
256
+ }
257
+
214
258
 
215
259
  // --- Gather (probes run concurrently → ~1× the slowest timeout, not N×) ---
216
260
  const probes: { name: string; credKind: string; result: ProbeResult }[] = [];
@@ -277,7 +321,7 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
277
321
  })),
278
322
  oauth: oauthHealth,
279
323
  ready,
280
- ...(routingEnabled ? { routing: { enabled: true, smolConfigured, ...(routingNotes.length ? { notes: routingNotes } : {}) } } : {}),
324
+ ...(routingEnabled ? { routing: { enabled: true, smolConfigured, preview: routingPreview, ...(routingNotes.length ? { notes: routingNotes } : {}) } } : {}),
281
325
 
282
326
  };
283
327
  console.log(JSON.stringify(report, null, 2));
@@ -327,6 +371,10 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
327
371
  } else {
328
372
  console.log(`${chalk.green("[routing]")} enabled, roles.smol configured — LLM escalation available on ambiguous prompts.`);
329
373
  }
374
+ console.log("Routing preview (what each tier resolves to right now):");
375
+ for (const p of routingPreview) {
376
+ console.log(` ${p.tier.padEnd(9)} → ${p.model} (${p.provider}) ${chalk.dim(`[${p.source}]`)}`);
377
+ }
330
378
 
331
379
  console.log("");
332
380
  }
@@ -885,7 +885,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
885
885
  // selection must stay session-local: running sessions never influence each other.
886
886
  const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
887
887
  let routed = routingEnabled && !sessionModel
888
- ? await routePrompt(userInput, turnConfig, { hasImages: !!images?.length }).catch(() => null)
888
+ ? await routePrompt(userInput, turnConfig, { hasImages: !!images?.length, sessionId }).catch(() => null)
889
889
  : null;
890
890
  // Fail-open credential gate: `routePrompt` only resolves a tier's model from
891
891
  // config (routing.tiers/roles.smol/roles.slow/defaultModel) — it deliberately
package/src/tui/app.ts CHANGED
@@ -73,7 +73,7 @@ export interface LaunchTuiOptions {
73
73
  * gate fell back). Renders a persistent `⚡tier` marker in the status bar so the
74
74
  * routed model stays visible without depending on the transient `[route]`
75
75
  * console notice. See `StatusBarData.routedTier` in components/status.ts. */
76
- routedTier?: "trivial" | "standard" | "complex";
76
+ routedTier?: "trivial" | "standard" | "high" | "complex";
77
77
  }
78
78
 
79
79
  export interface AgentEventsLike {
@@ -375,7 +375,7 @@ export class LaunchTui {
375
375
  private readonly thinkingLevel?: string;
376
376
  // PromptRouter's resolved tier when routing chose this turn's model; undefined when
377
377
  // routing didn't engage. Renders the persistent `⚡tier` marker in the status bar.
378
- private readonly routedTier?: "trivial" | "standard" | "complex";
378
+ private readonly routedTier?: "trivial" | "standard" | "high" | "complex";
379
379
 
380
380
  constructor(opts: LaunchTuiOptions) {
381
381
  // Backpressure-aware wrapper around the underlying writer (real stdout, or the
@@ -24,7 +24,7 @@ export interface StatusBarData {
24
24
  * marker so the routed model stays visible without depending on the transient
25
25
  * `[route] …` console notice, which intentionally stays silent for routine
26
26
  * unescalated `standard`-tier turns. */
27
- routedTier?: "trivial" | "standard" | "complex";
27
+ routedTier?: "trivial" | "standard" | "high" | "complex";
28
28
  branch?: string;
29
29
  /** Uncommitted-change count for the `?N` dirty flag; omit/0 = clean. */
30
30
  dirtyCount?: number;