jeo-code 0.7.56 → 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,18 @@ 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
+
9
21
  ## [0.7.56] - 2026-07-07
10
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._
11
23
 
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.
262
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.
263
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.
264
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.
265
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.
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.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
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.
264
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.
265
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.
266
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.
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.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
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.
268
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.
269
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.
270
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.
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.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
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.
263
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.
264
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.
265
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.
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.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":',
@@ -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,95 @@ 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
+ /** 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
+
188
297
  /** Cheapest cloud model jeo has a credential for, computed LIVE off
189
298
  * `MODEL_CATALOG`/`priceForModel` — picks up new/repriced catalog entries
190
299
  * automatically, never a hand-maintained id. `null` when no credentialed
191
300
  * model has a known price (caller falls back to `defaultModel`). Tiebreak:
192
301
  * canonical id (deterministic across otherwise-equal candidates). */
193
- function cheapestCredentialed(config: RoutingConfig): string | null {
302
+ export function cheapestCredentialed(config: RoutingConfig): string | null {
194
303
  let best: CatalogModel | null = null;
195
304
  let bestCost = Infinity;
196
305
  for (const m of MODEL_CATALOG) {
@@ -199,7 +308,7 @@ function cheapestCredentialed(config: RoutingConfig): string | null {
199
308
  // wins (unlike provider-status.ts's `configuredCredential` precedence). The
200
309
  // real veto gate in launch.ts's `runTurn` re-verifies full readiness (including
201
310
  // live OAuth expiry) before a turn actually uses the model.
202
- if (!isCloudProvider(m.provider) || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
311
+ if (!isCloudProvider(m.provider) || m.limitedAvailability || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
203
312
  const price = priceForModel(m.canonical);
204
313
  if (!price) continue;
205
314
  const cost = price.inPerM + price.outPerM;
@@ -216,14 +325,19 @@ function cheapestCredentialed(config: RoutingConfig): string | null {
216
325
  * output tokens, then context window, so a newly catalogued frontier model
217
326
  * is picked up automatically without a hand-maintained id. `null` when no
218
327
  * 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 {
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 {
223
336
  let best: CatalogModel | null = null;
224
337
  for (const m of MODEL_CATALOG) {
225
338
  // "ANY stored credential" is enough (see cheapestCredentialed's comment above).
226
- if (!isCloudProvider(m.provider) || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
339
+ if (!isCloudProvider(m.provider) || m.limitedAvailability || !(config.providers?.[m.provider] || config.oauth?.[m.provider])) continue;
340
+ if (filter && !filter(m)) continue;
227
341
  if (!best) { best = m; continue; }
228
342
  const xhighM = m.thinking.includes("xhigh") ? 1 : 0;
229
343
  const xhighBest = best.thinking.includes("xhigh") ? 1 : 0;
@@ -235,24 +349,59 @@ function strongestCredentialed(config: RoutingConfig): string | null {
235
349
  return best?.canonical ?? null;
236
350
  }
237
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
+
238
385
  // --- Tier -> model/thinking resolution (inline off config.roles/config.routing.tiers; no static map). ---
239
386
 
240
387
  /** Exported for `jeo doctor`'s routing-preview diagnostic (same resolution the real
241
388
  * `routePrompt` uses — no duplicated logic between "what WILL routing pick" and
242
389
  * "what does doctor SHOW the user routing will pick"). */
243
- export function resolveTierModel(tier: PromptTier, config: RoutingConfig): string {
390
+ export function resolveTierModel(tier: PromptTier, config: RoutingConfig, sessionId?: string): string {
244
391
  const configured = config.routing?.tiers?.[tier]?.model;
245
392
  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
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;
249
398
  }
250
399
 
251
400
  function resolveTierThinking(tier: PromptTier, config: RoutingConfig): ThinkLevel | undefined {
252
401
  return config.routing?.tiers?.[tier]?.thinking; // undefined = no override, caller keeps session/global level
253
402
  }
254
403
 
255
- const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "complex"];
404
+ const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "high", "complex"];
256
405
  function isPromptTier(value: unknown): value is PromptTier {
257
406
  return typeof value === "string" && (VALID_TIERS as readonly string[]).includes(value);
258
407
  }
@@ -266,8 +415,9 @@ function buildClassifierPrompt(prompt: string): string {
266
415
  return (
267
416
  `Classify the complexity of the following user request for a coding assistant into exactly one ` +
268
417
  `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` +
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` +
271
421
  `Request:\n${capped}`
272
422
  );
273
423
  }
@@ -336,7 +486,7 @@ export async function routePrompt(
336
486
  }
337
487
  }
338
488
 
339
- const model = resolveTierModel(tier, config);
489
+ const model = resolveTierModel(tier, config, opts.sessionId);
340
490
  if (opts.hasImages && catalogMetadata(model)?.images === false) return null;
341
491
 
342
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,7 +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 } from "../agent/prompt-router";
11
+ import { resolveTierModel, tierModelPool } from "../agent/prompt-router";
12
12
 
13
13
  interface ProbeResult {
14
14
  status: "ok" | "fail" | "skipped";
@@ -197,8 +197,9 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
197
197
  if (routingEnabled) {
198
198
  const tierCandidates: { tier: string; model: string | undefined }[] = [
199
199
  { tier: "trivial", model: config.routing?.tiers?.trivial?.model || config.roles?.smol },
200
- { tier: "standard", model: config.routing?.tiers?.standard?.model },
201
- { 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 },
202
203
  ];
203
204
  for (const { tier, model } of tierCandidates) {
204
205
  if (!model) continue;
@@ -220,11 +221,36 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
220
221
  // informational — never affects ready/--strict, same contract as routingNotes.
221
222
  const routingPreview: { tier: string; model: string; provider: string; source: string }[] = [];
222
223
  if (routingEnabled) {
223
- for (const tier of ["trivial", "standard", "complex"] as const) {
224
- const explicit = config.routing?.tiers?.[tier]?.model || (tier === "trivial" ? config.roles?.smol : tier === "complex" ? config.roles?.slow : undefined);
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;
225
236
  const model = resolveTierModel(tier, config);
226
237
  const provider = resolveProvider(model);
227
- const source = explicit ? "configured" : model === config.defaultModel ? "defaultModel" : tier === "trivial" ? "auto-selected: cheapest credentialed" : "auto-selected: strongest credentialed";
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";
228
254
  routingPreview.push({ tier, model, provider, source });
229
255
  }
230
256
  }
@@ -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;