jeo-code 0.8.14 → 0.8.16
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 +31 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +7 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/prompt-router.ts +75 -26
- package/src/ai/model-catalog.ts +116 -0
- package/src/ai/model-discovery.ts +5 -4
- package/src/commands/computer.ts +11 -5
- package/src/commands/launch/computer-slash.ts +61 -0
- package/src/commands/launch.ts +169 -13
- package/src/tui/app.ts +10 -1
- package/src/tui/components/live-model-picker.ts +6 -3
- package/src/tui/components/slash.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,37 @@ 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.16] - 2026-07-09
|
|
10
|
+
_"jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`/computer [status|on|off]` slash command** (`src/commands/launch/computer-slash.ts`, `src/commands/launch.ts`, `src/tui/components/slash.ts`) — mirrors `/route`'s session-local-override pattern. `executeComputerAction` (`src/commands/computer.ts`) now accepts an `opts.enabledOverride` that wins over `config.computer.enabled` when set, and forwards through batched sub-actions. `/computer on|off` sets a REPL-local override for the current session only (never persisted); `/computer status` reports the effective on/off state and reminds the caller that the fail-closed `computerSupervisor` kill-switch/heartbeat gate still applies independently to every non-read-only action.
|
|
14
|
+
- **New mascot artwork** (`assets/character-v2.png`) — a second character illustration (god-tibo-imagen / `gti`, same crimson-shell/cyan-magenta-glasses/purple-DNA-robe identity as `character.png`) depicting the mascot piloting the desktop-automation control panel while juggling provider-routing nodes, tying the two headline features (prompt routing, computer use) into one image.
|
|
15
|
+
|
|
16
|
+
### Verified
|
|
17
|
+
- `bun run typecheck` — no errors.
|
|
18
|
+
- `bun test test/computer-slash.test.ts test/computer.test.ts test/computer-supervisor.test.ts test/engine-computer-wiring.test.ts` — 25 pass / 0 fail (new `/computer` subcommand coverage + `enabledOverride` true/false end-to-end execution cases).
|
|
19
|
+
- `bun test test/slash.test.ts` — 30 pass / 0 fail (palette prefix-match fixture updated for the new `/computer` entry).
|
|
20
|
+
- Full `bun test` — 2843 pass / 1 pre-existing unrelated flake (`reachability veto: ...unreachable ollama model...`), 0 fail introduced by this change.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## [0.8.15] - 2026-07-09
|
|
24
|
+
_"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._
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **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.
|
|
28
|
+
- **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.
|
|
29
|
+
- **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.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
- **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.
|
|
33
|
+
|
|
34
|
+
### Verified
|
|
35
|
+
- 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.
|
|
36
|
+
- Typecheck: `bun run typecheck` — pass.
|
|
37
|
+
- Full suite: `bun test` — 2814 pass / 0 fail.
|
|
38
|
+
- Package tarball smoke: `npm pack --dry-run` — produced `jeo-code-0.8.15.tgz`.
|
|
39
|
+
|
|
9
40
|
## [0.8.14] - 2026-07-09
|
|
10
41
|
_"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._
|
|
11
42
|
|
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.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
|
|
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.
|
|
265
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
|
-
- **[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
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.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
|
|
267
|
+
- **[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
268
|
- **[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.
|
|
267
269
|
- **[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.
|
|
268
270
|
- **[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.
|
|
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).
|
|
271
271
|
|
|
272
272
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
273
273
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
<p align="center">
|
|
19
19
|
<img src="assets/character.gif" alt="animated jeo-code red crayfish mascot smart-routing a prompt to the cheapest provider and saving coins" width="320" />
|
|
20
|
+
<img src="assets/character-v2.png" alt="jeo-code red crayfish mascot piloting the computer-use desktop-automation control panel while juggling prompt-routing provider nodes" width="320" />
|
|
20
21
|
</p>
|
|
21
22
|
|
|
23
|
+
|
|
22
24
|
<p align="center">
|
|
23
25
|
<b>English</b> ·
|
|
24
26
|
<a href="README.ko.md">한국어</a> ·
|
|
@@ -38,7 +40,10 @@ Run `jeo` inside a repository and it reads files, edits them, runs commands, and
|
|
|
38
40
|
|
|
39
41
|
## Highlights
|
|
40
42
|
|
|
43
|
+
- **Prompt routing (cost-aware, credential-aware)** — every turn can auto-route to a tier-appropriate model among only the providers your configured credentials actually serve (`/route [status|on|off|why]`), with a live equivalent-model fallback whenever a routed provider is rate-limited, unauthenticated, unreachable, or silently times out — see `/route why` for the last decision.
|
|
44
|
+
- **Computer use (desktop automation)** — a fail-closed `computer` tool (screenshot/click/type/scroll/drag/batch) gated by both a config flag and an independent kill-switch/heartbeat supervisor; toggle it for the current session with `/computer [status|on|off]` without touching `~/.jeo/config.json`.
|
|
41
45
|
- **Multi-provider, one loop** — Anthropic / OpenAI (+Codex) / Gemini / Antigravity / Ollama / LM Studio, plus 20+ OpenAI- and Anthropic-compatible clouds (Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai, …), all behind one uniform JSON tool loop. OAuth login happens from the input box (`/provider login`), every model pick persists as the new default, and prompt routing only auto-selects usable credentialed paths: Gemini OAuth goes through the provider-qualified `antigravity/*` agent set (Gemini 3.5 Flash tiers, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6), never public `google/gemini-*` rows that require `GEMINI_API_KEY`; if a configured route points at an unready provider, jeo switches to an equivalent credentialed tier model before falling back to the default.
|
|
46
|
+
|
|
42
47
|
- **Edit integrity** — read output carries content anchors (`42ab|`); anchored edits are verified against the current file, re-mapped when lines shifted, and rejected with fresh content instead of corrupting.
|
|
43
48
|
- **Self-correcting verification loop** — configure a post-edit hook (tsc / eslint / tests) and the agent *sees* the diagnostics and fixes them in-loop; a red hook blocks `done` until resolved.
|
|
44
49
|
- **Real gates, no theater** — `ralplan` consensus is a repo-grounded critic subagent whose `[OKAY]` verdict is persisted and *required* by `jeo approve`; `ultragoal` reports honestly (a suite run is a global signal, never fabricated per-criterion passes).
|
|
@@ -265,11 +270,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
265
270
|
## Changelog
|
|
266
271
|
|
|
267
272
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
273
|
+
- **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
|
|
274
|
+
- **[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.
|
|
268
275
|
- **[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.
|
|
269
276
|
- **[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.
|
|
270
277
|
- **[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.
|
|
271
|
-
- **[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.
|
|
272
|
-
- **[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).
|
|
273
278
|
|
|
274
279
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
275
280
|
<!-- 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.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
|
|
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.
|
|
265
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
|
-
- **[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
270
|
|
|
271
271
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
272
272
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -49,16 +49,17 @@
|
|
|
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
|
|
|
60
59
|
export type PromptTier = "trivial" | "standard" | "high" | "complex";
|
|
61
60
|
|
|
61
|
+
export const PROMPT_TIERS = ["trivial", "standard", "high", "complex"] as const satisfies readonly PromptTier[];
|
|
62
|
+
|
|
62
63
|
export interface HeuristicResult {
|
|
63
64
|
tier: PromptTier;
|
|
64
65
|
confidence: number; // 0..1
|
|
@@ -90,24 +91,17 @@ export interface RoutePromptOptions {
|
|
|
90
91
|
// --- Frozen, bilingual-validated classification signals (do not re-derive). ---
|
|
91
92
|
|
|
92
93
|
const RE_CAUSAL = /\b(why|how|root cause|how come)\b/i;
|
|
93
|
-
const RE_CAUSAL_KR = /(왜\s|근본\s
|
|
94
|
+
const RE_CAUSAL_KR = /(왜\s|근본\s*원인|이유가|어떻게|이유|어째서|원인|해결책|해결방안)/;
|
|
94
95
|
const RE_TRIVIAL_QUESTION_WORD = /\b(what|who|when|where|which)\b/i;
|
|
95
|
-
const RE_TRIVIAL_QUESTION_WORD_KR = /(뭐|뭔가요|무엇|언제|어디|누구|몇\s
|
|
96
|
+
const RE_TRIVIAL_QUESTION_WORD_KR = /(뭐|뭔가요|무엇|언제|어디|누구|몇\s|인가요|있나요|냐|음\?|인가)/;
|
|
96
97
|
const RE_CODE_FENCE = /```/;
|
|
97
|
-
const
|
|
98
|
+
const RE_CODE_SNIPPET = /(?:\b(?:const|let|var)\s+\w+\s*=|\bimport\s+.*?\s+from\s+['"`\.\/]|\bexport\s+(?:const|let|var|function|class|default)\b|\b(?:function|class)\s+\w+|if\s*\(|try\s*{|=>|\{\s*[\w\d_]+\s*\})/;
|
|
99
|
+
const RE_PATH_TOKEN = /(?:^|[\s"'`(])@?(?:\.{0,2}[\w-]*[\/\\])+[\w-]+(?:\.[\w-]+)*(?:\.[A-Za-z]\w{0,7}|[\/\\])\b/g;
|
|
100
|
+
const RE_CLI_COMMAND = /(?:^|\s)(git|npm|bun|docker-compose|pip|cargo)\s+[a-z-_]+/i;
|
|
98
101
|
const RE_DEEP_KEYWORDS_EN = /\b(design|architecture|refactor|debug|deep\s*dive|investigate|diagnose|redesign)\b/i;
|
|
99
102
|
const RE_DEEP_KEYWORDS_KR = /(설계|아키텍처|리팩터|디버그|딥\s*다이브|딥다이브|진단)/;
|
|
100
|
-
const RE_SENTENCE_BOUNDARY = /[.!?][ \n]|[
|
|
103
|
+
const RE_SENTENCE_BOUNDARY = /[.!?][ \n]|[.!?.]$/g;
|
|
101
104
|
|
|
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
105
|
export function classifyPromptHeuristically(prompt: string): HeuristicResult {
|
|
112
106
|
const trimmed = prompt.trim();
|
|
113
107
|
const signals: string[] = [];
|
|
@@ -126,14 +120,22 @@ export function classifyPromptHeuristically(prompt: string): HeuristicResult {
|
|
|
126
120
|
signals.push("short-question");
|
|
127
121
|
}
|
|
128
122
|
|
|
129
|
-
// trivial signal 2: no code fence AND no file path mention.
|
|
123
|
+
// trivial signal 2: no code fence AND no code snippet AND no file path mention.
|
|
130
124
|
const hasCodeFence = RE_CODE_FENCE.test(trimmed);
|
|
125
|
+
const hasCodeSnippet = RE_CODE_SNIPPET.test(trimmed);
|
|
126
|
+
const hasCliCommand = RE_CLI_COMMAND.test(trimmed);
|
|
131
127
|
const pathMatches = [...trimmed.matchAll(RE_PATH_TOKEN)];
|
|
132
|
-
if (!hasCodeFence && pathMatches.length === 0) {
|
|
128
|
+
if (!hasCodeFence && !hasCodeSnippet && pathMatches.length === 0) {
|
|
133
129
|
trivialScore++;
|
|
134
130
|
signals.push("no-code-no-path");
|
|
135
131
|
}
|
|
136
132
|
|
|
133
|
+
// trivial signal 3: explicit CLI command (usually simple lookup/execution)
|
|
134
|
+
if (hasCliCommand) {
|
|
135
|
+
trivialScore++;
|
|
136
|
+
signals.push("cli-command");
|
|
137
|
+
}
|
|
138
|
+
|
|
137
139
|
// complex signal 1: deep-work keyword.
|
|
138
140
|
if (RE_DEEP_KEYWORDS_EN.test(trimmed) || RE_DEEP_KEYWORDS_KR.test(trimmed)) {
|
|
139
141
|
complexScore++;
|
|
@@ -160,7 +162,6 @@ export function classifyPromptHeuristically(prompt: string): HeuristicResult {
|
|
|
160
162
|
complexScore++;
|
|
161
163
|
signals.push("multi-file");
|
|
162
164
|
}
|
|
163
|
-
|
|
164
165
|
// Tier + confidence resolution (frozen thresholds).
|
|
165
166
|
if (trivialScore > 0 && complexScore > 0) {
|
|
166
167
|
return { tier: "standard", confidence: 0.35, signals }; // conflicting signals -> let escalation decide
|
|
@@ -202,7 +203,7 @@ export function resetPromptRouterWarnings(): void {
|
|
|
202
203
|
* `undefined`. Production callers (`readGlobalConfig()`'s real `Config`) always
|
|
203
204
|
* provide both. */
|
|
204
205
|
export type RoutingConfig = Pick<Config, "defaultModel" | "roles" | "routing"> &
|
|
205
|
-
Partial<Pick<Config, "providers" | "oauth">>;
|
|
206
|
+
Partial<Pick<Config, "providers" | "oauth" | "openaiBaseUrl">>;
|
|
206
207
|
|
|
207
208
|
/** Cloud providers eligible for cross-provider auto-select (excludes local
|
|
208
209
|
* ollama/lmstudio, which are never a sensible "route to the cheapest/strongest"
|
|
@@ -221,10 +222,19 @@ function hasAntigravityOauth(config: RoutingConfig): boolean {
|
|
|
221
222
|
return !!(config.oauth?.antigravity || config.oauth?.gemini);
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
function routingCatalog(config: RoutingConfig): CatalogModel[] {
|
|
226
|
+
const rows = [...MODEL_CATALOG, ...liveProviderCatalogModels(config)];
|
|
227
|
+
const eligible = rows.filter(m => isAutoSelectCandidate(m, config));
|
|
228
|
+
const byId = new Map<string, CatalogModel>();
|
|
229
|
+
for (const row of eligible) if (!byId.has(row.canonical)) byId.set(row.canonical, row);
|
|
230
|
+
return [...byId.values()];
|
|
231
|
+
}
|
|
232
|
+
|
|
224
233
|
function isAutoSelectCandidate(m: CatalogModel, config: RoutingConfig): boolean {
|
|
225
234
|
// Shared model-LEVEL credential gate (the full auth matrix is documented on
|
|
226
235
|
// modelServableWithConfig; keep aligned with resolveCall).
|
|
227
236
|
if (!isCloudProvider(m.provider) || m.limitedAvailability || !modelServableWithConfig(m.provider, m.canonical, config)) return false;
|
|
237
|
+
if (m.provider === "openai" && config.openaiBaseUrl && !isLiveProviderModel("openai", m.canonical, config)) return false;
|
|
228
238
|
if (hasAntigravityOauth(config) && m.provider === "gemini") return false;
|
|
229
239
|
return true;
|
|
230
240
|
}
|
|
@@ -253,6 +263,24 @@ function sizeClassFor(model: CatalogModel): "small" | "mid" | "large" | null {
|
|
|
253
263
|
if (segments.includes("sonnet") || segments.includes("pro")) return "mid";
|
|
254
264
|
return null;
|
|
255
265
|
}
|
|
266
|
+
const SIZE_CLASS_TO_TIER: Record<"small" | "mid" | "large", PromptTier> = { small: "trivial", mid: "standard", large: "complex" };
|
|
267
|
+
|
|
268
|
+
/** Infers the `PromptTier` whose equivalence pool matches `model`'s size class — lets a
|
|
269
|
+
* NON-ROUTED turn (routing disabled, or the user pinned a model via `/model`) reuse the
|
|
270
|
+
* same cross-provider equivalent-pool fallback (`tierModelPool`/`equivalentRouteFallback`
|
|
271
|
+
* in launch.ts) that a routed turn already gets on a usage-limit/rate-limit/credential
|
|
272
|
+
* failure — without silently downgrading a pinned flagship (e.g. opus) to a cheap
|
|
273
|
+
* trivial-tier substitute, or promoting a pinned cheap model to an expensive one. `null`
|
|
274
|
+
* sizeClass (no size-tier suffix — gpt-5.5/o3/grok-4.3/kimi-* / glm-* / …) and an
|
|
275
|
+
* uncatalogued model id both default to "standard" (the mid pool) as a reasonable
|
|
276
|
+
* middle ground rather than guessing complex/trivial. */
|
|
277
|
+
export function inferTierForModel(model: string): PromptTier {
|
|
278
|
+
const meta = catalogMetadata(model);
|
|
279
|
+
if (!meta) return "standard";
|
|
280
|
+
const cls = sizeClassFor(meta);
|
|
281
|
+
return cls ? SIZE_CLASS_TO_TIER[cls] : "standard";
|
|
282
|
+
}
|
|
283
|
+
|
|
256
284
|
|
|
257
285
|
const TIER_TO_SIZE_CLASS: Record<PromptTier, "small" | "mid" | "large"> = { trivial: "small", standard: "mid", high: "mid", complex: "large" };
|
|
258
286
|
|
|
@@ -280,7 +308,7 @@ function compareStrengthAscending(a: CatalogModel, b: CatalogModel): number {
|
|
|
280
308
|
* for deterministic pool ordering (required by `selectFromPool`'s index math). */
|
|
281
309
|
export function tierModelPool(tier: PromptTier, config: RoutingConfig): string[] {
|
|
282
310
|
const targetClass = TIER_TO_SIZE_CLASS[tier];
|
|
283
|
-
const credentialed =
|
|
311
|
+
const credentialed = routingCatalog(config);
|
|
284
312
|
|
|
285
313
|
const suffixPool = credentialed.filter(m => sizeClassFor(m) === targetClass);
|
|
286
314
|
|
|
@@ -329,7 +357,7 @@ export function selectFromPool(pool: readonly string[], sessionId: string | unde
|
|
|
329
357
|
export function cheapestCredentialed(config: RoutingConfig): string | null {
|
|
330
358
|
let best: CatalogModel | null = null;
|
|
331
359
|
let bestCost = Infinity;
|
|
332
|
-
for (const m of
|
|
360
|
+
for (const m of routingCatalog(config)) {
|
|
333
361
|
// Candidate eligibility is centralized in `isAutoSelectCandidate`: it keeps
|
|
334
362
|
// OAuth-backed Antigravity routing on provider-qualified 3.1+ Gemini rows and
|
|
335
363
|
// prevents public `gemini` catalog rows from winning when Antigravity OAuth is
|
|
@@ -377,7 +405,7 @@ export function strongestCredentialed(
|
|
|
377
405
|
filter?: (m: CatalogModel) => boolean
|
|
378
406
|
): string | null {
|
|
379
407
|
let best: CatalogModel | null = null;
|
|
380
|
-
for (const m of
|
|
408
|
+
for (const m of routingCatalog(config)) {
|
|
381
409
|
// Candidate eligibility is centralized in `isAutoSelectCandidate` (see
|
|
382
410
|
// cheapestCredentialed above for the Antigravity/Gemini OAuth rationale).
|
|
383
411
|
if (!isAutoSelectCandidate(m, config)) continue;
|
|
@@ -406,8 +434,8 @@ export function strongestCredentialed(
|
|
|
406
434
|
* mid-class-suffixed model is credentialed (caller falls through to
|
|
407
435
|
* `roles.medium`/`defaultModel` — never to `complex`'s flagship pick). */
|
|
408
436
|
function strongestMidTierCredentialed(config: RoutingConfig): string | null {
|
|
409
|
-
const midClass =
|
|
410
|
-
m =>
|
|
437
|
+
const midClass = routingCatalog(config).filter(
|
|
438
|
+
m => sizeClassFor(m) === "mid",
|
|
411
439
|
);
|
|
412
440
|
if (midClass.length === 0) return null;
|
|
413
441
|
return midClass.sort(compareStrengthAscending).at(-1)!.canonical;
|
|
@@ -447,9 +475,30 @@ function resolveTierThinking(tier: PromptTier, config: RoutingConfig): ThinkLeve
|
|
|
447
475
|
return config.routing?.tiers?.[tier]?.thinking; // undefined = no override, caller keeps session/global level
|
|
448
476
|
}
|
|
449
477
|
|
|
450
|
-
|
|
478
|
+
/** Apply one interactive `/model` pick to a PromptRouter tier without touching the
|
|
479
|
+
* global model registry. The write enables routing because a configured tier is
|
|
480
|
+
* otherwise invisible until `routing.enabled` is set. */
|
|
481
|
+
export function withRoutingTierSetting(
|
|
482
|
+
config: Pick<Config, "routing">,
|
|
483
|
+
tier: PromptTier,
|
|
484
|
+
patch: { model: string; thinking?: ThinkLevel },
|
|
485
|
+
): NonNullable<Config["routing"]> {
|
|
486
|
+
return {
|
|
487
|
+
...(config.routing ?? {}),
|
|
488
|
+
enabled: true,
|
|
489
|
+
tiers: {
|
|
490
|
+
...(config.routing?.tiers ?? {}),
|
|
491
|
+
[tier]: {
|
|
492
|
+
...(config.routing?.tiers?.[tier] ?? {}),
|
|
493
|
+
model: patch.model,
|
|
494
|
+
thinking: patch.thinking,
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
451
500
|
function isPromptTier(value: unknown): value is PromptTier {
|
|
452
|
-
return typeof value === "string" && (
|
|
501
|
+
return typeof value === "string" && (PROMPT_TIERS as readonly string[]).includes(value);
|
|
453
502
|
}
|
|
454
503
|
|
|
455
504
|
/** Hard cap on how much of the prompt is embedded in the escalation classifier call —
|
package/src/ai/model-catalog.ts
CHANGED
|
@@ -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
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
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) {
|
package/src/commands/computer.ts
CHANGED
|
@@ -53,16 +53,21 @@ async function runCommand(cmd: string[]): Promise<{ success: boolean; stdout: st
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export async function executeComputerAction(action: ComputerAction): Promise<{ success: boolean; output: string; error?: string }> {
|
|
57
|
-
|
|
58
|
-
if (
|
|
56
|
+
export async function executeComputerAction(action: ComputerAction, opts?: { enabledOverride?: boolean }): Promise<{ success: boolean; output: string; error?: string }> {
|
|
57
|
+
let enabled = opts?.enabledOverride;
|
|
58
|
+
if (enabled === undefined) {
|
|
59
|
+
const config = await readGlobalConfig().catch(() => null);
|
|
60
|
+
enabled = !!config?.computer?.enabled;
|
|
61
|
+
}
|
|
62
|
+
if (!enabled) {
|
|
59
63
|
return {
|
|
60
64
|
success: false,
|
|
61
65
|
output: "",
|
|
62
|
-
error: "Computer use is disabled. Enable it by setting 'computer.enabled: true' in ~/.jeo/config.json",
|
|
66
|
+
error: "Computer use is disabled. Enable it by running '/computer on' this session, or setting 'computer.enabled: true' in ~/.jeo/config.json",
|
|
63
67
|
};
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
|
|
66
71
|
// Read-only actions don't need supervisor inputAllowed check
|
|
67
72
|
const isReadOnly = action.action === "screenshot" || action.action === "wait";
|
|
68
73
|
if (!isReadOnly) {
|
|
@@ -247,7 +252,8 @@ export async function executeComputerAction(action: ComputerAction): Promise<{ s
|
|
|
247
252
|
const results: string[] = [];
|
|
248
253
|
for (let i = 0; i < action.actions.length; i++) {
|
|
249
254
|
const subAction = action.actions[i];
|
|
250
|
-
const res = await executeComputerAction(subAction);
|
|
255
|
+
const res = await executeComputerAction(subAction, opts);
|
|
256
|
+
|
|
251
257
|
if (!res.success) {
|
|
252
258
|
const err = `Batch failed at step ${i}: ${res.error}`;
|
|
253
259
|
await logAudit(action, false, err);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/computer` slash-command handler, mirroring `/route`'s extraction pattern
|
|
3
|
+
* (see `route-slash.ts`). Handles `/computer [status|on|off]` — toggles a
|
|
4
|
+
* SESSION-LOCAL override of `config.computer.enabled` (the safety-gated
|
|
5
|
+
* desktop-automation tool executed by `executeComputerAction` in
|
|
6
|
+
* `../computer.ts`), without requiring the user to hand-edit
|
|
7
|
+
* `~/.jeo/config.json`.
|
|
8
|
+
*
|
|
9
|
+
* Session-level toggle only — `sessionComputerOverride` is never persisted
|
|
10
|
+
* (mirrors `/route`'s `sessionRouteOverride`). Turning it on for a session
|
|
11
|
+
* does NOT relax the fail-closed `computerSupervisor` kill-switch/heartbeat
|
|
12
|
+
* gate inside `executeComputerAction` — it only satisfies the separate
|
|
13
|
+
* `config.computer.enabled` config gate that sits in front of it. Both gates
|
|
14
|
+
* must pass for any non-read-only action to actually execute.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ComputerSlashCtx {
|
|
18
|
+
sessionComputerOverride: boolean | undefined;
|
|
19
|
+
/** `turnConfig.computer?.enabled ?? false`, read fresh by the caller. */
|
|
20
|
+
computerConfigEnabled: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ComputerSlashResult {
|
|
24
|
+
/** Present only when changed by "on"/"off". */
|
|
25
|
+
sessionComputerOverride?: boolean | undefined;
|
|
26
|
+
/** Lines to print. */
|
|
27
|
+
lines: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const USAGE = "Usage: /computer [status|on|off]";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Handle `/computer [status|on|off]`. Extracted for the same reason as
|
|
34
|
+
* `/route`: shares REPL-local computer-use state with `runTurn` via an
|
|
35
|
+
* explicit ctx/result object rather than closing over it.
|
|
36
|
+
*/
|
|
37
|
+
export function runComputerSlash(input: string, ctx: ComputerSlashCtx): ComputerSlashResult {
|
|
38
|
+
const rest = input.slice("/computer".length).trim();
|
|
39
|
+
const [sub] = rest.split(/\s+/).filter(Boolean);
|
|
40
|
+
const effective = ctx.sessionComputerOverride ?? ctx.computerConfigEnabled;
|
|
41
|
+
|
|
42
|
+
if (!sub || sub === "status") {
|
|
43
|
+
const lines = [`computer use: ${effective ? "on" : "off"} (this session)`];
|
|
44
|
+
if (!effective) {
|
|
45
|
+
lines.push("note: the 'computer' tool is fail-closed while off — run '/computer on' to enable it for this session, or set 'computer.enabled: true' in ~/.jeo/config.json to enable it by default.");
|
|
46
|
+
} else {
|
|
47
|
+
lines.push("note: the fail-closed kill-switch/heartbeat supervisor still gates every non-read-only action (screenshot/wait are exempt) independently of this toggle.");
|
|
48
|
+
}
|
|
49
|
+
return { lines };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (sub === "on") {
|
|
53
|
+
return { sessionComputerOverride: true, lines: ["computer use: on (this session)"] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (sub === "off") {
|
|
57
|
+
return { sessionComputerOverride: false, lines: ["computer use: off (this session)"] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { lines: [`Unknown /computer subcommand: ${sub}`, USAGE] };
|
|
61
|
+
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { createInterface } from "node:readline/promises";
|
|
|
2
2
|
import { emitKeypressEvents } from "node:readline";
|
|
3
3
|
import { PassThrough } from "node:stream";
|
|
4
4
|
import { runAgentLoop, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, type AgentLoopEvents } from "../agent/engine";
|
|
5
|
+
import { computerSupervisor } from "../agent/computer-supervisor";
|
|
6
|
+
import { executeComputerAction } from "./computer";
|
|
7
|
+
|
|
5
8
|
import { createOpikTracer, wrapEvents } from "../agent/opik-tracer";
|
|
6
9
|
import { initialDynamicStepLimit } from "../agent/step-budget";
|
|
7
10
|
import { memoryPromptSection, spawnDetachedDistill, recordFailedAttempt } from "../agent/memory";
|
|
@@ -48,8 +51,10 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
|
|
|
48
51
|
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
|
|
49
52
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
50
53
|
import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
|
|
51
|
-
import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, type RouteDecision } from "../agent/prompt-router";
|
|
52
|
-
|
|
54
|
+
import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, type PromptTier, type RouteDecision } from "../agent/prompt-router";
|
|
55
|
+
|
|
56
|
+
import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
|
|
57
|
+
|
|
53
58
|
|
|
54
59
|
import { listAliases } from "../ai/model-registry";
|
|
55
60
|
import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/openai-compatible-catalog";
|
|
@@ -206,6 +211,8 @@ import { handleUndoSlash } from "./launch/git-slash";
|
|
|
206
211
|
import { runSessionSlash } from "./launch/session-slash";
|
|
207
212
|
import { runModelSlash } from "./launch/model-slash";
|
|
208
213
|
import { runRouteSlash } from "./launch/route-slash";
|
|
214
|
+
import { runComputerSlash } from "./launch/computer-slash";
|
|
215
|
+
|
|
209
216
|
import { runAgentsSlash, runRolesSlash, runFastSlash, runThinkingSlash } from "./launch/agents-slash";
|
|
210
217
|
import { wikiRootPromptLine, decideWikiSlash } from "./launch/wiki-slash";
|
|
211
218
|
import { decideThemeSlash, buildConfigPanelLines, runEvolveSimulation } from "./launch/system-slash";
|
|
@@ -652,6 +659,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
652
659
|
let sessionRouteOverride: boolean | undefined;
|
|
653
660
|
// Last routing decision (or the reason none applied) — /route why reads this.
|
|
654
661
|
let lastRouteDecision: RouteDecision | { note: string } | null = null;
|
|
662
|
+
// Computer-use session override: undefined = follow config.computer.enabled, true/false = /computer on|off wins this session.
|
|
663
|
+
let sessionComputerOverride: boolean | undefined;
|
|
664
|
+
|
|
655
665
|
// Cache of live, credential-validated models per provider (refreshed by live pickers).
|
|
656
666
|
let liveModelsCache: ProviderModelsResult[] | null = null;
|
|
657
667
|
const getLiveModels = async (force = false): Promise<ProviderModelsResult[]> => {
|
|
@@ -887,9 +897,26 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
887
897
|
let routeVetoNote: string | undefined;
|
|
888
898
|
const equivalentRouteFallback = async (decision: RouteDecision, reason: string, exclude: readonly string[] = []): Promise<string | null> => {
|
|
889
899
|
const excluded = new Set([decision.model, ...exclude]);
|
|
890
|
-
const
|
|
891
|
-
.
|
|
892
|
-
|
|
900
|
+
const poolCandidates = (): string[] =>
|
|
901
|
+
tierModelPool(decision.tier, turnConfig)
|
|
902
|
+
.filter(model => !excluded.has(model))
|
|
903
|
+
.filter(model => !images?.length || catalogMetadata(model)?.images !== false);
|
|
904
|
+
let candidates = poolCandidates();
|
|
905
|
+
// The static MODEL_CATALOG only lists a couple of OpenAI-compatible clouds by
|
|
906
|
+
// name (tencent, xai) — every other API-key provider (groq, deepseek, mistral,
|
|
907
|
+
// openrouter, …) only enters `tierModelPool` once its `/models` endpoint has
|
|
908
|
+
// been discovered THIS session (`recordLiveProviderModels`, driven by
|
|
909
|
+
// `getLiveModels`). Interactive sessions warm that cache in the background at
|
|
910
|
+
// startup, but a ONE-SHOT invocation (`jeo -p`/piped input) never calls
|
|
911
|
+
// `getLiveModels()` at all — so a credentialed-but-undiscovered API provider
|
|
912
|
+
// had ZERO fallback candidates even though the user's key works. Warming here
|
|
913
|
+
// is cheap: `getLiveModels()` caches its result (`liveModelsCache`), so this
|
|
914
|
+
// only pays real discovery latency once per cold session, exactly when the
|
|
915
|
+
// primary model already failed and a fallback is genuinely needed.
|
|
916
|
+
if (candidates.length === 0) {
|
|
917
|
+
await getLiveModels().catch(() => []);
|
|
918
|
+
candidates = poolCandidates();
|
|
919
|
+
}
|
|
893
920
|
if (candidates.length === 0) return null;
|
|
894
921
|
const selected = selectFromPool(candidates, sessionId ? `${sessionId}:fallback:${decision.model}:${reason}` : undefined);
|
|
895
922
|
const start = Math.max(0, candidates.indexOf(selected));
|
|
@@ -919,9 +946,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
919
946
|
routeCredentialNotice = warnOnce(noticeKey, `${notice} Switching to equivalent '${fallback}' for this turn.`);
|
|
920
947
|
return { ...decision, model: fallback, warning: decision.warning ? `${decision.warning}; ${routeVetoNote}` : routeVetoNote };
|
|
921
948
|
};
|
|
922
|
-
|
|
949
|
+
// `sessionRouteOverride === true` means the user explicitly ran `/route on` —
|
|
950
|
+
// that explicit request wins over a prior model pin (`sessionModel`, from
|
|
951
|
+
// `--model`/`/model`) so routing actually evaluates every prompt as asked,
|
|
952
|
+
// instead of being silently blocked by a pin the user may have forgotten about.
|
|
953
|
+
// Routing enabled only via config (`routing.enabled: true`, no explicit /route
|
|
954
|
+
// toggle this session) still respects an existing pin, as before.
|
|
955
|
+
const routeOverridesPin = sessionRouteOverride === true;
|
|
956
|
+
let routed = routingEnabled && (routeOverridesPin || !sessionModel)
|
|
923
957
|
? await routePrompt(userInput, turnConfig, { hasImages: !!images?.length, sessionId }).catch(() => null)
|
|
924
958
|
: null;
|
|
959
|
+
|
|
925
960
|
// Fail-open credential gate: `routePrompt` only resolves a tier's model from
|
|
926
961
|
// config (routing.tiers/roles.smol/roles.slow/defaultModel) — it deliberately
|
|
927
962
|
// stays pure/config-only (see its narrowed `Pick<Config,...>` param) and never
|
|
@@ -989,8 +1024,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
989
1024
|
}
|
|
990
1025
|
}
|
|
991
1026
|
}
|
|
992
|
-
lastRouteDecision = routed ?? (routeVetoNote ? { note: routeVetoNote } : routingEnabled && !sessionModel ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
|
|
993
|
-
// Explicit /model always wins
|
|
1027
|
+
lastRouteDecision = routed ?? (routeVetoNote ? { note: routeVetoNote } : routingEnabled && (routeOverridesPin || !sessionModel) ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
|
|
1028
|
+
// Explicit /model always wins UNLESS an explicit `/route on` this session opted
|
|
1029
|
+
// back into per-prompt routing (`routeOverridesPin`); otherwise `routed` is only
|
|
1030
|
+
// ever computed when `!sessionModel`.
|
|
1031
|
+
|
|
994
1032
|
let activeModel = routed?.model ?? (sessionModel || defaultModel);
|
|
995
1033
|
let activeThinking = routed?.thinking ?? sessionThinking;
|
|
996
1034
|
// Provider-side prompt caches are keyed PER MODEL — reusing the bare `sessionId`
|
|
@@ -1296,6 +1334,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1296
1334
|
irc: createIrcTool(subagentRegistry),
|
|
1297
1335
|
goal: createGoalTool(),
|
|
1298
1336
|
approve: createApproveTool(),
|
|
1337
|
+
// `/computer on|off` session override wins over `config.computer.enabled`
|
|
1338
|
+
// (see `sessionComputerOverride`/`computer-slash.ts`); undefined = fall
|
|
1339
|
+
// through to DEFAULT_TOOLS' own config-only gate inside executeComputerAction.
|
|
1340
|
+
computer: (a: Record<string, any>) => {
|
|
1341
|
+
computerSupervisor.setKillSwitchLive(true);
|
|
1342
|
+
computerSupervisor.heartbeat();
|
|
1343
|
+
return executeComputerAction(a as any, { enabledOverride: sessionComputerOverride });
|
|
1344
|
+
},
|
|
1345
|
+
|
|
1299
1346
|
};
|
|
1300
1347
|
const tools = filterToolMap(fullTools, Array.from(allowedTools));
|
|
1301
1348
|
// Opik observability (opt-in via JEO_OPIK): one trace per turn, spans per
|
|
@@ -1327,6 +1374,46 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1327
1374
|
const asError = new Error(message);
|
|
1328
1375
|
if (isRateLimitError(asError)) return "hit a rate limit";
|
|
1329
1376
|
if (isUsageLimitError(asError)) return "hit a usage/quota limit";
|
|
1377
|
+
// Auth/credential failures (401/403 rejection, missing/expired credential) are
|
|
1378
|
+
// an operational fault of THIS provider, not the prompt or the model's
|
|
1379
|
+
// capability — the equivalent-pool fallback exists precisely to route around a
|
|
1380
|
+
// provider whose credential just broke (expired OAuth token, revoked key, no
|
|
1381
|
+
// key configured) mid-session, instead of failing the whole turn.
|
|
1382
|
+
if (/rejected the credential|no credential for provider|no usable credential|\b401\b|\b403\b/i.test(message)) {
|
|
1383
|
+
return "is unauthenticated for this session";
|
|
1384
|
+
}
|
|
1385
|
+
// Pre-response connection failures (refused/unresolvable host, local
|
|
1386
|
+
// ollama/lmstudio server down, unreachable custom base URL) — see
|
|
1387
|
+
// isConnectionError's docs. Distinct from a mid-stream drop (transient,
|
|
1388
|
+
// already retried by the engine) or a genuine timeout (handled below).
|
|
1389
|
+
if (isConnectionError(asError) || /could not connect|is unreachable at/i.test(message)) {
|
|
1390
|
+
return "is unreachable";
|
|
1391
|
+
}
|
|
1392
|
+
// A silent/no-response provider (the call ran the full wall-clock timeout
|
|
1393
|
+
// with no usable output) is exactly the "no response" exception the
|
|
1394
|
+
// equivalent-pool fallback exists to route around.
|
|
1395
|
+
if (/did not complete the request within the call timeout|the operation timed out|exceeded the overall deadline/i.test(message)) {
|
|
1396
|
+
return "did not respond in time";
|
|
1397
|
+
}
|
|
1398
|
+
// Billing/payment failures (HTTP 402 — quota exhausted with postpaid billing
|
|
1399
|
+
// off, insufficient credit, free-trial exhausted) are a provider-account
|
|
1400
|
+
// fault, not a prompt/model problem: the SAME account's other models are
|
|
1401
|
+
// often unaffected (observed: Tencent's kimi-k2.5/deepseek-v3.2 402 while
|
|
1402
|
+
// deepseek-v4-flash/glm-5.2 on the SAME key kept working). Reroute exactly
|
|
1403
|
+
// like a rate/usage limit instead of leaving a configured-but-billing-blocked
|
|
1404
|
+
// model dead for the rest of the session.
|
|
1405
|
+
if (/\b402\b|payment required|insufficient credit|billing (?:is )?not enabled|free trial quota/i.test(message)) {
|
|
1406
|
+
return "requires billing/payment on this provider account";
|
|
1407
|
+
}
|
|
1408
|
+
// A persistent 5xx that SURVIVED the model-manager's own retry budget
|
|
1409
|
+
// (withRetry already backed off 500/502/503/504/529 internally — see
|
|
1410
|
+
// defaultRetryable's RETRYABLE_STATUS) means the provider's server itself
|
|
1411
|
+
// is down/overloaded beyond what backoff can recover from THIS session.
|
|
1412
|
+
// Equally a provider-side fault as the classes above, not a prompt issue.
|
|
1413
|
+
if (/\b(?:500|502|503|504|529)\b/.test(message)) {
|
|
1414
|
+
return "hit a persistent server-side error";
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1330
1417
|
if (/does not recognize the requested model|model .*?(?:not found|not available|unavailable|unsupported|retired|gated)|\b(?:400|404)\b/i.test(message)) {
|
|
1331
1418
|
return "is unavailable";
|
|
1332
1419
|
}
|
|
@@ -1351,23 +1438,39 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1351
1438
|
? Math.floor(routeFallbackMaxRaw)
|
|
1352
1439
|
: 3;
|
|
1353
1440
|
const attemptedRouteModels = new Set<string>([activeModel]);
|
|
1354
|
-
|
|
1441
|
+
// A NON-routed turn (routing disabled, or the user pinned a model via /model)
|
|
1442
|
+
// still needs the equivalent-pool fallback on a usage-limit/rate-limit/credential
|
|
1443
|
+
// failure — the exact "5-hour usage window exhausted, model just stops working"
|
|
1444
|
+
// case this loop exists to fix. Synthesize a same-size-class RouteDecision
|
|
1445
|
+
// (inferTierForModel) so `equivalentRouteFallback` has a tier/pool to search even
|
|
1446
|
+
// with routing off; a real `routed` decision (routing enabled) is used unchanged.
|
|
1447
|
+
let fallbackBaseDecision: RouteDecision = routed ?? {
|
|
1448
|
+
model: activeModel,
|
|
1449
|
+
thinking: activeThinking,
|
|
1450
|
+
tier: inferTierForModel(activeModel),
|
|
1451
|
+
confidence: 1,
|
|
1452
|
+
source: "heuristic",
|
|
1453
|
+
signals: [],
|
|
1454
|
+
};
|
|
1455
|
+
for (let routeFallbackAttempt = 0; routeFallbackAttempt < routeFallbackMax; routeFallbackAttempt++) {
|
|
1355
1456
|
const routedFailureReason = !result.done ? routeFailureReason(result.doneReason) : null;
|
|
1356
1457
|
if (!routedFailureReason) break;
|
|
1357
|
-
const fallback = await equivalentRouteFallback(
|
|
1458
|
+
const fallback = await equivalentRouteFallback(fallbackBaseDecision, routedFailureReason, [...attemptedRouteModels]);
|
|
1358
1459
|
if (!fallback) break;
|
|
1359
1460
|
const previousModel = activeModel;
|
|
1360
1461
|
attemptedRouteModels.add(fallback);
|
|
1361
1462
|
activeModel = fallback;
|
|
1362
|
-
activeThinking =
|
|
1463
|
+
activeThinking = fallbackBaseDecision.thinking ?? sessionThinking;
|
|
1363
1464
|
turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
|
|
1364
1465
|
contextTokens = catalogMetadata(activeModel)?.contextTokens;
|
|
1365
|
-
|
|
1366
|
-
|
|
1466
|
+
fallbackBaseDecision = { ...fallbackBaseDecision, model: activeModel };
|
|
1467
|
+
lastRouteDecision = { ...fallbackBaseDecision, warning: `model '${previousModel}' ${routedFailureReason}; switched to equivalent '${activeModel}'` };
|
|
1468
|
+
const notice = `[route] model '${previousModel}' ${routedFailureReason}; switching to equivalent '${activeModel}' for this turn.`;
|
|
1367
1469
|
if (tui) tui.events().onNotice?.(notice);
|
|
1368
1470
|
else console.log(notice);
|
|
1369
1471
|
result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
|
|
1370
1472
|
}
|
|
1473
|
+
|
|
1371
1474
|
if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
|
|
1372
1475
|
history.push({
|
|
1373
1476
|
role: "user",
|
|
@@ -2845,6 +2948,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2845
2948
|
color: "default",
|
|
2846
2949
|
},
|
|
2847
2950
|
];
|
|
2951
|
+
for (const tier of PROMPT_TIERS) {
|
|
2952
|
+
const configured = cfg.routing?.tiers?.[tier];
|
|
2953
|
+
if (!configured?.model) continue;
|
|
2954
|
+
assignments.push({
|
|
2955
|
+
role: `routing:${tier}`,
|
|
2956
|
+
label: `ROUTE:${tier}`,
|
|
2957
|
+
model: configured.model,
|
|
2958
|
+
thinking: configured.thinking ?? "auto",
|
|
2959
|
+
color: "default",
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2848
2962
|
for (const roleId of orderedRoleIds) {
|
|
2849
2963
|
const role = byId.get(roleId);
|
|
2850
2964
|
if (!role) continue;
|
|
@@ -2950,9 +3064,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2950
3064
|
title: string,
|
|
2951
3065
|
current: ThinkLevel | undefined,
|
|
2952
3066
|
inheritLabel?: string,
|
|
3067
|
+
supportedLevels?: readonly ThinkLevel[],
|
|
2953
3068
|
): Promise<ThinkLevel | "inherit" | undefined> => {
|
|
2954
3069
|
const levels = buildThinkingLevelChoices(current, {
|
|
2955
3070
|
inheritLabel,
|
|
3071
|
+
levels: supportedLevels,
|
|
2956
3072
|
tokenHint: lvl => `~${Math.round(thinkingMaxTokens(lvl as ThinkLevel) / 1000)}k tokens`,
|
|
2957
3073
|
});
|
|
2958
3074
|
const picked = await pickFromOptions(title, levels);
|
|
@@ -3023,6 +3139,35 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3023
3139
|
return true;
|
|
3024
3140
|
}
|
|
3025
3141
|
|
|
3142
|
+
if (targetId.startsWith("routing:")) {
|
|
3143
|
+
const tier = targetId.slice("routing:".length) as PromptTier;
|
|
3144
|
+
if (!PROMPT_TIERS.includes(tier)) return false;
|
|
3145
|
+
const meta = catalogMetadata(target);
|
|
3146
|
+
const supportedThinking = meta?.thinking ?? [];
|
|
3147
|
+
const currentRouteThinking = cfgForPick.routing?.tiers?.[tier]?.thinking;
|
|
3148
|
+
let thinking: ThinkLevel | undefined;
|
|
3149
|
+
if (supportedThinking.length > 0) {
|
|
3150
|
+
const level = await pickThinkingLevel(
|
|
3151
|
+
`Reasoning for route ${tier}: ${target}`,
|
|
3152
|
+
currentRouteThinking,
|
|
3153
|
+
undefined,
|
|
3154
|
+
supportedThinking,
|
|
3155
|
+
);
|
|
3156
|
+
if (level === undefined) {
|
|
3157
|
+
console.log("(cancelled)");
|
|
3158
|
+
return true;
|
|
3159
|
+
}
|
|
3160
|
+
thinking = isThinkingLevel(level) ? level : undefined;
|
|
3161
|
+
}
|
|
3162
|
+
await saveConfigPatch(raw => ({
|
|
3163
|
+
routing: withRoutingTierSetting(raw, tier, { model: target, thinking }),
|
|
3164
|
+
}));
|
|
3165
|
+
const { provider } = await describeModel(target);
|
|
3166
|
+
const thinkingLabel = supportedThinking.length > 0 ? ` · thinking ${thinking}` : " · no thinking override (model does not advertise thinking)";
|
|
3167
|
+
console.log(`Prompt route ${tier} set to ${target} (${provider})${thinkingLabel} — routing enabled in ~/.jeo/config.json. Clear any session model pin with /model auto to let routing evaluate prompts.`);
|
|
3168
|
+
return true;
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3026
3171
|
const role = getSubagentRole(targetId, cfgForPick);
|
|
3027
3172
|
if (!role) return false;
|
|
3028
3173
|
const currentRoleThinking = resolveSubagentThinking(role.id, cfgForPick) as ThinkLevel | undefined;
|
|
@@ -4295,6 +4440,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4295
4440
|
for (const line of routeResult.lines) console.log(line);
|
|
4296
4441
|
continue;
|
|
4297
4442
|
}
|
|
4443
|
+
if (input.startsWith("/computer") && (input === "/computer" || input[9] === " ")) {
|
|
4444
|
+
const cfgNow = await readGlobalConfig();
|
|
4445
|
+
const computerResult = runComputerSlash(input, {
|
|
4446
|
+
sessionComputerOverride,
|
|
4447
|
+
computerConfigEnabled: !!cfgNow.computer?.enabled,
|
|
4448
|
+
});
|
|
4449
|
+
if (computerResult.sessionComputerOverride !== undefined) sessionComputerOverride = computerResult.sessionComputerOverride;
|
|
4450
|
+
for (const line of computerResult.lines) console.log(line);
|
|
4451
|
+
continue;
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4298
4454
|
if (input.startsWith("/view") && (input === "/view" || input[5] === " ")) {
|
|
4299
4455
|
for (const line of await handleViewSlash(input, cwd)) console.log(line);
|
|
4300
4456
|
continue;
|
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
|
}
|
|
@@ -20,9 +20,9 @@ function liveModelHint(model: string, current?: string): string {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface ModelAssignmentBadge {
|
|
23
|
-
/** Stable target id: "default"
|
|
23
|
+
/** Stable target id: "default", a subagent role id, or "routing:<tier>". */
|
|
24
24
|
role: string;
|
|
25
|
-
/** Short badge text shown beside a matching model (DEFAULT, EXECUTOR, ...). */
|
|
25
|
+
/** Short badge text shown beside a matching model (DEFAULT, EXECUTOR, ROUTE:HIGH, ...). */
|
|
26
26
|
label: string;
|
|
27
27
|
/** Configured model id for this target, qualified (`provider/model`) when possible. */
|
|
28
28
|
model: string;
|
|
@@ -120,6 +120,8 @@ const THINKING_LEVEL_DESCRIPTION: Record<string, string> = {
|
|
|
120
120
|
export interface ThinkingLevelChoiceOptions {
|
|
121
121
|
/** Prepend an "inherit" row (role targets only, which can follow the default). */
|
|
122
122
|
inheritLabel?: string;
|
|
123
|
+
/** Restrict visible reasoning levels to the selected model's supported levels. */
|
|
124
|
+
levels?: readonly string[];
|
|
123
125
|
/** Per-level reasoning-budget hint, e.g. "~32k tokens". Return undefined to omit. */
|
|
124
126
|
tokenHint?: (level: string) => string | undefined;
|
|
125
127
|
}
|
|
@@ -138,7 +140,8 @@ export function buildThinkingLevelChoices(
|
|
|
138
140
|
if (opts.inheritLabel) {
|
|
139
141
|
items.push({ value: "inherit", label: opts.inheritLabel, hint: current === undefined ? "current" : "" });
|
|
140
142
|
}
|
|
141
|
-
|
|
143
|
+
const levels = opts.levels?.length ? THINKING_LEVEL_ORDER.filter(level => opts.levels!.includes(level)) : THINKING_LEVEL_ORDER;
|
|
144
|
+
for (const level of levels) {
|
|
142
145
|
const tokens = opts.tokenHint?.(level);
|
|
143
146
|
const label = `${level} — ${THINKING_LEVEL_DESCRIPTION[level]}${tokens ? ` (${tokens})` : ""}`;
|
|
144
147
|
items.push({ value: level, label, hint: current === level ? "current" : "" });
|
|
@@ -55,6 +55,8 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
|
|
|
55
55
|
{ command: "/usage", usage: "/usage", description: "Show cumulative token usage for this session", group: "system" },
|
|
56
56
|
{ command: "/context", usage: "/context", description: "Show context token usage breakdown", group: "system" },
|
|
57
57
|
{ command: "/tools", usage: "/tools", description: "Show the tools currently visible to the agent", group: "system" },
|
|
58
|
+
{ command: "/computer", usage: "/computer [on|off]", description: "Show/toggle the desktop-automation 'computer' tool (screenshot/click/type/etc) for this session", group: "system" },
|
|
59
|
+
|
|
58
60
|
{ command: "/hotkeys", usage: "/hotkeys", description: "Show keyboard shortcuts", group: "system" },
|
|
59
61
|
{ command: "/theme", usage: "/theme [name]", description: "Show or set the TUI theme (cosmic/matrix/solar/mono)", group: "system" },
|
|
60
62
|
{ command: "/wiki", usage: "/wiki [path|off]", description: "Show or set the global llm-wiki vault root (shared across all sessions)", group: "system" },
|