jeo-code 0.8.10 → 0.8.12

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,36 @@ 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.12] - 2026-07-08
10
+ _"제오코드에서 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._
11
+
12
+ ### Fixed
13
+ - **Added the missing `gpt-5.4-mini` to the OAuth allow-list** (`src/ai/model-catalog.ts`) — immediate fix for the exact reported failure, with a full capability row (272K context, 128K max output, full thinking support, image input) added to `MODEL_CATALOG`.
14
+
15
+ ### Added
16
+ - **Self-healing live Codex model gate** (`src/ai/model-catalog.ts`, `src/ai/model-discovery.ts`, `src/ai/model-manager.ts`, `src/commands/launch/model-slash.ts`) — new `isCodexModel()` checks the maintained `CODEX_MODELS` list PLUS any model id this session's OWN OAuth discovery call already observed live (`recordLiveCodexModels`, additive-only, session-lifetime). `listProviderModels` now records every `oauth`-sourced OpenAI model list on a successful call (picker open, `jeo doctor`, or any model resolution) — an `api_key`-sourced result never widens the gate, since it says nothing about what OAuth can serve. `oauthServesModel` (the gate behind `resolveCall`, `effectiveCredentialForProvider`, and `modelServableWithConfig`) and the `/model` pin-warning now both consult `isCodexModel` instead of the raw static list — closes this bug class permanently: any future Codex model OpenAI ships becomes usable the moment the account's own endpoint confirms it, with zero release lag. The OAuth-rejection error message is now built dynamically from `CODEX_MODELS` instead of a hard-coded two-model string.
17
+
18
+ ### Verified
19
+ - Independently mutation-tested (5 mutations across all 4 touched files): all 5 caught by tests; found and closed 1 genuine gap in `recordLiveCodexModels`'s additive-across-separate-calls contract (a clear-then-add REPLACE implementation was indistinguishable from ADD until a test called it twice with disjoint id lists).
20
+ - Live-verified: `jeo --model gpt-5.4-mini -p "hello"` failed with the OAuth-rejection error before the fix, succeeds after.
21
+ - Full suite 2802/2802 pass, typecheck clean.
22
+
23
+ ## [0.8.11] - 2026-07-08
24
+ _"프롬프트 라우팅 동작이 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._
25
+
26
+ ### Fixed
27
+ - **Local-provider reachability veto gate** (`src/commands/launch.ts`, `src/ai/model-discovery.ts`) — new `isLocalProviderReachable()` performs a short-timeout (1500ms) live probe against ollama/lmstudio before routing commits to a pinned local model. An unreachable server now vetoes the route (falls back to the session/default model, same `warnOnce` pattern as the existing credential/servability veto branches) instead of routing into a turn that would fail mid-call. Cloud providers never reach this branch — their existing credential/servability checks are sufficient.
28
+ - **Provider + base URL context on connection failures** (`src/util/retry.ts`, `src/ai/model-manager.ts`) — new `ConnectionContextError` wraps a raw `isConnectionError` failure (Bun `ConnectionRefused` / Node `ECONNREFUSED`/`ENOTFOUND`/`EHOSTUNREACH`/`EAI_AGAIN`) with the resolved provider and effective base URL, threaded through `resolveCall`'s `Resolved` type and applied at both `call()` and `stream()` in `createModelManager()` — covers the residual case where routing was NOT engaged (e.g. an explicit `/model ollama/...` pin) and a connection failure still needs to reach the user with useful context.
29
+ - **Actionable connection-error messages** (`src/util/provider-error.ts`) — `friendlyProviderError` now recognizes `ConnectionContextError` (names the provider + base URL; local providers get "start the server" guidance, others get "check the base URL / network" guidance) and bare `isConnectionError` failures as a defensive fallback, replacing the raw Bun message with `Could not connect to <provider> at <url>. …` in every case.
30
+ - **Connection failures are now retryable** (`src/util/retry.ts`) — `defaultRetryable` treats `isConnectionError` as retryable (a local server mid-restart, or a transient DNS blip, can clear within the backoff budget); a persistently-down provider still exhausts the budget and surfaces the new actionable message rather than the raw one.
31
+
32
+ ### Added
33
+ - **Reachability veto gate tests** (`test/launch-prompt-routing.test.ts`) — 5 tests covering the veto firing on an unreachable ollama pin, no false positive when reachable, `/route why` naming the fallback, `warnOnce` notice suppression across turns, and confirming cloud-provider routes never trigger the live probe.
34
+ - **`isLocalProviderReachable` unit tests** (`test/model-discovery.test.ts`) — reachable/unreachable/non-ok-status coverage.
35
+ - **`ConnectionContextError`/`withConnectionContext` unit tests** (`test/retry.test.ts`) — wrapping, pass-through of non-connection errors, and `defaultRetryable` classification.
36
+ - **`friendlyProviderError` connection-message tests** (`test/provider-error-taxonomy.test.ts`) — local vs. non-local guidance, and the bare-error fallback.
37
+ - **`model-manager` integration tests** (`test/model-provider-mapping.test.ts`) — `call()` and `stream()` both re-throw a raw connection failure as `ConnectionContextError` naming the correct provider/base URL.
38
+
9
39
  ## [0.8.10] - 2026-07-08
10
40
  _"지피티 모델 연결이 안되는데 원인파악해서 개선해" — 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)._
