jeo-code 0.8.13 → 0.8.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.8.15] - 2026-07-09
10
+ _"api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed._
11
+
12
+ ### Fixed
13
+ - **API-authenticated live model catalogs now feed routing** (`src/ai/model-catalog.ts`, `src/ai/model-discovery.ts`, `src/agent/prompt-router.ts`) — successful provider `/models` responses are recorded in a session-lifetime live-provider supplement separate from the OpenAI OAuth Codex allow-list. API-key OpenAI and OpenAI-compatible clouds (Groq, OpenRouter, custom base URLs, etc.) can now contribute live model rows to `tierModelPool()`, `cheapestCredentialed()`, and `strongestCredentialed()` without waiting for a static release.
14
+ - **Custom OpenAI base URLs no longer auto-select unrelated public OpenAI rows** (`src/agent/prompt-router.ts`) — when `openaiBaseUrl` is configured, routing excludes static public OpenAI catalog rows unless the current base URL's own `/models` response observed that id. Explicit user pins still work as manual overrides, but automatic routing now only chooses models the configured endpoint actually exposed.
15
+ - **Co-resident TUI instances no longer trip listener-leak warnings** (`src/tui/app.ts`, `test/tui-app.test.ts`) — resize/SIGCONT listener ceilings are raised only to the observed in-process TUI fan-out, and the backpressure test now calls `finish()` so its cleanup path matches production.
16
+
17
+ ### Added
18
+ - **API-auth model regression coverage** (`test/model-discovery.test.ts`, `test/prompt-router.test.ts`) — added tests proving API-key OpenAI discovery records live routing rows without widening `isCodexModel()`, custom base URLs do not inherit public `gpt-*` pools before discovery, recorded custom-base models route as `openai/...`, and OpenAI-compatible provider models enter pools under their provider prefix.
19
+
20
+ ### Verified
21
+ - Focused model/routing suite: `bun test test/model-discovery.test.ts test/prompt-router.test.ts test/model-manager.test.ts` — 132 pass / 0 fail.
22
+ - Typecheck: `bun run typecheck` — pass.
23
+ - Full suite: `bun test` — 2814 pass / 0 fail.
24
+ - Package tarball smoke: `npm pack --dry-run` — produced `jeo-code-0.8.15.tgz`.
25
+
26
+ ## [0.8.14] - 2026-07-09
27
+ _"GPT 연결 실패가 여전히 무응답으로 끝나는 케이스를 잡아줘" — traced the remaining GPT/routing failure path to post-call continuity: the route target could be credentialed and servable, but still end a turn with a recoverable terminal result such as plain `OpenAI returned no content`, rate/quota pressure, model unavailable/not found, or an invalid agent-tool response. jeo already knew the prompt tier and equivalent credentialed fallbacks, but it only applied that fallback before the model call. After the call failed, the turn stopped instead of trying another model in the same tier._
28
+
29
+ ### Fixed
30
+ - **Post-call routing recovery now tries equivalent tier fallbacks** (`src/commands/launch.ts`) — after a routed model ends without a usable answer for recoverable reasons (`returned no content`, rate/usage limit, model unavailable/not found, or agent protocol dead-end), jeo now walks the same `tierModelPool()` equivalent model set used by the pre-call veto path, skips models already attempted this turn, preserves per-model cache keys, emits a `[route]` notice, updates `/route why` with the actual replacement, and retries the turn. Deterministic budget/safety cases (`max_output_tokens`, `finish_reason=length`, content filter/refusal/safety categories) intentionally do not reroute.
31
+
32
+ ### Added
33
+ - **Post-call reroute regression coverage** (`test/launch-prompt-routing.test.ts`) — added tests for plain OpenAI no-content recovery to a same-tier fallback, continued traversal when the first fallback also fails recoverably, and no reroute for deterministic output-budget no-content.
34
+
35
+ ### Verified
36
+ - Focused routing suite: `bun test test/launch-prompt-routing.test.ts` — 26 pass / 0 fail.
37
+
9
38
  ## [0.8.13] - 2026-07-09
10
39
  _"테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used._
11
40
 
package/README.ja.md CHANGED
@@ -262,11 +262,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
262
262
  ## 変更履歴 (Changelog)
263
263
 
264
264
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
265
+ - **[0.8.15]** (2026-07-09) — "api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed.
266
+ - **[0.8.14]** (2026-07-09) — "GPT 연결 실패가 여전히 무응답으로 끝나는 케이스를 잡아줘" — traced the remaining GPT/routing failure path to post-call continuity: the route target could be credentialed and servable, but still end a turn with a recoverable terminal result such as plain `OpenAI returned no content`, rate/quota pressure, model unavailable/not found, or an invalid agent-tool response. jeo already knew the prompt tier and equivalent credentialed fallbacks, but it only applied that fallback before the model call. After the call failed, the turn stopped instead of trying another model in the same tier.
265
267
  - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
266
268
  - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
267
269
  - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
268
- - **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
269
- - **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
270
270
 
271
271
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
272
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -263,11 +263,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
263
263
  ## 변경 이력 (Changelog)
264
264
 
265
265
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
266
+ - **[0.8.15]** (2026-07-09) — "api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed.
267
+ - **[0.8.14]** (2026-07-09) — "GPT 연결 실패가 여전히 무응답으로 끝나는 케이스를 잡아줘" — traced the remaining GPT/routing failure path to post-call continuity: the route target could be credentialed and servable, but still end a turn with a recoverable terminal result such as plain `OpenAI returned no content`, rate/quota pressure, model unavailable/not found, or an invalid agent-tool response. jeo already knew the prompt tier and equivalent credentialed fallbacks, but it only applied that fallback before the model call. After the call failed, the turn stopped instead of trying another model in the same tier.
266
268
  - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
267
269
  - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
268
270
  - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
269
- - **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
270
- - **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
271
271
 
272
272
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
273
273
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -265,11 +265,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
265
265
  ## Changelog
266
266
 
267
267
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
268
+ - **[0.8.15]** (2026-07-09) — "api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed.
269
+ - **[0.8.14]** (2026-07-09) — "GPT 연결 실패가 여전히 무응답으로 끝나는 케이스를 잡아줘" — traced the remaining GPT/routing failure path to post-call continuity: the route target could be credentialed and servable, but still end a turn with a recoverable terminal result such as plain `OpenAI returned no content`, rate/quota pressure, model unavailable/not found, or an invalid agent-tool response. jeo already knew the prompt tier and equivalent credentialed fallbacks, but it only applied that fallback before the model call. After the call failed, the turn stopped instead of trying another model in the same tier.
268
270
  - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
269
271
  - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
270
272
  - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
271
- - **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
272
- - **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
273
273
 
274
274
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
275
275
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -262,11 +262,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
262
262
  ## 更新日志 (Changelog)
263
263
 
264
264
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
265
+ - **[0.8.15]** (2026-07-09) — "api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed.
266
+ - **[0.8.14]** (2026-07-09) — "GPT 연결 실패가 여전히 무응답으로 끝나는 케이스를 잡아줘" — traced the remaining GPT/routing failure path to post-call continuity: the route target could be credentialed and servable, but still end a turn with a recoverable terminal result such as plain `OpenAI returned no content`, rate/quota pressure, model unavailable/not found, or an invalid agent-tool response. jeo already knew the prompt tier and equivalent credentialed fallbacks, but it only applied that fallback before the model call. After the call failed, the turn stopped instead of trying another model in the same tier.
265
267
  - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
266
268
  - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
267
269
  - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
268
- - **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
269
- - **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
270
270
 
271
271
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
272
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.8.13",
3
+ "version": "0.8.15",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -49,11 +49,10 @@
49
49
  */
50
50
  import { callLlm } from "./loop";
51
51
  import { resolveRoleModel, modelServableWithConfig } from "../ai/model-manager";
52
- import { catalogMetadata, MODEL_CATALOG, compareReleaseDate, type CatalogModel } from "../ai/model-catalog";
52
+ import { catalogMetadata, MODEL_CATALOG, compareReleaseDate, liveProviderCatalogModels, isLiveProviderModel, type CatalogModel, type ThinkLevel } from "../ai/model-catalog";
53
53
  import { priceForModel } from "../ai/pricing";
54
54
  import { tryExtractJsonObject } from "./json";
55
55
  import type { Config } from "./state";
56
- import type { ThinkLevel } from "../ai/model-catalog";
57
56
  import type { ProviderName } from "../ai/types";
58
57
  import type { AuthProvider } from "../auth";
59
58
 
@@ -90,24 +89,17 @@ export interface RoutePromptOptions {
90
89
  // --- Frozen, bilingual-validated classification signals (do not re-derive). ---
91
90
 
92
91
  const RE_CAUSAL = /\b(why|how|root cause|how come)\b/i;
93
- const RE_CAUSAL_KR = /(왜\s|근본\s*원인|이유가|어떻게)/;
92
+ const RE_CAUSAL_KR = /(왜\s|근본\s*원인|이유가|어떻게|이유|어째서|원인|해결책|해결방안)/;
94
93
  const RE_TRIVIAL_QUESTION_WORD = /\b(what|who|when|where|which)\b/i;
95
- const RE_TRIVIAL_QUESTION_WORD_KR = /(뭐|뭔가요|무엇|언제|어디|누구|몇\s|인가요|있나요)/;
94
+ const RE_TRIVIAL_QUESTION_WORD_KR = /(뭐|뭔가요|무엇|언제|어디|누구|몇\s|인가요|있나요|냐|음\?|인가)/;
96
95
  const RE_CODE_FENCE = /```/;
97
- const RE_PATH_TOKEN = /(?:^|[\s"'`(])@?(?:\.{0,2}[\w-]*\/)+[\w-]+(?:\.[\w-]+)*\.[A-Za-z]\w{0,7}\b/g;
96
+ const RE_CODE_SNIPPET = /(?:const|let|var|import|export|function|class|if\s*\(|try\s*{|=>|\{\s*[\w\d_]+\s*\})/;
97
+ const RE_PATH_TOKEN = /(?:^|[\s"'`(])@?(?:\.{0,2}[\w-]*[\/\\])+[\w-]+(?:\.[\w-]+)*(?:\.[A-Za-z]\w{0,7}|[\/\\])\b/g;
98
+ const RE_CLI_COMMAND = /(?:^|\s)(git|npm|bun|docker-compose|pip|cargo)\s+[a-z-_]+/i;
98
99
  const RE_DEEP_KEYWORDS_EN = /\b(design|architecture|refactor|debug|deep\s*dive|investigate|diagnose|redesign)\b/i;
99
100
  const RE_DEEP_KEYWORDS_KR = /(설계|아키텍처|리팩터|디버그|딥\s*다이브|딥다이브|진단)/;
100
- const RE_SENTENCE_BOUNDARY = /[.!?][ \n]|[.!?]$/g;
101
+ const RE_SENTENCE_BOUNDARY = /[.!?][ \n]|[.!?.]$/g;
101
102
 
102
- /**
103
- * Pure, synchronous, zero-I/O prompt classification. Every branch and threshold
104
- * here is frozen per the PromptRouter contract (validated against a 17+-case
105
- * bilingual corpus) — in particular: a causal ("why"/"how"/"왜"/"어떻게") question
106
- * is NEVER treated as trivial just because it's short and question-shaped (a
107
- * debugging question is the opposite of trivial), and the path-token regex is
108
- * anchored so numeric fractions like "3/4.5" or "10/10" never false-positive as
109
- * a file path (the trailing segment must end in a letter-led extension).
110
- */
111
103
  export function classifyPromptHeuristically(prompt: string): HeuristicResult {
112
104
  const trimmed = prompt.trim();
113
105
  const signals: string[] = [];
@@ -126,14 +118,22 @@ export function classifyPromptHeuristically(prompt: string): HeuristicResult {
126
118
  signals.push("short-question");
127
119
  }
128
120
 
129
- // trivial signal 2: no code fence AND no file path mention.
121
+ // trivial signal 2: no code fence AND no code snippet AND no file path mention AND no CLI command.
130
122
  const hasCodeFence = RE_CODE_FENCE.test(trimmed);
123
+ const hasCodeSnippet = RE_CODE_SNIPPET.test(trimmed);
124
+ const hasCliCommand = RE_CLI_COMMAND.test(trimmed);
131
125
  const pathMatches = [...trimmed.matchAll(RE_PATH_TOKEN)];
132
- if (!hasCodeFence && pathMatches.length === 0) {
126
+ if (!hasCodeFence && !hasCodeSnippet && !hasCliCommand && pathMatches.length === 0) {
133
127
  trivialScore++;
134
128
  signals.push("no-code-no-path");
135
129
  }
136
130
 
131
+ // trivial signal 3: explicit CLI command (usually simple lookup/execution)
132
+ if (hasCliCommand) {
133
+ trivialScore++;
134
+ signals.push("cli-command");
135
+ }
136
+
137
137
  // complex signal 1: deep-work keyword.
138
138
  if (RE_DEEP_KEYWORDS_EN.test(trimmed) || RE_DEEP_KEYWORDS_KR.test(trimmed)) {
139
139
  complexScore++;
@@ -160,7 +160,6 @@ export function classifyPromptHeuristically(prompt: string): HeuristicResult {
160
160
  complexScore++;
161
161
  signals.push("multi-file");
162
162
  }
163
-
164
163
  // Tier + confidence resolution (frozen thresholds).
165
164
  if (trivialScore > 0 && complexScore > 0) {
166
165
  return { tier: "standard", confidence: 0.35, signals }; // conflicting signals -> let escalation decide
@@ -202,7 +201,7 @@ export function resetPromptRouterWarnings(): void {
202
201
  * `undefined`. Production callers (`readGlobalConfig()`'s real `Config`) always
203
202
  * provide both. */
204
203
  export type RoutingConfig = Pick<Config, "defaultModel" | "roles" | "routing"> &
205
- Partial<Pick<Config, "providers" | "oauth">>;
204
+ Partial<Pick<Config, "providers" | "oauth" | "openaiBaseUrl">>;
206
205
 
207
206
  /** Cloud providers eligible for cross-provider auto-select (excludes local
208
207
  * ollama/lmstudio, which are never a sensible "route to the cheapest/strongest"
@@ -221,10 +220,19 @@ function hasAntigravityOauth(config: RoutingConfig): boolean {
221
220
  return !!(config.oauth?.antigravity || config.oauth?.gemini);
222
221
  }
223
222
 
223
+ function routingCatalog(config: RoutingConfig): CatalogModel[] {
224
+ const rows = [...MODEL_CATALOG, ...liveProviderCatalogModels(config)];
225
+ const eligible = rows.filter(m => isAutoSelectCandidate(m, config));
226
+ const byId = new Map<string, CatalogModel>();
227
+ for (const row of eligible) if (!byId.has(row.canonical)) byId.set(row.canonical, row);
228
+ return [...byId.values()];
229
+ }
230
+
224
231
  function isAutoSelectCandidate(m: CatalogModel, config: RoutingConfig): boolean {
225
232
  // Shared model-LEVEL credential gate (the full auth matrix is documented on
226
233
  // modelServableWithConfig; keep aligned with resolveCall).
227
234
  if (!isCloudProvider(m.provider) || m.limitedAvailability || !modelServableWithConfig(m.provider, m.canonical, config)) return false;
235
+ if (m.provider === "openai" && config.openaiBaseUrl && !isLiveProviderModel("openai", m.canonical, config)) return false;
228
236
  if (hasAntigravityOauth(config) && m.provider === "gemini") return false;
229
237
  return true;
230
238
  }
@@ -280,7 +288,7 @@ function compareStrengthAscending(a: CatalogModel, b: CatalogModel): number {
280
288
  * for deterministic pool ordering (required by `selectFromPool`'s index math). */
281
289
  export function tierModelPool(tier: PromptTier, config: RoutingConfig): string[] {
282
290
  const targetClass = TIER_TO_SIZE_CLASS[tier];
283
- const credentialed = MODEL_CATALOG.filter(m => isAutoSelectCandidate(m, config));
291
+ const credentialed = routingCatalog(config);
284
292
 
285
293
  const suffixPool = credentialed.filter(m => sizeClassFor(m) === targetClass);
286
294
 
@@ -329,7 +337,7 @@ export function selectFromPool(pool: readonly string[], sessionId: string | unde
329
337
  export function cheapestCredentialed(config: RoutingConfig): string | null {
330
338
  let best: CatalogModel | null = null;
331
339
  let bestCost = Infinity;
332
- for (const m of MODEL_CATALOG) {
340
+ for (const m of routingCatalog(config)) {
333
341
  // Candidate eligibility is centralized in `isAutoSelectCandidate`: it keeps
334
342
  // OAuth-backed Antigravity routing on provider-qualified 3.1+ Gemini rows and
335
343
  // prevents public `gemini` catalog rows from winning when Antigravity OAuth is
@@ -377,7 +385,7 @@ export function strongestCredentialed(
377
385
  filter?: (m: CatalogModel) => boolean
378
386
  ): string | null {
379
387
  let best: CatalogModel | null = null;
380
- for (const m of MODEL_CATALOG) {
388
+ for (const m of routingCatalog(config)) {
381
389
  // Candidate eligibility is centralized in `isAutoSelectCandidate` (see
382
390
  // cheapestCredentialed above for the Antigravity/Gemini OAuth rationale).
383
391
  if (!isAutoSelectCandidate(m, config)) continue;
@@ -406,8 +414,8 @@ export function strongestCredentialed(
406
414
  * mid-class-suffixed model is credentialed (caller falls through to
407
415
  * `roles.medium`/`defaultModel` — never to `complex`'s flagship pick). */
408
416
  function strongestMidTierCredentialed(config: RoutingConfig): string | null {
409
- const midClass = MODEL_CATALOG.filter(
410
- m => isAutoSelectCandidate(m, config) && sizeClassFor(m) === "mid",
417
+ const midClass = routingCatalog(config).filter(
418
+ m => sizeClassFor(m) === "mid",
411
419
  );
412
420
  if (midClass.length === 0) return null;
413
421
  return midClass.sort(compareStrengthAscending).at(-1)!.canonical;
@@ -222,6 +222,122 @@ export function resetLiveCodexModels(): void {
222
222
  liveCodexModels.clear();
223
223
  }
224
224
 
225
+ /** Live provider model ids observed from authenticated/keyless `/models` endpoints during
226
+ * this process. Unlike `liveCodexModels`, this is NOT an OAuth allow-list; it is a
227
+ * routing supplement so API-key and OpenAI-compatible catalogs discovered at runtime
228
+ * can participate in prompt routing without waiting for a static release. */
229
+ type LiveProviderModelSource = "oauth" | "api_key" | "keyless" | "none";
230
+ interface LiveProviderModelRecord {
231
+ row: CatalogModel;
232
+ source: LiveProviderModelSource;
233
+ baseUrl?: string;
234
+ }
235
+ const liveProviderModels = new Map<string, LiveProviderModelRecord>();
236
+
237
+ function normalizeBaseUrl(baseUrl: string | undefined): string | undefined {
238
+ return baseUrl?.replace(/\/+$/, "");
239
+ }
240
+
241
+ function stripProviderPrefix(provider: ProviderName, id: string): string {
242
+ const prefix = `${provider}/`;
243
+ if (id.startsWith(prefix)) return id.slice(prefix.length);
244
+ if (provider === "openai" && id.startsWith("openai/")) return id.slice("openai/".length);
245
+ if (provider === "gemini" && id.startsWith("google/")) return id.slice("google/".length);
246
+ return id;
247
+ }
248
+
249
+ function liveCanonicalId(provider: ProviderName, id: string): string {
250
+ const trimmed = id.trim();
251
+ if (!trimmed) return trimmed;
252
+ if (trimmed.startsWith(`${provider}/`)) return trimmed;
253
+ if (provider === "openai") {
254
+ const lower = trimmed.toLowerCase();
255
+ // Official OpenAI-style ids already route to OpenAI by heuristic. Custom
256
+ // OpenAI-compatible deployments (Azure/local/proxy) often use arbitrary names;
257
+ // qualify those so routing sends them back to the endpoint that listed them.
258
+ return lower.includes("gpt") || /^o\d/.test(lower) || lower.includes("chatgpt")
259
+ ? trimmed
260
+ : `openai/${trimmed}`;
261
+ }
262
+ if (provider === "xai" || provider === "kimi") return trimmed;
263
+ return openaiCompatDef(provider) || provider === "ollama" || provider === "lmstudio" || provider === "antigravity"
264
+ ? `${provider}/${trimmed}`
265
+ : trimmed;
266
+ }
267
+
268
+ function liveModelRecordKey(provider: ProviderName, canonical: string, baseUrl: string | undefined): string {
269
+ return `${provider}\u0000${normalizeBaseUrl(baseUrl) ?? ""}\u0000${canonical}`;
270
+ }
271
+
272
+ function liveModelFallbackRow(provider: ProviderName, canonical: string, providerModel: string): CatalogModel {
273
+ const inferred = inferCatalogMetadata(canonical) ?? inferCatalogMetadata(providerModel);
274
+ return {
275
+ canonical,
276
+ provider,
277
+ providerModel,
278
+ contextTokens: inferred?.contextTokens ?? 128_000,
279
+ maxOutputTokens: inferred?.maxOutputTokens ?? 16_384,
280
+ thinking: inferred?.thinking ?? [],
281
+ images: inferred?.images ?? true,
282
+ company: inferred?.company ?? companyLabel(provider),
283
+ releaseDate: inferred?.releaseDate,
284
+ sizeClass: inferred?.sizeClass,
285
+ };
286
+ }
287
+
288
+ /** Record live ids returned by a provider's own model-list endpoint. Additive only:
289
+ * transient partial responses must not evict an id observed earlier this session. */
290
+ export function recordLiveProviderModels(
291
+ provider: ProviderName,
292
+ ids: readonly string[],
293
+ opts: { source?: LiveProviderModelSource; baseUrl?: string } = {},
294
+ ): void {
295
+ const baseUrl = normalizeBaseUrl(opts.baseUrl);
296
+ for (const id of ids) {
297
+ const canonical = liveCanonicalId(provider, id);
298
+ if (!canonical) continue;
299
+ const providerModel = stripProviderPrefix(provider, canonical);
300
+ const known = findCatalogModel(canonical) ?? findCatalogModel(providerModel);
301
+ const row = known
302
+ ? { ...known, canonical, provider, providerModel }
303
+ : liveModelFallbackRow(provider, canonical, providerModel);
304
+ liveProviderModels.set(liveModelRecordKey(provider, canonical, baseUrl), {
305
+ row,
306
+ source: opts.source ?? "none",
307
+ baseUrl,
308
+ });
309
+ }
310
+ }
311
+
312
+ function liveRecordMatchesConfig(record: LiveProviderModelRecord, config?: { openaiBaseUrl?: string }): boolean {
313
+ if (record.row.provider !== "openai") return true;
314
+ const wanted = normalizeBaseUrl(config?.openaiBaseUrl);
315
+ if (wanted || record.baseUrl) return wanted === record.baseUrl;
316
+ return true;
317
+ }
318
+
319
+ /** Live-discovered catalog rows usable for routing under the current config. */
320
+ export function liveProviderCatalogModels(config?: { openaiBaseUrl?: string }): CatalogModel[] {
321
+ return [...liveProviderModels.values()]
322
+ .filter(record => liveRecordMatchesConfig(record, config))
323
+ .map(record => record.row);
324
+ }
325
+
326
+ /** True when a model came from the current provider/base URL's live model list. */
327
+ export function isLiveProviderModel(provider: ProviderName, model: string, config?: { openaiBaseUrl?: string }): boolean {
328
+ const candidates = new Set([model, liveCanonicalId(provider, model), stripProviderPrefix(provider, model)]);
329
+ return [...liveProviderModels.values()].some(record =>
330
+ record.row.provider === provider &&
331
+ liveRecordMatchesConfig(record, config) &&
332
+ (candidates.has(record.row.canonical) || candidates.has(record.row.providerModel))
333
+ );
334
+ }
335
+
336
+ /** Test-only: clear live provider model supplements. */
337
+ export function resetLiveProviderModels(): void {
338
+ liveProviderModels.clear();
339
+ }
340
+
225
341
  /**
226
342
  * Model ids the Kimi Code OAuth backend (api.kimi.com/coding, Anthropic Messages
227
343
  * format) actually serves. The subscription endpoint rejects Moonshot API-platform
@@ -11,7 +11,7 @@ import { readGlobalConfig, type Config } from "../agent/state";
11
11
  import { resolveCredential, type AuthProvider, type Credential } from "../auth";
12
12
  import type { ProviderName } from "./types";
13
13
  import { PROVIDER_NAMES } from "./provider-status";
14
- import { catalogByProvider, CODEX_MODELS, KIMI_CODE_MODELS, recordLiveCodexModels } from "./model-catalog";
14
+ import { catalogByProvider, CODEX_MODELS, KIMI_CODE_MODELS, recordLiveCodexModels, recordLiveProviderModels } from "./model-catalog";
15
15
  import { extractChatgptAccountId } from "./providers/openai-responses";
16
16
  import { openaiCompatDef } from "./providers/openai-compatible-catalog";
17
17
 
@@ -405,9 +405,10 @@ export async function listProviderModels(
405
405
  }
406
406
  }
407
407
  const models = [...new Set(ids)].sort().slice(0, limit);
408
- // Widen the self-healing Codex gate (model-catalog.ts) the moment the account's
409
- // OWN live endpoint confirms a model — closes the "picker shows it, call-time
410
- // gate rejects it" drift window without waiting for a jeo release.
408
+ // Record live ids for routing supplements. For OpenAI OAuth, keep the
409
+ // existing Codex allow-list widening separate: API-key/custom-base discovery
410
+ // must not make OAuth Codex calls accept unrelated API models.
411
+ recordLiveProviderModels(provider, models, { source, baseUrl: opts.baseUrl });
411
412
  if (provider === "openai" && source === "oauth") recordLiveCodexModels(models);
412
413
  return { provider, models, ok: true, source };
413
414
  } catch (err) {
@@ -1323,31 +1323,50 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1323
1323
  });
1324
1324
  const routeFailureReason = (doneReason: unknown): string | null => {
1325
1325
  const message = String(doneReason ?? "");
1326
- if (!message) return null;
1326
+ if (!message) return "stopped without a final answer";
1327
1327
  const asError = new Error(message);
1328
1328
  if (isRateLimitError(asError)) return "hit a rate limit";
1329
1329
  if (isUsageLimitError(asError)) return "hit a usage/quota limit";
1330
1330
  if (/does not recognize the requested model|model .*?(?:not found|not available|unavailable|unsupported|retired|gated)|\b(?:400|404)\b/i.test(message)) {
1331
1331
  return "is unavailable";
1332
1332
  }
1333
+ // Treat plain empty completions and protocol dead-ends as continuity failures:
1334
+ // the routed model did not produce an answer the agent can commit. Do NOT
1335
+ // reroute deterministic policy/budget errors — switching models must not be
1336
+ // a safety bypass or a substitute for raising output/context budgets.
1337
+ if (/returned no content/i.test(message)) {
1338
+ if (/max[_ -]?(?:output[_ -]?)?tokens|finish_reason=length|done_reason=length|stop_reason=max_tokens|content_filter|refusal|safety|prohibited_content|blocklist|recitation|spii|reasoning_extraction/i.test(message)) {
1339
+ return null;
1340
+ }
1341
+ return "returned no content";
1342
+ }
1343
+ if (/no valid tool call|without signaling done|consecutive failing tool calls|consecutive failing tool steps|repeating the same tool call|cycling through the same/i.test(message)) {
1344
+ return "did not produce a usable agent response";
1345
+ }
1333
1346
  return null;
1334
1347
  };
1335
1348
  result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
1336
- const routedFailureReason = routed && !result.done ? routeFailureReason(result.doneReason) : null;
1337
- if (routed && routedFailureReason) {
1338
- const fallback = await equivalentRouteFallback(routed, routedFailureReason, [activeModel]);
1339
- if (fallback) {
1340
- const previousModel = activeModel;
1341
- activeModel = fallback;
1342
- activeThinking = routed.thinking ?? sessionThinking;
1343
- turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
1344
- contextTokens = catalogMetadata(activeModel)?.contextTokens;
1345
- lastRouteDecision = { ...routed, model: activeModel, warning: `routed model '${previousModel}' ${routedFailureReason}; switched to equivalent '${activeModel}'` };
1346
- const notice = `[route] routed model '${previousModel}' ${routedFailureReason}; switching to equivalent '${activeModel}' for this turn.`;
1347
- if (tui) tui.events().onNotice?.(notice);
1348
- else console.log(notice);
1349
- result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
1350
- }
1349
+ const routeFallbackMaxRaw = Number(jeoEnv("ROUTE_FALLBACK_MAX_ATTEMPTS") ?? NaN);
1350
+ const routeFallbackMax = Number.isFinite(routeFallbackMaxRaw) && routeFallbackMaxRaw >= 0
1351
+ ? Math.floor(routeFallbackMaxRaw)
1352
+ : 3;
1353
+ const attemptedRouteModels = new Set<string>([activeModel]);
1354
+ for (let routeFallbackAttempt = 0; routed && routeFallbackAttempt < routeFallbackMax; routeFallbackAttempt++) {
1355
+ const routedFailureReason = !result.done ? routeFailureReason(result.doneReason) : null;
1356
+ if (!routedFailureReason) break;
1357
+ const fallback = await equivalentRouteFallback(routed, routedFailureReason, [...attemptedRouteModels]);
1358
+ if (!fallback) break;
1359
+ const previousModel = activeModel;
1360
+ attemptedRouteModels.add(fallback);
1361
+ activeModel = fallback;
1362
+ activeThinking = routed.thinking ?? sessionThinking;
1363
+ turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
1364
+ contextTokens = catalogMetadata(activeModel)?.contextTokens;
1365
+ lastRouteDecision = { ...routed, model: activeModel, warning: `routed model '${previousModel}' ${routedFailureReason}; switched to equivalent '${activeModel}'` };
1366
+ const notice = `[route] routed model '${previousModel}' ${routedFailureReason}; switching to equivalent '${activeModel}' for this turn.`;
1367
+ if (tui) tui.events().onNotice?.(notice);
1368
+ else console.log(notice);
1369
+ result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
1351
1370
  }
1352
1371
  if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
1353
1372
  history.push({
package/src/tui/app.ts CHANGED
@@ -1135,14 +1135,23 @@ export class LaunchTui {
1135
1135
  this.draw();
1136
1136
  });
1137
1137
  // Watch terminal resizes: rows/cols changes invalidate the previous frame, so
1138
- // force a full repaint instead of diffing against stale line positions.
1138
+ // force a full repaint instead of diffing against stale line positions. Tests and
1139
+ // embedded runners can create several co-resident TUI instances before teardown;
1140
+ // raise the listener ceiling just enough to represent that real fan-out instead
1141
+ // of letting Node/Bun turn legitimate co-residency into a MaxListeners warning.
1139
1142
  if (this.tty) {
1143
+ const resizeCount = process.stdout.listenerCount("resize");
1144
+ const stdoutMax = process.stdout.getMaxListeners();
1145
+ if (stdoutMax > 0 && resizeCount + 1 >= stdoutMax) process.stdout.setMaxListeners(resizeCount + 2);
1140
1146
  process.stdout.on("resize", this.onResize);
1141
1147
  // Suspend (Ctrl-Z) → resume (fg): SIGWINCH is lost while the process is stopped,
1142
1148
  // so a terminal resized mid-suspend would resume with stale geometry and a torn
1143
1149
  // frame. SIGCONT fires on resume; force a re-measure + full repaint. POSIX only
1144
1150
  // (Windows has no SIGCONT — registering it would throw).
1145
1151
  if (process.platform !== "win32") {
1152
+ const resumeCount = process.listenerCount("SIGCONT");
1153
+ const processMax = process.getMaxListeners();
1154
+ if (processMax > 0 && resumeCount + 1 >= processMax) process.setMaxListeners(resumeCount + 2);
1146
1155
  process.on("SIGCONT", this.onResume);
1147
1156
  }
1148
1157
  }