11
41
 
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.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.
266
+ - **[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.
265
267
  - **[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).
266
268
  - **[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.
267
269
  - **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
268
- - **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
269
- - **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
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.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
+ - **[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.
266
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).
267
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.
268
270
  - **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
269
- - **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
270
- - **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
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.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.
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
270
  - **[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
271
  - **[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
272
  - **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
271
- - **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
272
- - **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
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.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.
266
+ - **[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.
265
267
  - **[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).
266
268
  - **[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.
267
269
  - **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
268
- - **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
269
- - **[0.8.6]** (2026-07-08) — Explicit `/model` pin always winning over PromptRouter was correct but opaque: `/route status` showed "routing: on" even while a session pin blocked routing from ever evaluating a prompt, and there was no way back to routed mode short of restarting the session. Also closed a related escalation gap: `routing.enabled` with `roles.smol` unset made ambiguous-prompt LLM escalation silently skip every turn, even when a cheaper credentialed model existed to run the classifier.
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.10",
3
+ "version": "0.8.12",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -229,11 +229,53 @@ interface ParsedConceptEntry { sig: string; concept: Concept | null }
229
229
  const parsedConceptCache = new Map<string, ParsedConceptEntry>();
230
230
  const PARSED_CONCEPT_CACHE_CAP = 512;
231
231
 
232
+ /** Single-slot cache for `buildCorpusStats`/`buildConceptGraph` over the LAST
233
+ * `concepts` array seen, keyed by reference identity. `memoryPromptSection` runs
234
+ * `selectWithinBudget` once per interactive turn (`refreshSessionMemory`) and once
235
+ * per subagent spawn (team/ralph/autopilot) — each call re-tokenized every concept's
236
+ * searchable text and re-scanned every body for cross-links, even when NOTHING in the
237
+ * bundle changed between calls. `loadConcepts` already returns the same cached `Concept`
238
+ * OBJECTS when a file's mtime/size is unchanged (see `parsedConceptCache` above), so an
239
+ * identical (same length + same object references) `concepts` array is the common case
240
+ * across consecutive calls in one session — this hits that case with a cheap O(n)
241
+ * reference-equality scan instead of paying the O(n · text length) rebuild. A genuinely
242
+ * changed bundle (new/edited/deleted concept) always produces a different array — either
243
+ * a different length or at least one different object reference — so correctness never
244
+ * depends on this cache; it only skips work that would reach the same answer. */
245
+ let statsGraphCacheConcepts: Concept[] | null = null;
246
+ let statsGraphCacheStats: CorpusStats | null = null;
247
+ let statsGraphCacheGraph: ConceptGraph | null = null;
248
+
249
+ function sameConceptsArray(a: Concept[], b: Concept[]): boolean {
250
+ if (a.length !== b.length) return false;
251
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
252
+ return true;
253
+ }
254
+
255
+ /** Corpus stats + link graph for `concepts`, memoized against the last call (see above). */
256
+ function statsAndGraphFor(concepts: Concept[]): { stats: CorpusStats; graph: ConceptGraph } {
257
+ if (
258
+ statsGraphCacheConcepts && statsGraphCacheStats && statsGraphCacheGraph &&
259
+ sameConceptsArray(statsGraphCacheConcepts, concepts)
260
+ ) {
261
+ return { stats: statsGraphCacheStats, graph: statsGraphCacheGraph };
262
+ }
263
+ const stats = buildCorpusStats(concepts);
264
+ const graph = buildConceptGraph(concepts);
265
+ statsGraphCacheConcepts = concepts;
266
+ statsGraphCacheStats = stats;
267
+ statsGraphCacheGraph = graph;
268
+ return { stats, graph };
269
+ }
270
+
232
271
  /** Drop the per-file concept parse cache so the next loadConcepts re-reads disk.
233
272
  * The cache is already mtime/size self-invalidating; this is a hard reset for
234
273
  * tests and callers that mutate files without changing their stat signature. */
235
274
  export function invalidateConceptCache(): void {
236
275
  parsedConceptCache.clear();
276
+ statsGraphCacheConcepts = null;
277
+ statsGraphCacheStats = null;
278
+ statsGraphCacheGraph = null;
237
279
  }
238
280
 
239
281
  /** Read every concept document in the bundle into structured `Concept`s. Reserved
@@ -572,8 +614,7 @@ function selectWithinBudget(concepts: Concept[], query: string | undefined, budg
572
614
  const sectionLenByType = new Map<string, number>(); // type -> rendered length of that type's section so far
573
615
  let total = 0; // rendered length of `selected` under renderConcepts
574
616
 
575
- const stats = buildCorpusStats(concepts);
576
- const graph = buildConceptGraph(concepts);
617
+ const { stats, graph } = statsAndGraphFor(concepts);
577
618
 
578
619
  const take = (pool: Concept[]) => {
579
620
  for (const c of priorityOrder(pool, query, stats, graph)) {
@@ -60,15 +60,18 @@ export const TOOL_SPILL_THRESHOLD = TOOL_OUTPUT_MAX;
60
60
  /** Most recent tool-result artifacts to keep; older ones are pruned on each spill. */
61
61
  export const MAX_TOOL_ARTIFACTS = 50;
62
62
 
63
- /** Best-effort retention: keep the newest `MAX_TOOL_ARTIFACTS` files in `dir`, delete the rest. */
63
+ /** Best-effort retention: keep the newest `MAX_TOOL_ARTIFACTS` files in `dir`, delete the
64
+ * rest. Filenames are always `${Date.now()}-${rand}-${tool}.txt` (spillToolResult is the
65
+ * sole writer into this directory) — the leading 13-digit millisecond timestamp is fixed-
66
+ * width until year 2286, so a plain lexicographic sort of the FILENAMES reproduces mtime
67
+ * order exactly. This replaces an `fs.stat` per file (up to MAX_TOOL_ARTIFACTS syscalls,
68
+ * paid on every spill once a long session's artifact dir sits at/near the cap) with a pure
69
+ * in-memory string sort — zero syscalls beyond the single `readdir` every call already paid. */
64
70
  async function pruneToolArtifacts(dir: string): Promise<void> {
65
71
  const files = await fs.readdir(dir).catch(() => [] as string[]);
66
72
  if (files.length <= MAX_TOOL_ARTIFACTS) return;
67
- const stamped = await Promise.all(
68
- files.map(async f => ({ f, m: (await fs.stat(path.join(dir, f)).catch(() => null))?.mtimeMs ?? 0 })),
69
- );
70
- stamped.sort((a, b) => b.m - a.m); // newest first
71
- for (const { f } of stamped.slice(MAX_TOOL_ARTIFACTS)) {
73
+ const newestFirst = [...files].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
74
+ for (const f of newestFirst.slice(MAX_TOOL_ARTIFACTS)) {
72
75
  await fs.rm(path.join(dir, f), { force: true }).catch(() => {});
73
76
  }
74
77
  }
@@ -101,6 +101,7 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
101
101
  // OpenAI (newest first: gpt-5.5 > gpt-5.4 > o4-mini/o3/gpt-4.1 > o3-mini > gpt-4o-mini > gpt-4o)
102
102
  { canonical: "gpt-5.5", provider: "openai", providerModel: "gpt-5.5", contextTokens: 400_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-04" },
103
103
  { canonical: "gpt-5.4", provider: "openai", providerModel: "gpt-5.4", contextTokens: 400_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-03" },
104
+ { canonical: "gpt-5.4-mini", provider: "openai", providerModel: "gpt-5.4-mini", contextTokens: 272_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-03" },
104
105
  { canonical: "o4-mini", provider: "openai", providerModel: "o4-mini", contextTokens: 200_000, maxOutputTokens: 100_000, thinking: STD, images: true, releaseDate: "2025-04" },
105
106
  { canonical: "o3", provider: "openai", providerModel: "o3", contextTokens: 200_000, maxOutputTokens: 100_000, thinking: STD, images: true, releaseDate: "2025-04" },
106
107
  { canonical: "gpt-4.1", provider: "openai", providerModel: "gpt-4.1", contextTokens: 1_000_000, maxOutputTokens: 32_768, thinking: [], images: true, releaseDate: "2025-04" },
@@ -182,7 +183,44 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
182
183
  * usable list endpoint, so an OAuth-only OpenAI login surfaces exactly these instead
183
184
  * of the full chat-completions catalog. Verified live against a ChatGPT account.
184
185
  */
185
- export const CODEX_MODELS: readonly string[] = ["gpt-5.5", "gpt-5.4"];
186
+ export const CODEX_MODELS: readonly string[] = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"];
187
+
188
+ /**
189
+ * Session-lifetime cache of OpenAI Codex model ids OBSERVED live from the account's
190
+ * own `codex/models` response (see `listProviderModels` in model-discovery.ts, which
191
+ * calls `recordLiveCodexModels` on every successful OAuth discovery). `CODEX_MODELS`
192
+ * above is a maintained snapshot that WILL drift the moment OpenAI ships a new Codex
193
+ * model — that drift is exactly the bug class this closes: the picker shows a model
194
+ * (from the SAME live endpoint) that the static gate then hard-rejects at call time
195
+ * ("OAuth doesn't support this model") even though the account can serve it. Any
196
+ * successful discovery this session widens the gate immediately, no release needed;
197
+ * a fresh process starts empty and falls back to the static list until the first
198
+ * discovery call (picker open, `jeo doctor`, or model resolution) populates it.
199
+ */
200
+ const liveCodexModels = new Set<string>();
201
+
202
+ /** Record model ids OpenAI's live Codex endpoint returned for the current account
203
+ * this session. Additive only — never removes a previously-observed id (a transient
204
+ * discovery hiccup should never narrow what a call is allowed to reach). */
205
+ export function recordLiveCodexModels(ids: readonly string[]): void {
206
+ for (const id of ids) liveCodexModels.add(id);
207
+ }
208
+
209
+ /** True when `model` is a Codex-servable id — either in the maintained static list,
210
+ * or observed live this session (see `recordLiveCodexModels`). Accepts a bare or
211
+ * `openai/`-qualified id. */
212
+ export function isCodexModel(model: string): boolean {
213
+ const wire = model.startsWith("openai/") ? model.slice(7) : model;
214
+ return CODEX_MODELS.includes(wire) || liveCodexModels.has(wire);
215
+ }
216
+
217
+ /** Test-only: clear the live-observed Codex model cache (mirrors `resetPromptRouterWarnings`
218
+ * / `resetAppearanceCache`). Bun runs test files in one process, so this module-level Set
219
+ * would otherwise leak an `oauth`-source `listProviderModels("openai", …)` call's observed
220
+ * ids into unrelated tests in the same run. */
221
+ export function resetLiveCodexModels(): void {
222
+ liveCodexModels.clear();
223
+ }
186
224
 
187
225
  /**
188
226
  * Model ids the Kimi Code OAuth backend (api.kimi.com/coding, Anthropic Messages
@@ -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 } from "./model-catalog";
14
+ import { catalogByProvider, CODEX_MODELS, KIMI_CODE_MODELS, recordLiveCodexModels } from "./model-catalog";
15
15
  import { extractChatgptAccountId } from "./providers/openai-responses";
16
16
  import { openaiCompatDef } from "./providers/openai-compatible-catalog";
17
17
 
@@ -405,6 +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.
411
+ if (provider === "openai" && source === "oauth") recordLiveCodexModels(models);
408
412
  return { provider, models, ok: true, source };
409
413
  } catch (err) {
410
414
  const msg = (err as Error)?.name === "TimeoutError" || (err as Error)?.name === "AbortError" ? "timeout" : "unreachable";
@@ -412,6 +416,27 @@ export async function listProviderModels(
412
416
  }
413
417
  }
414
418
 
419
+ /** Live reachability probe for a KEYLESS local provider (ollama/lmstudio) — the
420
+ * narrow gap `describeProvider` leaves open: it reports these as `ready: true`
421
+ * unconditionally (keyless means "no credential needed", not "server is up"), so
422
+ * a routing decision that lands on a downed local server previously sailed past
423
+ * every readiness check and only failed at call time with a raw, unhelpful
424
+ * connection error (see `friendlyProviderError`'s `ConnectionContextError`
425
+ * branch for the same failure surfaced OUTSIDE this proactive check, e.g. an
426
+ * explicit `/model ollama/...` pin). Reuses `listProviderModels`'s tested
427
+ * fetch/timeout/error handling — never throws. `timeoutMs` defaults short
428
+ * (1500ms, well under `listProviderModels`'s 5000ms default): a live loopback
429
+ * service answers in single-digit ms, so this stays cheap on the turn hot path
430
+ * and still fails fast on a genuinely unreachable host. */
431
+ export async function isLocalProviderReachable(
432
+ provider: "ollama" | "lmstudio",
433
+ baseUrl: string | undefined,
434
+ opts: Pick<DiscoveryOptions, "timeoutMs" | "fetchImpl"> = {},
435
+ ): Promise<boolean> {
436
+ const result = await listProviderModels(provider, { baseUrl, timeoutMs: opts.timeoutMs ?? 1500, fetchImpl: opts.fetchImpl, limit: 1 });
437
+ return result.ok;
438
+ }
439
+
415
440
  /**
416
441
  * Discover live models across providers. By default only queries providers that
417
442
  * are logged in / reachable (skips `none` cloud providers); ollama is always
@@ -6,10 +6,10 @@ import "./register-providers"; // side-effect: registers built-in adapters into
6
6
  import type { CallOptions, Message, ProviderAdapter, ProviderName } from "./types";
7
7
  import { expandAlias, resolveModelId, effectiveAliasesFor, BUILTIN_ALIASES } from "./model-registry";
8
8
  import { findCatalogEntry, type ModelCatalogEntry } from "./model-catalog-compat";
9
- import { toProviderModel, CODEX_MODELS, KIMI_CODE_MODELS, findCatalogModel } from "./model-catalog";
9
+ import { toProviderModel, CODEX_MODELS, KIMI_CODE_MODELS, findCatalogModel, isCodexModel } from "./model-catalog";
10
10
  import { xaiCredential } from "./providers/xai";
11
11
  import { OPENAI_COMPAT_NAMES, isOpenAICompatProvider } from "./providers/openai-compatible-catalog";
12
- import { withRetry, defaultRetryable, type RetryOptions } from "../util/retry";
12
+ import { withRetry, defaultRetryable, withConnectionContext, isConnectionError, ConnectionContextError, type RetryOptions } from "../util/retry";
13
13
  import { jeoEnv } from "../util/env";
14
14
  import type { Config } from "../agent/state";
15
15
 
@@ -307,10 +307,11 @@ export function resolveRetryOptions(retry: Config["retry"], kind: "request" | "s
307
307
  */
308
308
  function oauthServesModel(provider: AuthProvider, model: string): boolean {
309
309
  if (provider === "openai") {
310
- // Provider-qualified ids (`openai/gpt-5.5`) are valid route targets — the
311
- // adapters strip the prefix on the wire, so servability must match.
312
- const wire = model.startsWith("openai/") ? model.slice(7) : model;
313
- return CODEX_MODELS.includes(wire);
310
+ // `isCodexModel` checks the maintained CODEX_MODELS list PLUS any id observed
311
+ // live this session (model-catalog.ts) — closes the "picker shows a model the
312
+ // account's own endpoint just confirmed, call-time gate rejects it anyway" drift
313
+ // window that opens every time OpenAI ships a new Codex model between releases.
314
+ return isCodexModel(model);
314
315
  }
315
316
  if (provider === "kimi") {
316
317
  const wire = model.startsWith("kimi/") ? model.slice(5) : model;
@@ -380,6 +381,12 @@ interface Resolved {
380
381
  callOptions: CallOptions;
381
382
  credential: Credential;
382
383
  retry: RetryOptions;
384
+ /** The resolved provider + effective base URL, threaded through so a raw
385
+ * pre-response connection failure (see `isConnectionError`) can be enriched
386
+ * with "which provider/URL was unreachable" before it reaches the user —
387
+ * the bare fetch error carries neither. */
388
+ provider: ProviderName;
389
+ baseUrl: string | undefined;
383
390
  }
384
391
 
385
392
  /**
@@ -452,13 +459,13 @@ export async function resolveCall(options: Partial<CallOptions>, kind: "request"
452
459
  const retry: RetryOptions = { ...resolveRetryOptions(config.retry, kind), ...(options.onRetry ? { onRetry: options.onRetry } : {}), ...(callOptions.signal ? { signal: callOptions.signal } : {}) };
453
460
 
454
461
  if (provider === "ollama" || provider === "lmstudio") {
455
- return { adapter, callOptions, credential: { kind: "none", provider: "openai" }, retry };
462
+ return { adapter, callOptions, credential: { kind: "none", provider: "openai" }, retry, provider, baseUrl };
456
463
  }
457
464
 
458
465
  if (provider === "xai") {
459
466
  const key = config.providers?.xai;
460
467
  if (!key) throw new Error("No credential for provider 'xai'. Set XAI_API_KEY (or providers.xai in config).");
461
- return { adapter, callOptions, credential: xaiCredential(key), retry };
468
+ return { adapter, callOptions, credential: xaiCredential(key), retry, provider, baseUrl };
462
469
  }
463
470
 
464
471
  if (provider === "antigravity") {
@@ -472,7 +479,7 @@ export async function resolveCall(options: Partial<CallOptions>, kind: "request"
472
479
  if (credential.kind !== "oauth") {
473
480
  throw new Error("Antigravity models use Google OAuth. Run 'jeo auth login antigravity' (recommended) or 'jeo auth login gemini', then retry — the Google Cloud projectId is discovered automatically.");
474
481
  }
475
- return { adapter, callOptions, credential, retry };
482
+ return { adapter, callOptions, credential, retry, provider, baseUrl };
476
483
  }
477
484
 
478
485
  const credentialProvider = provider as AuthProvider;
@@ -481,7 +488,7 @@ export async function resolveCall(options: Partial<CallOptions>, kind: "request"
481
488
  const isLocalOpenAi = provider === "openai" && !!baseUrl;
482
489
  if (provider === "openai" && effective.kind === "oauth" && !isLocalOpenAi && !oauthServesModel("openai", model)) {
483
490
  throw new Error(
484
- "OpenAI OAuth 자격증명은 Codex 모델(gpt-5.5/gpt-5.4)만 지원. OPENAI_API_KEY를 설정하거나 모델을 변경하세요"
491
+ `OpenAI OAuth 자격증명은 Codex 모델(${CODEX_MODELS.join("/")})만 지원. OPENAI_API_KEY를 설정하거나 모델을 변경하세요`
485
492
  );
486
493
  }
487
494
  if (provider === "kimi" && effective.kind === "oauth" && !oauthServesModel("kimi", model)) {
@@ -494,7 +501,7 @@ export async function resolveCall(options: Partial<CallOptions>, kind: "request"
494
501
  `No credential for provider '${provider}'. Run 'jeo setup', 'jeo auth login', or set ${provider.toUpperCase()}_API_KEY / ${provider.toUpperCase()}_OAUTH_TOKEN.`
495
502
  );
496
503
  }
497
- return { adapter, callOptions, credential: credentialForCall(provider, effective, config, baseUrl), retry };
504
+ return { adapter, callOptions, credential: credentialForCall(provider, effective, config, baseUrl), retry, provider, baseUrl };
498
505
  }
499
506
 
500
507
  /** Hard wall-clock cap for a single NON-streaming provider request (service-readiness: a
@@ -694,11 +701,15 @@ export function createModelManager(): ModelManager {
694
701
  return {
695
702
  resolveProvider,
696
703
  async call(messages, options = {}) {
697
- const { adapter, callOptions, credential, retry } = await resolveCall(options);
698
- return withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, callTimeoutMs()) }, credential), retry);
704
+ const { adapter, callOptions, credential, retry, provider, baseUrl } = await resolveCall(options);
705
+ return withConnectionContext(
706
+ () => withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, callTimeoutMs()) }, credential), retry),
707
+ provider,
708
+ baseUrl,
709
+ );
699
710
  },
700
711
  async *stream(messages, options = {}) {
701
- const { adapter, callOptions, credential, retry } = await resolveCall(options, "stream");
712
+ const { adapter, callOptions, credential, retry, provider, baseUrl } = await resolveCall(options, "stream");
702
713
  if (adapter.stream) {
703
714
  const streamFn = adapter.stream.bind(adapter);
704
715
  // Per-attempt abort controller fired by the idle timeout — so a stalled stream
@@ -733,15 +744,24 @@ export function createModelManager(): ModelManager {
733
744
  return streamFn(messages, { ...liveOptions, signal }, credential)[Symbol.asyncIterator]();
734
745
  };
735
746
  const maxMs = streamMaxMs();
736
- yield* retryableStream(makeIter, retry, {
737
- idleMs: streamIdleMs(),
738
- ...(maxMs !== undefined ? { deadlineAt: Date.now() + maxMs } : {}),
739
- lastActivityAt: () => lastActivityAt,
740
- onIdle: () => attempt?.abort(),
741
- });
747
+ try {
748
+ yield* retryableStream(makeIter, retry, {
749
+ idleMs: streamIdleMs(),
750
+ ...(maxMs !== undefined ? { deadlineAt: Date.now() + maxMs } : {}),
751
+ lastActivityAt: () => lastActivityAt,
752
+ onIdle: () => attempt?.abort(),
753
+ });
754
+ } catch (err) {
755
+ if (isConnectionError(err)) throw new ConnectionContextError(provider, baseUrl, err);
756
+ throw err;
757
+ }
742
758
  } else {
743
759
  // Fallback: providers without streaming yield the full response as one chunk.
744
- yield await withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, callTimeoutMs()) }, credential), retry);
760
+ yield await withConnectionContext(
761
+ () => withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, callTimeoutMs()) }, credential), retry),
762
+ provider,
763
+ baseUrl,
764
+ );
745
765
  }
746
766
  },
747
767
  };
@@ -2,10 +2,10 @@ import { randomUUID } from "node:crypto";
2
2
  import type { Credential } from "../../auth";
3
3
  import type { CallOptions, Message, ProviderAdapter } from "../types";
4
4
  import { readSse } from "../sse";
5
- import { providerHttpError } from "./errors";
5
+ import { providerHttpError, ProviderStreamError } from "./errors";
6
6
  import { serializeToolCalls } from "../../agent/tool-schemas";
7
7
  import { sanitizeJsonStrings } from "../../util/sanitize-json";
8
- import { geminiThinkingConfig, getGeminiCliHeaders, type GeminiThinkingConfig } from "./gemini";
8
+ import { geminiThinkingConfig, type GeminiThinkingConfig } from "./gemini";
9
9
 
10
10
  const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
11
11
  const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
@@ -95,11 +95,31 @@ export async function resolveAntigravityProjectId(
95
95
 
96
96
  const discover = opts.discover ?? (async (token: string) => {
97
97
  const { discoverGoogleProjectId, ANTIGRAVITY_DISCOVERY_METADATA } = await import("../../auth/flows/google-project");
98
- // Antigravity-client tokens discover with ANTIGRAVITY metadata; gemini-cli
99
- // tokens use the default gemini-cli metadata shape.
100
- return discoverGoogleProjectId(token, credential.provider === "antigravity"
101
- ? { metadata: { ...ANTIGRAVITY_DISCOVERY_METADATA }, extraHeaders: { "User-Agent": getAntigravityUserAgent() }, alwaysOnboard: true, signal: opts.signal }
102
- : { extraHeaders: getGeminiCliHeaders(), signal: opts.signal });
98
+ // This function's SOLE callers are fetchAntigravity/ccaTurn — every discovery
99
+ // it performs exists to serve an Antigravity/Cloud-Code-Assist turn, so it must
100
+ // ALWAYS identify itself to Google as Antigravity (metadata + User-Agent),
101
+ // regardless of which OAuth STORAGE SLOT the token came from (a dedicated
102
+ // antigravity login vs a plain gemini-cli token used as fallback — the common
103
+ // case for users who only ran `jeo auth login gemini`, the documented default).
104
+ // Previously this branched on `credential.provider === "antigravity"` and sent
105
+ // gemini-cli identity (User-Agent `GeminiCLI/…`, metadata `pluginType: GEMINI`)
106
+ // for the fallback case. Root-caused live: Google's `loadCodeAssist` now REJECTS
107
+ // that identity for gemini-cli-individual accounts it has migrated to Antigravity
108
+ // (`ineligibleTiers[].reasonCode: "UNSUPPORTED_CLIENT"`, message "please migrate
109
+ // to the Antigravity suite of products") — the response omits `currentTier`/
110
+ // `cloudaicompanionProject` entirely, so discovery falls through into onboarding,
111
+ // which then fails after 5 polls with "did not return a provisioned project id"
112
+ // even though the SAME account resolves its EXISTING project instantly (0 polls)
113
+ // once the request honestly identifies as Antigravity. This was the actual
114
+ // observed "stops/hangs on Antigravity Gemini" symptom for any account without a
115
+ // dedicated antigravity OAuth login: a deterministic, fast-failing (not literally
116
+ // infinite) but 100%-reproducible discovery break, not a timeout/network hang.
117
+ return discoverGoogleProjectId(token, {
118
+ metadata: { ...ANTIGRAVITY_DISCOVERY_METADATA },
119
+ extraHeaders: { "User-Agent": getAntigravityUserAgent() },
120
+ alwaysOnboard: true,
121
+ signal: opts.signal,
122
+ });
103
123
  });
104
124
  let projectId: string;
105
125
  try {
@@ -232,6 +252,17 @@ interface CcaChunk {
232
252
  candidates?: { content?: { parts?: { text?: string; thought?: boolean; functionCall?: { name?: string; args?: Record<string, unknown> } }[] }; finishReason?: string }[];
233
253
  usageMetadata?: CcaUsage;
234
254
  };
255
+ /** A `google.rpc.Status`-shaped in-band error line, sibling of `response` — see
256
+ * `gemini.ts`'s identical field for the full rationale (silently swallowed here
257
+ * before this fix, which for a turn that had already streamed some text meant an
258
+ * early, error-free, TRUNCATED stop: "안티그라비티 모델이 중간에 멈추는" report). */
259
+ error?: { code?: number; message?: string; status?: string };
260
+ }
261
+ /** Throw a {@link ProviderStreamError} for an in-band error line — no-op when absent. */
262
+ function throwIfGoogleStreamError(chunk: CcaChunk): void {
263
+ const err = chunk.error;
264
+ if (!err) return;
265
+ throw new ProviderStreamError("Antigravity", err.message ?? "unknown error", err.status, err.code);
235
266
  }
236
267
  /** When Antigravity (Cloud Code Assist) returns HTTP 200 with no text, surface the
237
268
  * real cause (safety block / RECITATION / etc.) instead of a silent, reason-free
@@ -293,6 +324,7 @@ export const antigravityAdapter: ProviderAdapter = {
293
324
  for await (const data of readSse(response.body)) {
294
325
  let chunk: CcaChunk;
295
326
  try { chunk = JSON.parse(data); } catch { continue; }
327
+ throwIfGoogleStreamError(chunk);
296
328
  const thought = thoughtOf(chunk);
297
329
  if (thought) options.onReasoning?.(thought);
298
330
  const delta = textOf(chunk);
@@ -321,6 +353,7 @@ export const antigravityAdapter: ProviderAdapter = {
321
353
  for await (const data of readSse(response.body, options.onStreamActivity)) {
322
354
  let chunk: CcaChunk;
323
355
  try { chunk = JSON.parse(data); } catch { continue; }
356
+ throwIfGoogleStreamError(chunk);
324
357
  const thought = thoughtOf(chunk);
325
358
  if (thought) options.onReasoning?.(thought);
326
359
  const delta = textOf(chunk);
@@ -39,10 +39,14 @@ export class ProviderStreamError extends Error {
39
39
  readonly status: number;
40
40
  readonly provider: string;
41
41
  readonly code?: string;
42
- constructor(provider: string, message: string, code?: string) {
42
+ /** `explicitStatus` lets a caller pass an ALREADY-numeric provider status (e.g. Google's
43
+ * `error.code` on a `google.rpc.Status` envelope — 429/500/503) instead of relying on the
44
+ * OpenAI-specific `code === "rate_limit_exceeded"` string heuristic below, which only
45
+ * recognizes that one literal and defaults everything else to 500. */
46
+ constructor(provider: string, message: string, code?: string, explicitStatus?: number) {
43
47
  super(`${provider} stream failed${code ? ` (${code})` : ""}: ${message}`);
44
48
  this.name = "ProviderStreamError";
45
- this.status = code === "rate_limit_exceeded" ? 429 : 500;
49
+ this.status = explicitStatus ?? (code === "rate_limit_exceeded" ? 429 : 500);
46
50
  this.provider = provider;
47
51
  this.code = code;
48
52
  }
@@ -1,7 +1,7 @@
1
1
  import type { Credential } from "../../auth";
2
2
  import type { CallOptions, Message, ProviderAdapter } from "../types";
3
3
  import { readSse } from "../sse";
4
- import { providerHttpError, fetchWithArtifactFailSafe } from "./errors";
4
+ import { providerHttpError, fetchWithArtifactFailSafe, ProviderStreamError } from "./errors";
5
5
  import { jeoEnv } from "../../util/env";
6
6
  import { serializeToolCalls } from "../../agent/tool-schemas";
7
7
  import { sanitizeJsonStrings } from "../../util/sanitize-json";
@@ -258,11 +258,22 @@ interface GeminiChunk {
258
258
  candidates?: { content?: { parts?: { text?: string; thought?: boolean; thoughtSignature?: string; functionCall?: { name?: string; args?: Record<string, unknown> } }[] }; finishReason?: string }[];
259
259
  promptFeedback?: { blockReason?: string };
260
260
  usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; thoughtsTokenCount?: number };
261
+ /** A `google.rpc.Status`-shaped in-band error line: Google's streaming endpoints can emit
262
+ * this as its own SSE `data:` event on an otherwise-live 200 connection (the codebase
263
+ * already knows this envelope shape from `parseRetryFromBody`'s `"retryDelay"` parsing on
264
+ * the equivalent non-stream error body) instead of terminating the HTTP response with a
265
+ * non-2xx status. Previously silently ignored — neither `candidates` nor `promptFeedback`
266
+ * matches it, so it fell through every existing check with no error and no text, which for
267
+ * a turn that had already streamed SOME text meant the turn just ended early with a
268
+ * truncated reply and no error at all: the "모델이 중간에 멈추는" (model stops mid-way) report. */
269
+ error?: { code?: number; message?: string; status?: string };
261
270
  }
262
271
 
263
- /** Cloud Code Assist wraps each standard chunk under `response`. */
272
+ /** Cloud Code Assist wraps each standard chunk under `response`; an in-band error (see
273
+ * `GeminiChunk.error`) arrives as a SIBLING of `response`, not nested inside it. */
264
274
  interface CcaChunk {
265
275
  response?: GeminiChunk;
276
+ error?: { code?: number; message?: string; status?: string };
266
277
  }
267
278
 
268
279
  function textOf(chunk: GeminiChunk): string {
@@ -317,6 +328,20 @@ function blockedReason(chunk: GeminiChunk): string | undefined {
317
328
  return undefined;
318
329
  }
319
330
 
331
+ /** Throw a {@link ProviderStreamError} for an in-band `google.rpc.Status` error line (see
332
+ * `GeminiChunk.error`/`CcaChunk.error`) — a no-op when the chunk carries none. Mirrors the
333
+ * OpenAI Responses `response.failed`/`error` fix: `error.status` (a string enum, e.g.
334
+ * `"RESOURCE_EXHAUSTED"`/`"UNAVAILABLE"`/`"INTERNAL"`) plays the same "classifier" role our
335
+ * `code` param plays for OpenAI, and `error.code` (Google's own numeric HTTP-equivalent) is
336
+ * passed straight through as the explicit status instead of re-derived from a string match. */
337
+ function throwIfGoogleStreamError(
338
+ provider: string,
339
+ err: { code?: number; message?: string; status?: string } | undefined,
340
+ ): void {
341
+ if (!err) return;
342
+ throw new ProviderStreamError(provider, err.message ?? "unknown error", err.status, err.code);
343
+ }
344
+
320
345
  /**
321
346
  * Cloud Code Assist SSE turn for a Google OAuth credential: resolves the
322
347
  * projectId (stored → env → lazy loadCodeAssist/onboardUser discovery), POSTs
@@ -343,6 +368,7 @@ async function* ccaTurn(messages: Message[], options: CallOptions, credential: C
343
368
  } catch {
344
369
  continue;
345
370
  }
371
+ throwIfGoogleStreamError("Gemini (Cloud Code Assist)", chunk.error);
346
372
  const inner = chunk.response;
347
373
  if (!inner) continue;
348
374
  const thought = thoughtOf(inner);
@@ -419,6 +445,7 @@ export const geminiAdapter: ProviderAdapter = {
419
445
  } catch {
420
446
  continue;
421
447
  }
448
+ throwIfGoogleStreamError("Gemini", chunk.error);
422
449
  const thought = thoughtOf(chunk);
423
450
  if (thought) options.onReasoning?.(thought);
424
451
  captureGeminiSignatures(chunk, options, seenSigs);
@@ -21,6 +21,7 @@ import {
21
21
  resolveSelection,
22
22
  catalogMetadata,
23
23
  CODEX_MODELS,
24
+ isCodexModel,
24
25
  qualifyModelId,
25
26
  } from "../../ai";
26
27
  import type { ProviderModelsResult, PickEntry, ProviderName, ThinkLevel } from "../../ai";
@@ -287,7 +288,10 @@ export async function runModelSlash(input: string, ctx: ModelSlashCtx): Promise<
287
288
  if (st && !st.ready) console.log(` ! ${provider} is not ready (${st.label}) — set ${st.envVar ?? "the provider key"} or run 'jeo setup'.`);
288
289
  // ChatGPT OAuth only serves the Codex models; warn before the turn fails if the user
289
290
  // pins a non-Codex id with no local base URL to fall back to (gjc-parity readiness guard).
290
- if (arg && provider === "openai" && st?.kind === "oauth" && !CODEX_MODELS.includes(resolved)) {
291
+ // `isCodexModel` also accepts any id this session's OWN live discovery already
292
+ // confirmed (see model-catalog.ts) — a static-list-only check here would falsely
293
+ // warn about a model /model's own live picker just showed as valid.
294
+ if (arg && provider === "openai" && st?.kind === "oauth" && !isCodexModel(resolved)) {
291
295
  const hasLocalBase = !!((await readGlobalConfig()).openaiBaseUrl || process.env.OPENAI_BASE_URL);
292
296
  if (!hasLocalBase) {
293
297
  console.log(` ! ChatGPT OAuth serves only Codex models (${CODEX_MODELS.join(", ")}); '${resolved}' will be rejected at runtime — pick one of those, or set OPENAI_API_KEY / OPENAI_BASE_URL.`);
@@ -45,7 +45,7 @@ import { callLlm, type Message } from "../agent/loop";
45
45
  import { friendlyProviderError } from "../util/provider-error";
46
46
  import { readGlobalConfig, saveConfigPatch, resolveWikiRoot } from "../agent/state";
47
47
  import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
48
- import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig } from "../ai";
48
+ import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
49
49
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
50
50
  import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
51
51
  import { routePrompt, deriveCacheSessionKey, warnOnce, type RouteDecision } from "../agent/prompt-router";
@@ -926,6 +926,32 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
926
926
  `[route] routed to '${routed.model}' but the stored ${routedProvider} OAuth login cannot serve that model — falling back to the default model this turn. Set ${routedProvider.toUpperCase()}_API_KEY or reconfigure routing.tiers/roles to a model your login serves.`,
927
927
  );
928
928
  routed = null;
929
+ } else if (routedProvider === "ollama" || routedProvider === "lmstudio") {
930
+ // Live reachability gate for LOCAL providers only: `describeProvider` reports
931
+ // ollama/lmstudio as `ready: true` unconditionally (keyless just means "no
932
+ // credential needed", not "the server is actually up"), so a routing.tiers/
933
+ // roles pin to a downed local server previously sailed past every readiness
934
+ // check above and only failed mid-turn with a raw, provider-less connection
935
+ // error ("Unable to connect. Is the computer able to access the url?" —
936
+ // Bun's fetch/undici error for a refused connection AND an unresolvable
937
+ // host alike). A short-timeout live probe here keeps routing's fail-open
938
+ // contract intact for the one credential class it couldn't previously see
939
+ // through. Cloud providers never reach this branch — their credential/
940
+ // servability checks above are sufficient and adding a live probe there
941
+ // would just add latency for a class of failure `withRetry` already
942
+ // recovers from at call time.
943
+ const localBaseUrl = routedProvider === "ollama"
944
+ ? (turnConfig.ollamaBaseUrl ?? "http://localhost:11434")
945
+ : (turnConfig.lmstudioBaseUrl ?? "http://localhost:1234/v1");
946
+ const reachable = await isLocalProviderReachable(routedProvider, localBaseUrl);
947
+ if (!reachable) {
948
+ routeVetoNote = `routed model '${routed.model}' (${routedProvider}) is unreachable at ${localBaseUrl} this turn — fell back to '${sessionModel || defaultModel}'`;
949
+ routeCredentialNotice = warnOnce(
950
+ `prompt-router:provider-unreachable:${routedProvider}`,
951
+ `[route] routed to '${routed.model}' (${routedProvider}) but ${localBaseUrl} is unreachable — falling back to the default model this turn. Start the ${routedProvider} server, or reconfigure routing.tiers/roles for a provider that is running.`,
952
+ );
953
+ routed = null;
954
+ }
929
955
  }
930
956
  }
931
957
  lastRouteDecision = routed ?? (routeVetoNote ? { note: routeVetoNote } : routingEnabled && !sessionModel ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
@@ -188,6 +188,7 @@ export function skillsPromptSection(skills: SkillDoc[] = SKILLS): string {
188
188
  }
189
189
 
190
190
  import * as fs from "node:fs/promises";
191
+ import type { Dirent } from "node:fs";
191
192
  import * as path from "node:path";
192
193
  import * as os from "node:os";
193
194
 
@@ -411,6 +412,54 @@ function isSupportedExternalSkill(doc: SkillDoc): boolean {
411
412
  return !RESERVED_SKILL_NAMES.has(nameLower);
412
413
  }
413
414
 
415
+ /** Resolve ONE directory entry from {@link skillDirs} to its `SkillDoc`, or `null`
416
+ * when the entry is not a skill (dotfile, non-`.md` file, directory with no
417
+ * `SKILL.md`, unreadable/broken symlink). Pure I/O + parse — no shared-state
418
+ * writes — so callers can run many of these concurrently. */
419
+ async function resolveSkillEntry(dir: string, entry: Dirent): Promise<SkillDoc | null> {
420
+ if (entry.name.startsWith(".")) return null;
421
+ let isFile = entry.isFile();
422
+ let isDir = entry.isDirectory();
423
+ // Vercel `npx skills add` links agent dirs (.claude/skills/x) to its canonical
424
+ // store — a symlinked skill reports NEITHER isFile nor isDirectory on the
425
+ // Dirent, which silently dropped every linked skill. Follow the link.
426
+ if (entry.isSymbolicLink()) {
427
+ try {
428
+ const st = await fs.stat(path.join(dir, entry.name));
429
+ isFile = st.isFile();
430
+ isDir = st.isDirectory();
431
+ } catch { return null; /* broken symlink */ }
432
+ }
433
+ if (isFile && entry.name.endsWith(".md")) {
434
+ const nm = entry.name.slice(0, -3);
435
+ const filePath = path.join(dir, entry.name);
436
+ try {
437
+ const parsed = parseSkillMarkdown(nm, await fs.readFile(filePath, "utf-8"), { preferMetaName: true });
438
+ parsed.sourcePath = filePath;
439
+ return isSupportedExternalSkill(parsed) ? parsed : null;
440
+ } catch { return null; /* unreadable file */ }
441
+ }
442
+ if (isDir) {
443
+ const skillPath = path.join(dir, entry.name, "SKILL.md");
444
+ try {
445
+ const parsed = parseSkillMarkdown(entry.name, await fs.readFile(skillPath, "utf-8"), { preferMetaName: true });
446
+ parsed.sourcePath = skillPath;
447
+ return isSupportedExternalSkill(parsed) ? parsed : null;
448
+ } catch { return null; /* dir without SKILL.md, or unreadable */ }
449
+ }
450
+ return null;
451
+ }
452
+
453
+ /** Concurrency cap for {@link resolveSkillEntry} within one directory: high enough
454
+ * that a large skill directory (hundreds of entries — a real field case: `npx skills
455
+ * add`-style installs commonly accumulate 500+) resolves in one or two batches
456
+ * instead of paying per-file `fs` round-trip latency SEQUENTIALLY (534 entries at
457
+ * ~0.4ms/syscall was a measured ~220ms — a visible stall on every session start and
458
+ * every `/skill` listing), while staying well under the lowest realistic per-process
459
+ * file-descriptor ulimit (macOS defaults to 256) even with several directories'
460
+ * batches racing across the `skillDirs` loop below. */
461
+ const SKILL_ENTRY_LOAD_BATCH = 32;
462
+
414
463
  /** Bundled skills merged with user skill docs from {@link skillDirs} (user overrides by name). */
415
464
  export async function loadSkills(cwd: string = process.cwd()): Promise<SkillDoc[]> {
416
465
  try {
@@ -424,39 +473,20 @@ export async function loadSkills(cwd: string = process.cwd()): Promise<SkillDoc[
424
473
  }
425
474
  } catch {}
426
475
  const byName = new Map<string, SkillDoc>(SKILLS.map(s => [s.name.toLowerCase(), s]));
476
+ // Directories are walked in their documented lowest→highest precedence order
477
+ // (a later dir's skill overrides an earlier one with the same name) — that
478
+ // cross-directory override MUST stay sequential. Entries WITHIN one directory
479
+ // have no such contract (readdir order is not a precedence guarantee anyone
480
+ // depends on), so they resolve concurrently in bounded batches, then apply to
481
+ // `byName` in their original readdir order for a fully deterministic result.
427
482
  for (const dir of skillDirs(cwd)) {
428
- let entries: import("node:fs").Dirent[] = [];
483
+ let entries: Dirent[] = [];
429
484
  try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { continue; }
430
- for (const entry of entries) {
431
- if (entry.name.startsWith(".")) continue;
432
- let isFile = entry.isFile();
433
- let isDir = entry.isDirectory();
434
- // Vercel `npx skills add` links agent dirs (.claude/skills/x) to its canonical
435
- // store — a symlinked skill reports NEITHER isFile nor isDirectory on the
436
- // Dirent, which silently dropped every linked skill. Follow the link.
437
- if (entry.isSymbolicLink()) {
438
- try {
439
- const st = await fs.stat(path.join(dir, entry.name));
440
- isFile = st.isFile();
441
- isDir = st.isDirectory();
442
- } catch { continue; /* broken symlink */ }
443
- }
444
- if (isFile && entry.name.endsWith(".md")) {
445
- const nm = entry.name.slice(0, -3);
446
- try {
447
- const parsed = parseSkillMarkdown(nm, await fs.readFile(path.join(dir, entry.name), "utf-8"), { preferMetaName: true });
448
- parsed.sourcePath = path.join(dir, entry.name);
449
- if (isSupportedExternalSkill(parsed)) byName.set(parsed.name.toLowerCase(), parsed);
450
- } catch { /* skip unreadable file */ }
451
- continue;
452
- }
453
- if (isDir) {
454
- const skillPath = path.join(dir, entry.name, "SKILL.md");
455
- try {
456
- const parsed = parseSkillMarkdown(entry.name, await fs.readFile(skillPath, "utf-8"), { preferMetaName: true });
457
- parsed.sourcePath = skillPath;
458
- if (isSupportedExternalSkill(parsed)) byName.set(parsed.name.toLowerCase(), parsed);
459
- } catch { /* skip dirs without SKILL.md or unreadable files */ }
485
+ for (let i = 0; i < entries.length; i += SKILL_ENTRY_LOAD_BATCH) {
486
+ const batch = entries.slice(i, i + SKILL_ENTRY_LOAD_BATCH);
487
+ const resolved = await Promise.all(batch.map(entry => resolveSkillEntry(dir, entry)));
488
+ for (const parsed of resolved) {
489
+ if (parsed) byName.set(parsed.name.toLowerCase(), parsed);
460
490
  }
461
491
  }
462
492
  }
@@ -4,7 +4,8 @@
4
4
  * (carrying `.status` and a body), and the agent loop surfaces failures both as a
5
5
  * thrown error and as a `doneReason`, so this lives in a shared util used by both.
6
6
  */
7
- import { isUsageLimitError, isRefusalError } from "./retry";
7
+ import { isUsageLimitError, isRefusalError, isConnectionError, ConnectionContextError } from "./retry";
8
+ import { companyLabel } from "../ai/model-catalog";
8
9
  // Re-export: engine.ts and callers import the refusal predicate from here; the
9
10
  // definition lives in retry.ts so defaultRetryable can fail fast without a cycle.
10
11
  export { isRefusalError } from "./retry";
@@ -32,6 +33,18 @@ function formatDuration(ms: number): string {
32
33
  }
33
34
 
34
35
  export function friendlyProviderError(err: unknown): string {
36
+ if (err instanceof ConnectionContextError) {
37
+ const provider = companyLabel(err.provider);
38
+ const target = err.baseUrl ? ` at ${err.baseUrl}` : "";
39
+ const isLocal = err.provider === "ollama" || err.provider === "lmstudio";
40
+ const hint = isLocal
41
+ ? ` Start the ${provider} server (or check it's still running), verify the base URL in 'jeo setup' / config.json ('${err.provider}BaseUrl'), or switch model with /model.`
42
+ : ` Check the configured base URL, your network connection, or switch model with /model.`;
43
+ return `Could not connect to ${provider}${target}.${hint}`;
44
+ }
45
+ if (isConnectionError(err)) {
46
+ return `Could not connect to the provider. Check the configured base URL, your network connection, or switch model with /model.`;
47
+ }
35
48
  const msg = (err as Error)?.message ?? String(err);
36
49
  const status = (err as { status?: number })?.status;
37
50
  const provider = /antigravity/i.test(msg)
package/src/util/retry.ts CHANGED
@@ -340,6 +340,34 @@ export function isConnectionError(err: unknown): boolean {
340
340
  return /unable to connect\. is the computer able to access the url\?/i.test(errorMessageOf(err));
341
341
  }
342
342
 
343
+ /** A raw `isConnectionError` failure re-thrown with the provider/URL context the bare
344
+ * fetch error never carries (see `isConnectionError`'s docs). `.cause` preserves the
345
+ * original error so `isConnectionError`/`defaultRetryable` still recognize it (both
346
+ * inspect `.code` and the message text, which this class forwards). */
347
+ export class ConnectionContextError extends Error {
348
+ readonly code: string | undefined;
349
+ constructor(readonly provider: string, readonly baseUrl: string | undefined, cause: unknown) {
350
+ const causeCode = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : undefined;
351
+ super(errorMessageOf(cause), { cause });
352
+ this.name = "ConnectionContextError";
353
+ this.code = causeCode;
354
+ }
355
+ }
356
+
357
+ /** Run `fn`; a raw `isConnectionError` failure is re-thrown as a `ConnectionContextError`
358
+ * naming `provider`/`baseUrl` so `friendlyProviderError` can turn Bun's context-free
359
+ * "Unable to connect. Is the computer able to access the url?" into an actionable
360
+ * message. Every other error (including a non-connection failure) passes through
361
+ * unchanged. */
362
+ export async function withConnectionContext<T>(fn: () => Promise<T>, provider: string, baseUrl: string | undefined): Promise<T> {
363
+ try {
364
+ return await fn();
365
+ } catch (err) {
366
+ if (isConnectionError(err)) throw new ConnectionContextError(provider, baseUrl, err);
367
+ throw err;
368
+ }
369
+ }
370
+
343
371
  /** True for an in-band provider stream failure: the HTTP response itself was 200 (a
344
372
  * live SSE connection), but the provider emitted a terminal error EVENT instead of
345
373
  * completing (OpenAI Responses `response.failed`/`error`: