jeo-code 0.8.16 → 0.8.18
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 +38 -0
- package/README.ja.md +6 -3
- package/README.ko.md +6 -3
- package/README.md +7 -4
- package/README.zh.md +6 -3
- package/package.json +1 -1
- package/src/agent/route-history.ts +107 -0
- package/src/agent/subagents.ts +20 -3
- package/src/ai/providers/anthropic-compatible.ts +19 -2
- package/src/ai/providers/errors.ts +34 -0
- package/src/ai/providers/kimi.ts +23 -8
- package/src/ai/providers/openai-compatible.ts +19 -2
- package/src/commands/launch/route-slash.ts +51 -19
- package/src/commands/launch.ts +22 -2
- package/src/tui/app.ts +61 -1
- package/src/tui/components/slash.ts +1 -1
- package/src/util/provider-error.ts +62 -15
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,44 @@ 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.18] - 2026-07-09
|
|
10
|
+
_"라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct._
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **TUI footer no longer shows a stale model after a mid-turn equivalent-pool fallback** (`src/tui/app.ts`, `src/commands/launch.ts`) — the footer's `model`/`provider` fields were set once at `LaunchTui` construction and never updated when `launch.ts`'s post-call fallback loop (routed model hit a persistent server-side error, rate limit, etc.) switched `activeModel` mid-turn; the status bar kept showing the ORIGINAL pre-fallback model for the remainder of that turn even though the reply actually came from the fallback model. Added an `onModelSwitch(model, provider)` event, fired from the fallback loop right alongside the existing `[route] … switching to equivalent …` notice, that updates the live footer in place.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **`/route history [n]`** (`src/agent/route-history.ts`, `src/commands/launch/route-slash.ts`, registered in `src/tui/components/slash.ts`) — wires the `RouteHistory` class (shipped disconnected in the prior commit) into the live turn loop: a session-scoped bounded FIFO (never persisted) records every real routing decision — both the pre-call routed pick and any mid-turn equivalent-model fallback that actually served the turn — so history reflects reality, not a since-superseded pre-call guess. `/route history [n]` prints the last n (default 10, non-positive/non-numeric args fall back to the default) as `turn N: tier -> model (source, confidence X.XX)`.
|
|
17
|
+
|
|
18
|
+
### Removed
|
|
19
|
+
- **`IMPROVEMENT_SUMMARY.md`, `REVIEW.md`** — stray AI-session working notes from the prior commit, never referenced by `README.md`, `CHANGELOG.md`, or `package.json`'s `files` allowlist.
|
|
20
|
+
|
|
21
|
+
### Verified
|
|
22
|
+
- Live PTY re-verification (`bun src/cli.ts`, isolated `JEO_CONFIG_DIR`, real Anthropic credentials): 5 prompts of varying complexity in one session routed to 5 distinct tier/model pairs (trivial→claude-haiku-4-5, complex→claude-fable-5, trivial→claude-haiku-4-5, high→claude-sonnet-5, trivial→claude-haiku-4-5); `/route why` and `/route status` matched the actual serving model on every turn.
|
|
23
|
+
- Live post-call-fallback reproduction: a real `Bun.serve` mock returning HTTP 500 on every call forced `launch.ts`'s equivalent-pool fallback to fire mid-turn; `/route why` immediately after correctly reported the POST-fallback model and a `warning: … switched to equivalent …` field, not the original pre-call pick.
|
|
24
|
+
- `bun test test/route-history.test.ts test/route-slash.test.ts test/launch-prompt-routing.test.ts` — 69 pass / 0 fail.
|
|
25
|
+
- `bun test test/tui-app.test.ts` — 51 pass / 0 fail (50 pre-existing + 1 new, covering the footer live-update fix).
|
|
26
|
+
- Full `bun test` — 2885 pass / 0 fail across 290 files.
|
|
27
|
+
- `bun run typecheck` — no errors.
|
|
28
|
+
|
|
29
|
+
## [0.8.17] - 2026-07-09
|
|
30
|
+
_"서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns._
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- **README `/route on` semantics corrected** (`README.md`, `README.ko.md`, `README.ja.md`, `README.zh.md`) — the caution block previously said the model pin releases "until `/route on` with no pin active", which has the mechanism backwards: `/route on` (`src/commands/launch/route-slash.ts:93-98`) sets `sessionRouteOverride = true`, which *outranks* an active `sessionModel` pin without clearing it — the pin reasserts itself the instant `/route off` runs (confirmed by the existing `test/launch-prompt-routing.test.ts` "/route on then /route off" regression). Only `/model auto` actually clears the pin (`sessionModel = undefined`).
|
|
34
|
+
- **README tier-fallback claim scoped correctly** — the caution block previously implied any tier missing a `roles.*` entry falls back to `defaultModel` on every turn. `resolveTierModel` (`src/agent/prompt-router.ts:464-472`) only guarantees that for the `standard` tier; `high` and `complex` first run a live credentialed-model scan (`strongestMidTierCredentialed` / `strongestCredentialed`) before falling back, so they can still resolve to a different model each turn even when unconfigured. Reworded in all 4 languages; also fixed a KO/JA translation drift that narrowed "a tier" to "상위 티어"/"上位ティア" (upper tier), which had no counterpart in the EN/ZH text.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- **Mid-session `/model <name>` slash-command lock + persistence coverage** (`test/launch-prompt-routing.test.ts`) — 3 new tests proving the pin set by literally typing `/model <name>` (not just the `--model` CLI flag) holds on the very next turn, persists across 3+ consecutive subsequent turns with no further commands, and correctly cycles through pin → `/model auto` release → re-pin to a different model.
|
|
38
|
+
- **Tier-resolution freshness + scoping coverage** (`test/prompt-router-tiers.test.ts`) — 2 new tests proving `resolveTierModel` has no cross-call caching (interleaved trivial/complex/trivial calls never bleed into each other) and asserting the standard/high/complex fallback-boundary contrast in one place (`standard` and `high` fall through to `defaultModel` on an OpenAI-only fixture with no mid-class model, `complex` does not) as a regression guard against this exact scope-of-claim mistake recurring silently.
|
|
39
|
+
|
|
40
|
+
### Verified
|
|
41
|
+
- `bun test test/launch-prompt-routing.test.ts` — 38 pass / 0 fail (35 pre-existing + 3 new).
|
|
42
|
+
- `bun test test/prompt-router-tiers.test.ts test/prompt-router.test.ts` — 86 pass / 0 fail (84 pre-existing + 2 new).
|
|
43
|
+
- Full `bun test` — 2877 pass / 0 fail across 290 files.
|
|
44
|
+
- `bun run typecheck` — no errors.
|
|
45
|
+
- Live PTY re-verification (`bun src/cli.ts`, isolated `JEO_CONFIG_DIR`, real Anthropic credentials): trivial prompt → routed to `roles.smol`; `/model claude-sonnet-4-6` → next trivial prompt stayed pinned (no `[route]` notice, confirmed via raw byte-stream regex search, not just screen snapshot); `/model auto` → next trivial prompt re-routed to `roles.smol` again. Closes the one claim that was previously only inferred from a code comment.
|
|
46
|
+
|
|
9
47
|
## [0.8.16] - 2026-07-09
|
|
10
48
|
_"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
49
|
|
package/README.ja.md
CHANGED
|
@@ -79,7 +79,7 @@ jeo --tmux # 独立した tmux セッションで実行
|
|
|
79
79
|
| `/provider login <name>` · `/logout` | 入力欄から OAuth ログイン/ログアウト |
|
|
80
80
|
| `/agents [role]` · `/subagent` | ロール別(executor/planner/architect/critic)モデル・thinking・ステップ設定 |
|
|
81
81
|
| `/thinking [level]` | デフォルト推論予算(low…xhigh)の表示/設定 |
|
|
82
|
-
| `/route [status\|on\|off\|why]` | セッション単位のプロンプトベース・モデルルーティングの切替 · 直近のルーティング判断の説明(設定済み資格情報 — OAuth または API キー — が実際に提供するモデルの中でのみ自動ルーティング) |
|
|
82
|
+
| `/route [status\|on\|off\|why\|history [n]]` | セッション単位のプロンプトベース・モデルルーティングの切替 · 直近のルーティング判断の説明 · `history [n]` はこのセッションの直近 n 件(デフォルト 10 件)のルーティング判断を表示(設定済み資格情報 — OAuth または API キー — が実際に提供するモデルの中でのみ自動ルーティング) |
|
|
83
83
|
| `/fast [on\|off\|status]` | 現在のモデルが low 推論をサポートする場合に fast thinking モードを切替 |
|
|
84
84
|
| `/skill` · `$<skill> [intent]` | ワークフロースキルの一覧/実行(`$team "task"` 形式) |
|
|
85
85
|
| `/view` · `/diff` · `/find` · `/search` | コード表示、git diff、ファイル/パターン検索 |
|
|
@@ -90,6 +90,9 @@ jeo --tmux # 独立した tmux セッションで実行
|
|
|
90
90
|
| `/theme` · `/config` · `/help` | テーマ、ランタイム設定、ヘルプ |
|
|
91
91
|
| `jeo autopilot status` | スコア方向、keep/revert 数、次アクションを示す ratchet ステータスフィールド |
|
|
92
92
|
|
|
93
|
+
> [!CAUTION]
|
|
94
|
+
> **`/model <name>` で特定モデルを手動指定すると、そのセッション中ルーティングが固定されます。** プロンプトルーティング(`/route`)はモデルが手動固定されていない間のみ毎ターン再評価されます。`/model <name>` で特定モデルを選ぶとその選択が固定され、`/model auto`(ピンを完全に解除)を実行するか、`/route on`(ピンを消さずに優先度だけ上げる — `/route off` した瞬間ピンが復活)を実行するまでルーティングは切り替わりません。`roles.*` 未設定時に `defaultModel` へ確定フォールバックするのは `standard` ティアのみで、`high`/`complex` ティアはフォールバック前にライブでクレデンシャル済みの最強モデルを先に探索するため、未設定でも毎ターン別モデルに着地することがあります。
|
|
95
|
+
|
|
93
96
|
## Spec-first ワークフロー
|
|
94
97
|
|
|
95
98
|
要件 → プラン → 承認 → 実行 → 検証が `.jeo/state/` で繋がり、各ハンドオフに**ブロック可能な本物のゲート**があります:
|
|
@@ -262,11 +265,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
262
265
|
## 変更履歴 (Changelog)
|
|
263
266
|
|
|
264
267
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
268
|
+
- **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
|
|
269
|
+
- **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
|
|
265
270
|
- **[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
271
|
- **[0.8.15]** (2026-07-09) — "api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed.
|
|
267
272
|
- **[0.8.14]** (2026-07-09) — "GPT 연결 실패가 여전히 무응답으로 끝나는 케이스를 잡아줘" — traced the remaining GPT/routing failure path to post-call continuity: the route target could be credentialed and servable, but still end a turn with a recoverable terminal result such as plain `OpenAI returned no content`, rate/quota pressure, model unavailable/not found, or an invalid agent-tool response. jeo already knew the prompt tier and equivalent credentialed fallbacks, but it only applied that fallback before the model call. After the call failed, the turn stopped instead of trying another model in the same tier.
|
|
268
|
-
- **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
|
|
269
|
-
- **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
|
|
270
273
|
|
|
271
274
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
272
275
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -80,7 +80,7 @@ jeo --tmux # 독립 tmux 세션에서 실행
|
|
|
80
80
|
| `/provider login <name>` · `/logout` | 입력창에서 OAuth 로그인/로그아웃 |
|
|
81
81
|
| `/agents [role]` · `/subagent` | 역할별(executor/planner/architect/critic) 모델·thinking·스텝 구성 |
|
|
82
82
|
| `/thinking [level]` | 기본 추론 예산(low…xhigh) 조회/설정 |
|
|
83
|
-
| `/route [status\|on\|off\|why]` | 세션별 프롬프트 기반 모델 라우팅 켜기/끄기 · 마지막 라우팅 결정 설명(설정된 자격증명 — OAuth 또는 API 키 — 이 실제로 서비스하는 모델 안에서만 자동 라우팅) |
|
|
83
|
+
| `/route [status\|on\|off\|why\|history [n]]` | 세션별 프롬프트 기반 모델 라우팅 켜기/끄기 · 마지막 라우팅 결정 설명 · `history [n]`은 이번 세션의 최근 n개(기본 10개) 라우팅 결정 표시(설정된 자격증명 — OAuth 또는 API 키 — 이 실제로 서비스하는 모델 안에서만 자동 라우팅) |
|
|
84
84
|
| `/fast [on\|off\|status]` | 현재 모델이 low 추론을 지원하면 fast thinking 모드를 켜고 끔 |
|
|
85
85
|
| `/skill` · `$<skill> [intent]` | 워크플로 스킬 목록/실행(`$team "작업"` 스타일) |
|
|
86
86
|
| `/view` · `/diff` · `/find` · `/search` | 코드 보기, git diff, 파일/패턴 검색 |
|
|
@@ -91,6 +91,9 @@ jeo --tmux # 독립 tmux 세션에서 실행
|
|
|
91
91
|
| `/theme` · `/config` · `/help` | 테마, 런타임 설정, 도움말 |
|
|
92
92
|
| `jeo autopilot status` | 점수 방향, keep/revert 횟수, 다음 액션을 보여주는 ratchet 상태 필드 |
|
|
93
93
|
|
|
94
|
+
> [!CAUTION]
|
|
95
|
+
> **`/model <name>`으로 특정 모델을 수동 지정하면 그 세션 동안 라우팅이 고정됩니다.** 프롬프트 라우팅(`/route`)은 모델이 수동으로 고정되지 않은 동안만 매 턴 재평가됩니다. `/model <name>`으로 특정 모델을 선택하면 그 선택이 그대로 고정되며, `/model auto`(핀을 완전히 해제)를 실행하거나 `/route on`(핀을 지우지 않고 그보다 우선순위를 높임 — `/route off` 하는 즉시 핀이 다시 살아남)을 실행하기 전까지는 라우팅이 다시 전환되지 않습니다. `roles.*` 항목 미설정 시 `defaultModel`로 확정 폴백되는 건 `standard` 티어뿐이며, `high`/`complex` 티어는 폴백 전에 실시간으로 크레덴셜된 가장 강력한 모델을 먼저 탐색하므로, 설정이 없어도 매 턴 다른 모델로 갈 수 있습니다.
|
|
96
|
+
|
|
94
97
|
## Spec-first 워크플로
|
|
95
98
|
|
|
96
99
|
요구사항 → 플랜 → 승인 → 실행 → 검증이 `.jeo/state/`로 이어지며, 모든 핸드오프에 **차단 가능한 진짜 게이트**가 있습니다:
|
|
@@ -263,11 +266,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
263
266
|
## 변경 이력 (Changelog)
|
|
264
267
|
|
|
265
268
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
269
|
+
- **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
|
|
270
|
+
- **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
|
|
266
271
|
- **[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
272
|
- **[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
273
|
- **[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
|
-
- **[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
|
-
- **[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
274
|
|
|
272
275
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
273
276
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Run `jeo` inside a repository and it reads files, edits them, runs commands, and
|
|
|
40
40
|
|
|
41
41
|
## Highlights
|
|
42
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.
|
|
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|history]`), 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, `/route history` for the recent ones.
|
|
44
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`.
|
|
45
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
46
|
|
|
@@ -85,7 +85,7 @@ Inside the `jeo` REPL (Tab autocompletes; `/` opens the palette).
|
|
|
85
85
|
| `/provider login <name>` · `/logout` | OAuth login/logout from the input box |
|
|
86
86
|
| `/agents [role]` · `/subagent` | Per-role (executor/planner/architect/critic) model · thinking · step config |
|
|
87
87
|
| `/thinking [level]` | Show/set default reasoning budget (low…xhigh) |
|
|
88
|
-
| `/route [status\|on\|off\|why]` | Toggle prompt-based model routing for this session · explain the last routing decision (auto-routes each turn to a tier-appropriate model among the models your configured credentials — OAuth or API key — actually serve, and switches to an equivalent tier model when a configured route is unready) |
|
|
88
|
+
| `/route [status\|on\|off\|why\|history [n]]` | Toggle prompt-based model routing for this session · explain the last routing decision · `history [n]` lists the last n (default 10) routing decisions this session (auto-routes each turn to a tier-appropriate model among the models your configured credentials — OAuth or API key — actually serve, and switches to an equivalent tier model when a configured route is unready) |
|
|
89
89
|
| `/fast [on\|off\|status]` | Toggle fast thinking mode when the active model advertises low reasoning |
|
|
90
90
|
| `/skill` · `$<skill> [intent]` | List/run workflow skills (`$team "task"` style) |
|
|
91
91
|
| `/view` · `/diff` · `/find` · `/search` | Code view, git diff, file/pattern search |
|
|
@@ -96,6 +96,9 @@ Inside the `jeo` REPL (Tab autocompletes; `/` opens the palette).
|
|
|
96
96
|
| `/theme` · `/config` · `/help` | Theme, runtime config, help |
|
|
97
97
|
| `jeo autopilot status` | Ratchet status field with score direction, keep/revert counts, and next action |
|
|
98
98
|
|
|
99
|
+
> [!CAUTION]
|
|
100
|
+
> **`/model <name>` locks routing for the rest of the session.** Prompt routing (`/route`) only re-evaluates per turn while no model is manually pinned. Picking a specific model via `/model <name>` freezes that choice — routing will *not* switch away from it again until you run `/model auto` (which clears the pin), or `/route on` (which *outranks* an active pin without clearing it — the pin reasserts itself the moment you run `/route off`). Missing a `roles.*` entry only guarantees a `defaultModel` fallback on the `standard` tier; the `high`/`complex` tiers first scan for the strongest live-credentialed model before falling back, so they can still land on a different model each turn even when unconfigured.
|
|
101
|
+
|
|
99
102
|
## Spec-first workflow
|
|
100
103
|
|
|
101
104
|
Requirements → plan → approval → execution → verification, carried through `.jeo/state/` with **real, blocking gates** at every handoff:
|
|
@@ -270,11 +273,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
270
273
|
## Changelog
|
|
271
274
|
|
|
272
275
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
276
|
+
- **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
|
|
277
|
+
- **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
|
|
273
278
|
- **[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
279
|
- **[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.
|
|
275
280
|
- **[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.
|
|
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.
|
|
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.
|
|
278
281
|
|
|
279
282
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
280
283
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -79,7 +79,7 @@ jeo --tmux # 在独立 tmux 会话中运行
|
|
|
79
79
|
| `/provider login <name>` · `/logout` | 在输入框内 OAuth 登录/登出 |
|
|
80
80
|
| `/agents [role]` · `/subagent` | 按角色(executor/planner/architect/critic)配置模型·thinking·步数 |
|
|
81
81
|
| `/thinking [level]` | 查看/设置默认推理预算(low…xhigh) |
|
|
82
|
-
| `/route [status\|on\|off\|why]` | 切换本会话的基于提示词的模型路由 · 解释最近一次路由决策(仅在已配置凭证 — OAuth 或 API 密钥 — 实际可用的模型内自动路由) |
|
|
82
|
+
| `/route [status\|on\|off\|why\|history [n]]` | 切换本会话的基于提示词的模型路由 · 解释最近一次路由决策 · `history [n]` 列出本会话最近 n 条(默认 10 条)路由决策(仅在已配置凭证 — OAuth 或 API 密钥 — 实际可用的模型内自动路由) |
|
|
83
83
|
| `/fast [on\|off\|status]` | 当前模型支持 low 推理时切换 fast thinking 模式 |
|
|
84
84
|
| `/skill` · `$<skill> [intent]` | 列出/运行工作流技能(`$team "任务"` 风格) |
|
|
85
85
|
| `/view` · `/diff` · `/find` · `/search` | 代码查看、git diff、文件/模式搜索 |
|
|
@@ -90,6 +90,9 @@ jeo --tmux # 在独立 tmux 会话中运行
|
|
|
90
90
|
| `/theme` · `/config` · `/help` | 主题、运行时配置、帮助 |
|
|
91
91
|
| `jeo autopilot status` | 显示分数方向、keep/revert 次数和下一步动作的 ratchet 状态字段 |
|
|
92
92
|
|
|
93
|
+
> [!CAUTION]
|
|
94
|
+
> **通过 `/model <name>` 手动指定模型后,路由会在本会话内保持锁定。** Prompt routing(`/route`)只在没有手动锁定模型时才会逐轮重新评估。用 `/model <name>` 选定具体模型后,该选择会被锁定 — 直到你运行 `/model auto`(彻底解除锁定),或运行 `/route on`(不会清除锁定,只是优先级更高 — 一旦运行 `/route off`,锁定会立刻恢复)之前,路由都不会再切换。未配置 `roles.*` 条目时,只有 `standard` 层级会确定性地退回到 `defaultModel`;`high`/`complex` 层级会先实时扫描已配置凭证中最强的可用模型再决定是否回退,所以即使未配置,也可能每轮落到不同的模型上。
|
|
95
|
+
|
|
93
96
|
## Spec-first 工作流
|
|
94
97
|
|
|
95
98
|
需求 → 计划 → 批准 → 执行 → 验证,经由 `.jeo/state/` 串联,每次交接都有**可阻断的真实门禁**:
|
|
@@ -262,11 +265,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
262
265
|
## 更新日志 (Changelog)
|
|
263
266
|
|
|
264
267
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
268
|
+
- **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
|
|
269
|
+
- **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
|
|
265
270
|
- **[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
271
|
- **[0.8.15]** (2026-07-09) — "api 인증 모델도 사용가능하도록 딥리서치하고 개선해줘" — traced the API-authenticated GPT path to a routing catalog gap: `GET /models` with an OpenAI API key or an OpenAI-compatible provider returned real account-scoped model ids, and `/model` could show/pin them, but prompt routing still built its auto-select pools from static `MODEL_CATALOG` rows only. A custom `openaiBaseUrl` made this worse by treating every public OpenAI row as servable, so routing could choose `gpt-*`/`o*` ids that the configured local/Azure/proxy endpoint never listed.
|
|
267
272
|
- **[0.8.14]** (2026-07-09) — "GPT 연결 실패가 여전히 무응답으로 끝나는 케이스를 잡아줘" — traced the remaining GPT/routing failure path to post-call continuity: the route target could be credentialed and servable, but still end a turn with a recoverable terminal result such as plain `OpenAI returned no content`, rate/quota pressure, model unavailable/not found, or an invalid agent-tool response. jeo already knew the prompt tier and equivalent credentialed fallbacks, but it only applied that fallback before the model call. After the call failed, the turn stopped instead of trying another model in the same tier.
|
|
268
|
-
- **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
|
|
269
|
-
- **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
|
|
270
273
|
|
|
271
274
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
272
275
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing decision history tracker — maintains a bounded FIFO queue of recent
|
|
3
|
+
* routing decisions for `/route history` visibility and performance analysis.
|
|
4
|
+
*
|
|
5
|
+
* Session-scoped (one per REPL), not persisted to config.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RouteDecision } from "./prompt-router";
|
|
9
|
+
|
|
10
|
+
export interface RouteHistoryEntry extends RouteDecision {
|
|
11
|
+
timestamp: number; // Date.now()
|
|
12
|
+
turnNumber: number; // 1-indexed turn in this session
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Bounded FIFO queue of routing decisions. Oldest entries are dropped when
|
|
17
|
+
* the queue exceeds `maxSize`.
|
|
18
|
+
*/
|
|
19
|
+
export class RouteHistory {
|
|
20
|
+
private entries: RouteHistoryEntry[] = [];
|
|
21
|
+
private turnNumber = 0;
|
|
22
|
+
readonly maxSize: number;
|
|
23
|
+
|
|
24
|
+
constructor(maxSize: number = 10) {
|
|
25
|
+
this.maxSize = Math.max(1, maxSize);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record a routing decision. Increments the turn counter and adds the entry
|
|
30
|
+
* to the queue, dropping the oldest if necessary.
|
|
31
|
+
*/
|
|
32
|
+
add(decision: RouteDecision): void {
|
|
33
|
+
this.turnNumber++;
|
|
34
|
+
const entry: RouteHistoryEntry = {
|
|
35
|
+
...decision,
|
|
36
|
+
timestamp: Date.now(),
|
|
37
|
+
turnNumber: this.turnNumber,
|
|
38
|
+
};
|
|
39
|
+
this.entries.push(entry);
|
|
40
|
+
if (this.entries.length > this.maxSize) {
|
|
41
|
+
this.entries.shift();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get all recorded entries in chronological order (oldest first).
|
|
47
|
+
*/
|
|
48
|
+
getAll(): RouteHistoryEntry[] {
|
|
49
|
+
return [...this.entries];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the most recent entry, if any.
|
|
54
|
+
*/
|
|
55
|
+
getLast(): RouteHistoryEntry | undefined {
|
|
56
|
+
return this.entries[this.entries.length - 1];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get entries for a specific model.
|
|
61
|
+
*/
|
|
62
|
+
getByModel(model: string): RouteHistoryEntry[] {
|
|
63
|
+
return this.entries.filter((e) => e.model === model);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get entries for a specific tier.
|
|
68
|
+
*/
|
|
69
|
+
getByTier(tier: string): RouteHistoryEntry[] {
|
|
70
|
+
return this.entries.filter((e) => e.tier === tier);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Compute statistics over the history.
|
|
75
|
+
*/
|
|
76
|
+
getStats(): {
|
|
77
|
+
totalDecisions: number;
|
|
78
|
+
modelFrequency: Record<string, number>;
|
|
79
|
+
tierFrequency: Record<string, number>;
|
|
80
|
+
averageConfidence: number;
|
|
81
|
+
} {
|
|
82
|
+
const modelFreq: Record<string, number> = {};
|
|
83
|
+
const tierFreq: Record<string, number> = {};
|
|
84
|
+
let totalConfidence = 0;
|
|
85
|
+
|
|
86
|
+
for (const entry of this.entries) {
|
|
87
|
+
modelFreq[entry.model] = (modelFreq[entry.model] ?? 0) + 1;
|
|
88
|
+
tierFreq[entry.tier] = (tierFreq[entry.tier] ?? 0) + 1;
|
|
89
|
+
totalConfidence += entry.confidence;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
totalDecisions: this.entries.length,
|
|
94
|
+
modelFrequency: modelFreq,
|
|
95
|
+
tierFrequency: tierFreq,
|
|
96
|
+
averageConfidence: this.entries.length > 0 ? totalConfidence / this.entries.length : 0,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Clear all entries.
|
|
102
|
+
*/
|
|
103
|
+
clear(): void {
|
|
104
|
+
this.entries = [];
|
|
105
|
+
this.turnNumber = 0;
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/agent/subagents.ts
CHANGED
|
@@ -16,6 +16,9 @@ import architectPrompt from "../prompts/agents/architect.md" with { type: "text"
|
|
|
16
16
|
import criticPrompt from "../prompts/agents/critic.md" with { type: "text" };
|
|
17
17
|
import executorPrompt from "../prompts/agents/executor.md" with { type: "text" };
|
|
18
18
|
import plannerPrompt from "../prompts/agents/planner.md" with { type: "text" };
|
|
19
|
+
import type { PromptTier } from "./prompt-router";
|
|
20
|
+
|
|
21
|
+
const PROMPT_ROUTE_TIERS = ["trivial", "standard", "high", "complex"] as const satisfies readonly PromptTier[];
|
|
19
22
|
|
|
20
23
|
export interface SubagentRole {
|
|
21
24
|
/** Stable id used in config + `/agents <id>`. */
|
|
@@ -362,9 +365,9 @@ export function clearSubagentSetting(config: Pick<Config, "subagents">, roleId:
|
|
|
362
365
|
return subs;
|
|
363
366
|
}
|
|
364
367
|
|
|
365
|
-
/** One pickable apply-target: the global default or a subagent role. */
|
|
368
|
+
/** One pickable apply-target: the global default, a prompt-routing tier, or a subagent role. */
|
|
366
369
|
export interface ApplyTargetChoice {
|
|
367
|
-
/** "default" or a subagent role id. */
|
|
370
|
+
/** "default", "routing:<tier>", or a subagent role id. */
|
|
368
371
|
value: string;
|
|
369
372
|
label: string;
|
|
370
373
|
/** Right-aligned hint: the target's CURRENT model (so the picker doubles as a viewer). */
|
|
@@ -378,18 +381,32 @@ export interface ApplyTargetChoice {
|
|
|
378
381
|
* read-and-change panel for existing role assignments. Pure — testable.
|
|
379
382
|
*/
|
|
380
383
|
export function applyTargetChoices(
|
|
381
|
-
config: Pick<Config, "defaultModel" | "subagents" | "thinkingLevel"
|
|
384
|
+
config: Pick<Config, "defaultModel" | "subagents" | "thinkingLevel" | "routing" | "roles"> &
|
|
385
|
+
Partial<Pick<Config, "providers" | "oauth" | "openaiBaseUrl">>,
|
|
382
386
|
): ApplyTargetChoice[] {
|
|
383
387
|
const roleThink = (id: string): string => {
|
|
384
388
|
const t = resolveSubagentThinking(id, config);
|
|
385
389
|
return t ? ` (${t})` : " (inherit)";
|
|
386
390
|
};
|
|
391
|
+
const routeThink = (tier: PromptTier): string => {
|
|
392
|
+
const t = config.routing?.tiers?.[tier]?.thinking;
|
|
393
|
+
return t ? ` (${t})` : " (auto thinking)";
|
|
394
|
+
};
|
|
395
|
+
// Lazy load keeps the existing subagents→prompt-router dependency from
|
|
396
|
+
// becoming an eager module cycle; resolveSubagentModel uses the same pattern.
|
|
397
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
398
|
+
const { resolveTierModel } = require("./prompt-router");
|
|
387
399
|
return [
|
|
388
400
|
{
|
|
389
401
|
value: "default",
|
|
390
402
|
label: "default — every session",
|
|
391
403
|
hint: `${config.defaultModel} (${config.thinkingLevel ?? "medium"})`,
|
|
392
404
|
},
|
|
405
|
+
...PROMPT_ROUTE_TIERS.map(tier => ({
|
|
406
|
+
value: `routing:${tier}`,
|
|
407
|
+
label: `route ${tier} — PromptRouter tier`,
|
|
408
|
+
hint: `${config.routing?.tiers?.[tier]?.model ?? resolveTierModel(tier, config)}${config.routing?.tiers?.[tier]?.model ? "" : " (auto)"}${routeThink(tier)}`,
|
|
409
|
+
})),
|
|
393
410
|
...allSubagentRoles(config).map(role => ({
|
|
394
411
|
value: role.id,
|
|
395
412
|
label: `subagent ${role.id} — ${role.title}`,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ProviderAdapter, CallOptions, ProviderName } from "../types";
|
|
2
2
|
import { anthropicAdapter } from "./anthropic";
|
|
3
|
+
import { relabelProviderError } from "./errors";
|
|
4
|
+
import { companyLabel } from "../model-catalog";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Factory for Anthropic-Messages-compatible providers (z.ai, MiniMax, …). They speak
|
|
@@ -11,17 +13,32 @@ import { anthropicAdapter } from "./anthropic";
|
|
|
11
13
|
*/
|
|
12
14
|
export function makeAnthropicCompatibleAdapter(opts: { name: ProviderName; baseUrl: string }): ProviderAdapter {
|
|
13
15
|
const prefix = `${opts.name}/`;
|
|
16
|
+
const label = companyLabel(opts.name);
|
|
14
17
|
const prep = (o: CallOptions): CallOptions => ({
|
|
15
18
|
...o,
|
|
16
19
|
model: o.model.startsWith(prefix) ? o.model.slice(prefix.length) : o.model,
|
|
17
20
|
baseUrl: o.baseUrl ?? opts.baseUrl,
|
|
18
21
|
});
|
|
22
|
+
// `anthropicAdapter` hardcodes "Anthropic" as the provider label on every thrown
|
|
23
|
+
// ProviderHttpError/ProviderStreamError — relabel to the REAL backend (e.g. "Tencent",
|
|
24
|
+
// "z.ai") so friendlyProviderError/the fallback classifier/the user all see the true
|
|
25
|
+
// account that needs attention (auth, billing, rate limit) instead of Anthropic's.
|
|
19
26
|
return {
|
|
20
27
|
name: opts.name,
|
|
21
28
|
supportsNativeTools: anthropicAdapter.supportsNativeTools,
|
|
22
|
-
call: (messages, options, credential) =>
|
|
29
|
+
call: async (messages, options, credential) => {
|
|
30
|
+
try {
|
|
31
|
+
return await anthropicAdapter.call(messages, prep(options), credential);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
throw relabelProviderError(err, label);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
23
36
|
async *stream(messages, options, credential) {
|
|
24
|
-
|
|
37
|
+
try {
|
|
38
|
+
yield* anthropicAdapter.stream!(messages, prep(options), credential);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
throw relabelProviderError(err, label);
|
|
41
|
+
}
|
|
25
42
|
},
|
|
26
43
|
};
|
|
27
44
|
}
|
|
@@ -12,12 +12,20 @@ export class ProviderHttpError extends Error {
|
|
|
12
12
|
readonly provider: string;
|
|
13
13
|
/** Server-directed backoff from a `Retry-After` header, in ms (if present). */
|
|
14
14
|
readonly retryAfterMs?: number;
|
|
15
|
+
/** Raw response body (or synthesized detail text) — kept separately so a caller can
|
|
16
|
+
* reconstruct this error under a DIFFERENT provider label (see `relabelProviderError`)
|
|
17
|
+
* without re-parsing it back out of the composed `.message`. */
|
|
18
|
+
readonly detail: string;
|
|
19
|
+
/** Optional context suffix (e.g. "(stream)") — see `detail`'s rationale. */
|
|
20
|
+
readonly context?: string;
|
|
15
21
|
constructor(provider: string, status: number, detail: string, context?: string, retryAfterMs?: number) {
|
|
16
22
|
super(`${provider} request failed (HTTP ${status})${context ? ` ${context}` : ""}: ${detail}`);
|
|
17
23
|
this.name = "ProviderHttpError";
|
|
18
24
|
this.status = status;
|
|
19
25
|
this.provider = provider;
|
|
20
26
|
this.retryAfterMs = retryAfterMs;
|
|
27
|
+
this.detail = detail;
|
|
28
|
+
this.context = context;
|
|
21
29
|
}
|
|
22
30
|
}
|
|
23
31
|
|
|
@@ -39,6 +47,10 @@ export class ProviderStreamError extends Error {
|
|
|
39
47
|
readonly status: number;
|
|
40
48
|
readonly provider: string;
|
|
41
49
|
readonly code?: string;
|
|
50
|
+
/** Raw stream-error message (pre-composition) — see `ProviderHttpError.detail`'s
|
|
51
|
+
* rationale; lets `relabelProviderError` reconstruct this error under a different
|
|
52
|
+
* provider label without re-parsing `.message`. */
|
|
53
|
+
readonly rawMessage: string;
|
|
42
54
|
/** `explicitStatus` lets a caller pass an ALREADY-numeric provider status (e.g. Google's
|
|
43
55
|
* `error.code` on a `google.rpc.Status` envelope — 429/500/503) instead of relying on the
|
|
44
56
|
* OpenAI-specific `code === "rate_limit_exceeded"` string heuristic below, which only
|
|
@@ -49,9 +61,31 @@ export class ProviderStreamError extends Error {
|
|
|
49
61
|
this.status = explicitStatus ?? (code === "rate_limit_exceeded" ? 429 : 500);
|
|
50
62
|
this.provider = provider;
|
|
51
63
|
this.code = code;
|
|
64
|
+
this.rawMessage = message;
|
|
52
65
|
}
|
|
53
66
|
}
|
|
54
67
|
|
|
68
|
+
/** Rebuild `err` under a DIFFERENT provider label — both `.provider` and the composed
|
|
69
|
+
* `.message` are corrected, not just the field (a caller matching on message text, or
|
|
70
|
+
* a user reading the raw error, must see the TRUE provider either way). Used by the
|
|
71
|
+
* Anthropic/OpenAI-compatible adapter factories (`makeAnthropicCompatibleAdapter`,
|
|
72
|
+
* `makeOpenAICompatibleAdapter`): they delegate to `anthropicAdapter`/`openaiAdapter`,
|
|
73
|
+
* which hardcode "Anthropic"/"OpenAI" at their `ProviderHttpError`/`ProviderStreamError`
|
|
74
|
+
* construction sites — so a groq/tencent/zai/deepseek/… failure previously surfaced as
|
|
75
|
+
* "Anthropic rejected the credential" or "OpenAI requires billing", sending the user to
|
|
76
|
+
* fix the WRONG account. Non-`ProviderHttpError`/`ProviderStreamError` errors (bare
|
|
77
|
+
* `Error`, network failures, etc.) pass through unchanged — those never carried a
|
|
78
|
+
* hardcoded label to begin with. */
|
|
79
|
+
export function relabelProviderError(err: unknown, provider: string): unknown {
|
|
80
|
+
if (err instanceof ProviderHttpError) {
|
|
81
|
+
return new ProviderHttpError(provider, err.status, err.detail, err.context, err.retryAfterMs);
|
|
82
|
+
}
|
|
83
|
+
if (err instanceof ProviderStreamError) {
|
|
84
|
+
return new ProviderStreamError(provider, err.rawMessage, err.code, err.status);
|
|
85
|
+
}
|
|
86
|
+
return err;
|
|
87
|
+
}
|
|
88
|
+
|
|
55
89
|
/**
|
|
56
90
|
* Parse a `Retry-After` header into ms. Supports the delta-seconds form
|
|
57
91
|
* (`"5"`) and the HTTP-date form (`"Wed, 21 Oct 2025 07:28:00 GMT"`).
|
package/src/ai/providers/kimi.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { getKimiCommonHeaders } from "../../auth/flows/kimi";
|
|
|
3
3
|
import type { CallOptions, ProviderAdapter } from "../types";
|
|
4
4
|
import { anthropicAdapter } from "./anthropic";
|
|
5
5
|
import { makeOpenAICompatibleAdapter } from "./openai-compatible";
|
|
6
|
+
import { relabelProviderError } from "./errors";
|
|
7
|
+
import { companyLabel } from "../model-catalog";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Kimi (Moonshot) — two credential-dependent backends (gjc parity):
|
|
@@ -18,6 +20,7 @@ export const KIMI_BASE_URL = "https://api.moonshot.ai/v1";
|
|
|
18
20
|
export const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding";
|
|
19
21
|
|
|
20
22
|
const openaiCompatKimi = makeOpenAICompatibleAdapter({ name: "kimi", baseUrl: KIMI_BASE_URL });
|
|
23
|
+
const KIMI_LABEL = companyLabel("kimi");
|
|
21
24
|
|
|
22
25
|
/** Route the `kimi/` prefix + OAuth base/headers onto the anthropic adapter. */
|
|
23
26
|
// ponytail: model ids pass through untranslated — the moonshot catalog ids (kimi-latest, …)
|
|
@@ -33,19 +36,31 @@ function prepOAuth(o: CallOptions): CallOptions {
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** Dispatch on credential kind: OAuth → Kimi Code Anthropic endpoint; else the
|
|
36
|
-
* original OpenAI-compatible moonshot adapter (API-key behavior unchanged).
|
|
39
|
+
* original OpenAI-compatible moonshot adapter (API-key behavior unchanged).
|
|
40
|
+
* The OAuth path delegates to `anthropicAdapter`, which hardcodes "Anthropic" at
|
|
41
|
+
* its `ProviderHttpError`/`ProviderStreamError` construction sites — relabel to
|
|
42
|
+
* "Moonshot" (same fix as `makeAnthropicCompatibleAdapter`/`makeOpenAICompatibleAdapter`)
|
|
43
|
+
* so a Kimi Code failure never sends the user to fix their Anthropic account. */
|
|
37
44
|
export const kimiAdapter: ProviderAdapter = {
|
|
38
45
|
name: "kimi",
|
|
39
46
|
supportsNativeTools: openaiCompatKimi.supportsNativeTools,
|
|
40
|
-
call: (messages, options, credential) =>
|
|
41
|
-
credential.kind
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
call: async (messages, options, credential) => {
|
|
48
|
+
if (credential.kind !== "oauth") return openaiCompatKimi.call(messages, options, credential);
|
|
49
|
+
try {
|
|
50
|
+
return await anthropicAdapter.call(messages, prepOAuth(options), credential);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw relabelProviderError(err, KIMI_LABEL);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
44
55
|
async *stream(messages, options, credential) {
|
|
45
|
-
if (credential.kind
|
|
46
|
-
yield* anthropicAdapter.stream!(messages, prepOAuth(options), credential);
|
|
47
|
-
} else {
|
|
56
|
+
if (credential.kind !== "oauth") {
|
|
48
57
|
yield* openaiCompatKimi.stream!(messages, options, credential);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
yield* anthropicAdapter.stream!(messages, prepOAuth(options), credential);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw relabelProviderError(err, KIMI_LABEL);
|
|
49
64
|
}
|
|
50
65
|
},
|
|
51
66
|
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ProviderAdapter, CallOptions, ProviderName } from "../types";
|
|
2
2
|
import type { Credential } from "../../auth";
|
|
3
3
|
import { openaiAdapter } from "./openai";
|
|
4
|
+
import { relabelProviderError } from "./errors";
|
|
5
|
+
import { companyLabel } from "../model-catalog";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Factory for OpenAI-compatible providers (LM Studio, xAI/Grok, …). They all speak
|
|
@@ -14,6 +16,7 @@ const KEYLESS: Credential = { kind: "none", provider: "openai" };
|
|
|
14
16
|
|
|
15
17
|
export function makeOpenAICompatibleAdapter(opts: { name: ProviderName; baseUrl: string; keyless?: boolean; thinkingFormat?: CallOptions["reasoningFormat"] }): ProviderAdapter {
|
|
16
18
|
const prefix = `${opts.name}/`;
|
|
19
|
+
const label = companyLabel(opts.name);
|
|
17
20
|
const prep = (o: CallOptions): CallOptions => ({
|
|
18
21
|
...o,
|
|
19
22
|
model: o.model.startsWith(prefix) ? o.model.slice(prefix.length) : o.model,
|
|
@@ -23,12 +26,26 @@ export function makeOpenAICompatibleAdapter(opts: { name: ProviderName; baseUrl:
|
|
|
23
26
|
reasoningFormat: o.reasoningFormat ?? opts.thinkingFormat,
|
|
24
27
|
});
|
|
25
28
|
const credFor = (c: Credential): Credential => (opts.keyless ? KEYLESS : c);
|
|
29
|
+
// `openaiAdapter` hardcodes "OpenAI" as the provider label on every thrown
|
|
30
|
+
// ProviderHttpError/ProviderStreamError — relabel to the REAL backend (e.g. "Groq",
|
|
31
|
+
// "DeepSeek") so friendlyProviderError/the fallback classifier/the user all see the
|
|
32
|
+
// true account that needs attention (auth, billing, rate limit) instead of OpenAI's.
|
|
26
33
|
return {
|
|
27
34
|
name: opts.name,
|
|
28
35
|
supportsNativeTools: openaiAdapter.supportsNativeTools,
|
|
29
|
-
call: (messages, options, credential) =>
|
|
36
|
+
call: async (messages, options, credential) => {
|
|
37
|
+
try {
|
|
38
|
+
return await openaiAdapter.call(messages, prep(options), credFor(credential));
|
|
39
|
+
} catch (err) {
|
|
40
|
+
throw relabelProviderError(err, label);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
30
43
|
async *stream(messages, options, credential) {
|
|
31
|
-
|
|
44
|
+
try {
|
|
45
|
+
yield* openaiAdapter.stream!(messages, prep(options), credFor(credential));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw relabelProviderError(err, label);
|
|
48
|
+
}
|
|
32
49
|
},
|
|
33
50
|
};
|
|
34
51
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `/route` slash-command handler extracted from launch.ts.
|
|
3
3
|
*
|
|
4
|
-
* Handles `/route [status|on|off|why]` (default/`status`: report the
|
|
5
|
-
* session routing state + the last decision, if any; `on`/`off`:
|
|
6
|
-
* SESSION-LOCAL override of `config.routing.enabled`; `why`: explain
|
|
7
|
-
* routing decision in detail
|
|
8
|
-
* (
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* Handles `/route [status|on|off|why|history]` (default/`status`: report the
|
|
5
|
+
* effective session routing state + the last decision, if any; `on`/`off`:
|
|
6
|
+
* toggle a SESSION-LOCAL override of `config.routing.enabled`; `why`: explain
|
|
7
|
+
* the last routing decision in detail; `history [n]`: print the last `n`
|
|
8
|
+
* (default 10) recorded decisions, one per line). This block shares mutable
|
|
9
|
+
* REPL state (`sessionRouteOverride`, `lastRouteDecision`, `routeHistory`)
|
|
10
|
+
* with `runTurn`'s own routing insertion, so the caller passes the current
|
|
11
|
+
* values in via an explicit context object and reads back a result object
|
|
12
|
+
* instead of this function closing over REPL state directly.
|
|
12
13
|
*
|
|
13
14
|
* Session-level toggle only — `sessionRouteOverride` is never persisted to
|
|
14
15
|
* `~/.jeo/config.json` (mirrors `/thinking` being session-only vs. `/model
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
import type { RouteDecision } from "../../agent/prompt-router";
|
|
21
|
+
import type { RouteHistoryEntry } from "../../agent/route-history";
|
|
20
22
|
|
|
21
23
|
export interface RouteSlashCtx {
|
|
22
24
|
sessionRouteOverride: boolean | undefined;
|
|
@@ -28,9 +30,13 @@ export interface RouteSlashCtx {
|
|
|
28
30
|
* never changes routing's actual gating logic (`!sessionModel` in launch.ts's
|
|
29
31
|
* `runTurn` stays the single source of truth for whether routing engages). */
|
|
30
32
|
pinnedModel?: string;
|
|
33
|
+
/** Chronological (oldest-first) snapshot of this session's `RouteHistory`
|
|
34
|
+
* (`routeHistory.getAll()`), read fresh by the caller for `/route history`. */
|
|
35
|
+
routeHistory: RouteHistoryEntry[];
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
|
|
39
|
+
|
|
34
40
|
export interface RouteSlashResult {
|
|
35
41
|
/** Present only when changed by "on"/"off". */
|
|
36
42
|
sessionRouteOverride?: boolean | undefined;
|
|
@@ -38,7 +44,7 @@ export interface RouteSlashResult {
|
|
|
38
44
|
lines: string[];
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
const USAGE = "Usage: /route [status|on|off|why]";
|
|
47
|
+
const USAGE = "Usage: /route [status|on|off|why|history [n]]";
|
|
42
48
|
|
|
43
49
|
function isRealDecision(decision: RouteDecision | { note: string } | null): decision is RouteDecision {
|
|
44
50
|
return !!decision && "model" in decision;
|
|
@@ -61,13 +67,25 @@ function explainDecision(decision: RouteDecision | { note: string } | null): str
|
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
70
|
+
* Format the last `n` history entries (default 10) as one line each:
|
|
71
|
+
* `turn N: tier -> model (source, confidence X.XX)`, oldest of the selected
|
|
72
|
+
* window first — matches `getAll()`'s chronological ordering.
|
|
73
|
+
*/
|
|
74
|
+
function formatHistory(entries: RouteHistoryEntry[], n: number): string[] {
|
|
75
|
+
if (entries.length === 0) return ["No routing decisions recorded yet this session."];
|
|
76
|
+
const selected = n > 0 ? entries.slice(Math.max(0, entries.length - n)) : [];
|
|
77
|
+
if (selected.length === 0) return ["No routing decisions recorded yet this session."];
|
|
78
|
+
return selected.map((e) => `turn ${e.turnNumber}: ${e.tier} -> ${e.model} (${e.source}, confidence ${e.confidence.toFixed(2)})`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handle `/route [status|on|off|why|history]`. Extracted for the same reason
|
|
83
|
+
* as `/model`: shares REPL-local routing state with `runTurn` via an
|
|
84
|
+
* explicit ctx/result object rather than closing over it.
|
|
67
85
|
*/
|
|
68
86
|
export function runRouteSlash(input: string, ctx: RouteSlashCtx): RouteSlashResult {
|
|
69
87
|
const rest = input.slice("/route".length).trim();
|
|
70
|
-
const [sub] = rest.split(/\s+/).filter(Boolean);
|
|
88
|
+
const [sub, arg] = rest.split(/\s+/).filter(Boolean);
|
|
71
89
|
const effective = ctx.sessionRouteOverride ?? ctx.routingConfigEnabled;
|
|
72
90
|
|
|
73
91
|
if (!sub || sub === "status") {
|
|
@@ -75,17 +93,21 @@ export function runRouteSlash(input: string, ctx: RouteSlashCtx): RouteSlashResu
|
|
|
75
93
|
if (isRealDecision(ctx.lastRouteDecision)) {
|
|
76
94
|
lines.push(`last decision: ${ctx.lastRouteDecision.tier} → ${ctx.lastRouteDecision.model} (${ctx.lastRouteDecision.source}: ${ctx.lastRouteDecision.signals.join(", ") || "none"}, confidence ${ctx.lastRouteDecision.confidence.toFixed(2)})`);
|
|
77
95
|
}
|
|
78
|
-
// A model pin (`--model`/`/model`)
|
|
79
|
-
// this session
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
// A model pin (`--model`/`/model`) blocks routing UNLESS the user explicitly ran
|
|
97
|
+
// `/route on` this session — that explicit toggle wins over a prior pin (see
|
|
98
|
+
// `routeOverridesPin` in launch.ts's `runTurn`) so routing actually evaluates
|
|
99
|
+
// every prompt as requested. Surface the distinction here, since "routing: on"
|
|
100
|
+
// read alone would otherwise be misleading about whether it's actually pinned.
|
|
101
|
+
if (ctx.pinnedModel && ctx.sessionRouteOverride !== true) {
|
|
102
|
+
lines.push(`note: model pinned to '${ctx.pinnedModel}' this session — routing will not evaluate any prompt until the pin is cleared (/model auto) or you run '/route on' to override the pin`);
|
|
103
|
+
} else if (ctx.pinnedModel && ctx.sessionRouteOverride === true) {
|
|
104
|
+
lines.push(`note: model pinned to '${ctx.pinnedModel}', but '/route on' overrides the pin — routing will evaluate every prompt`);
|
|
84
105
|
}
|
|
85
106
|
return { lines };
|
|
86
107
|
}
|
|
87
108
|
|
|
88
109
|
|
|
110
|
+
|
|
89
111
|
if (sub === "on") {
|
|
90
112
|
return { sessionRouteOverride: true, lines: ["routing: on (this session)"] };
|
|
91
113
|
}
|
|
@@ -98,5 +120,15 @@ export function runRouteSlash(input: string, ctx: RouteSlashCtx): RouteSlashResu
|
|
|
98
120
|
return { lines: explainDecision(ctx.lastRouteDecision) };
|
|
99
121
|
}
|
|
100
122
|
|
|
123
|
+
if (sub === "history") {
|
|
124
|
+
// Only a finite POSITIVE count selects a window; 0, negatives, and
|
|
125
|
+
// non-numeric args (`bogus`) all fall back to the default 10 rather than
|
|
126
|
+
// silently printing the empty-history message when decisions actually
|
|
127
|
+
// exist (that message must mean "nothing recorded", never "you asked for 0").
|
|
128
|
+
const parsed = arg !== undefined ? Number.parseInt(arg, 10) : 10;
|
|
129
|
+
const n = Number.isFinite(parsed) && parsed > 0 ? parsed : 10;
|
|
130
|
+
return { lines: formatHistory(ctx.routeHistory, n) };
|
|
131
|
+
}
|
|
132
|
+
|
|
101
133
|
return { lines: [`Unknown /route subcommand: ${sub}`, USAGE] };
|
|
102
134
|
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -52,6 +52,7 @@ import { describeModel, describeAllProviders, describeProvider, resolveProvider,
|
|
|
52
52
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
53
53
|
import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
|
|
54
54
|
import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, type PromptTier, type RouteDecision } from "../agent/prompt-router";
|
|
55
|
+
import { RouteHistory } from "../agent/route-history";
|
|
55
56
|
|
|
56
57
|
import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
|
|
57
58
|
|
|
@@ -659,6 +660,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
659
660
|
let sessionRouteOverride: boolean | undefined;
|
|
660
661
|
// Last routing decision (or the reason none applied) — /route why reads this.
|
|
661
662
|
let lastRouteDecision: RouteDecision | { note: string } | null = null;
|
|
663
|
+
// Bounded FIFO of this session's routing decisions — /route history reads this.
|
|
664
|
+
// Same lifecycle scope as `lastRouteDecision`: one instance per REPL session,
|
|
665
|
+
// never persisted to config.
|
|
666
|
+
const routeHistory = new RouteHistory();
|
|
662
667
|
// Computer-use session override: undefined = follow config.computer.enabled, true/false = /computer on|off wins this session.
|
|
663
668
|
let sessionComputerOverride: boolean | undefined;
|
|
664
669
|
|
|
@@ -1025,6 +1030,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1025
1030
|
}
|
|
1026
1031
|
}
|
|
1027
1032
|
lastRouteDecision = routed ?? (routeVetoNote ? { note: routeVetoNote } : routingEnabled && (routeOverridesPin || !sessionModel) ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
|
|
1033
|
+
// Only a REAL decision (not a `{note}` placeholder) is worth recording —
|
|
1034
|
+
// `/route history` exists to show what actually routed, not every turn's
|
|
1035
|
+
// gating outcome.
|
|
1036
|
+
if (routed) routeHistory.add(routed);
|
|
1028
1037
|
// Explicit /model always wins UNLESS an explicit `/route on` this session opted
|
|
1029
1038
|
// back into per-prompt routing (`routeOverridesPin`); otherwise `routed` is only
|
|
1030
1039
|
// ever computed when `!sessionModel`.
|
|
@@ -1465,9 +1474,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1465
1474
|
contextTokens = catalogMetadata(activeModel)?.contextTokens;
|
|
1466
1475
|
fallbackBaseDecision = { ...fallbackBaseDecision, model: activeModel };
|
|
1467
1476
|
lastRouteDecision = { ...fallbackBaseDecision, warning: `model '${previousModel}' ${routedFailureReason}; switched to equivalent '${activeModel}'` };
|
|
1477
|
+
// Post-call fallback: the pre-call `routed` pick (already recorded above,
|
|
1478
|
+
// if any) got superseded mid-turn — record the decision that ACTUALLY
|
|
1479
|
+
// served the turn so `/route history`/`why` reflect reality, not a
|
|
1480
|
+
// since-abandoned pre-call guess.
|
|
1481
|
+
routeHistory.add(lastRouteDecision);
|
|
1468
1482
|
const notice = `[route] model '${previousModel}' ${routedFailureReason}; switching to equivalent '${activeModel}' for this turn.`;
|
|
1469
|
-
if (tui)
|
|
1470
|
-
|
|
1483
|
+
if (tui) {
|
|
1484
|
+
const { provider: fallbackProvider } = await describeModel(activeModel, turnConfig);
|
|
1485
|
+
tui.events().onModelSwitch?.(activeModel, fallbackProvider);
|
|
1486
|
+
tui.events().onNotice?.(notice);
|
|
1487
|
+
} else {
|
|
1488
|
+
console.log(notice);
|
|
1489
|
+
}
|
|
1471
1490
|
result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
|
|
1472
1491
|
}
|
|
1473
1492
|
|
|
@@ -4435,6 +4454,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4435
4454
|
routingConfigEnabled: !!cfgNow.routing?.enabled,
|
|
4436
4455
|
lastRouteDecision,
|
|
4437
4456
|
pinnedModel: sessionModel,
|
|
4457
|
+
routeHistory: routeHistory.getAll(),
|
|
4438
4458
|
});
|
|
4439
4459
|
if (routeResult.sessionRouteOverride !== undefined) sessionRouteOverride = routeResult.sessionRouteOverride;
|
|
4440
4460
|
for (const line of routeResult.lines) console.log(line);
|
package/src/tui/app.ts
CHANGED
|
@@ -92,6 +92,13 @@ export interface AgentEventsLike {
|
|
|
92
92
|
* item). The TUI ignores these; launch.ts uses them to persist the final reply's artifacts. */
|
|
93
93
|
onReasoningArtifactStream?(artifact: import("../ai/types").ReasoningArtifact): void;
|
|
94
94
|
onBudget?(limit: number, reason: string): void;
|
|
95
|
+
/** Fired when the active model changes mid-turn (post-call equivalent-pool fallback,
|
|
96
|
+
* launch.ts's `routeFallbackAttempt` loop) — updates the footer's model/provider
|
|
97
|
+
* label so the status bar reflects the model that actually serves the REST of the
|
|
98
|
+
* turn, instead of staying stuck on the pre-fallback model for the remainder of the
|
|
99
|
+
* render (the backend `lastRouteDecision`/`routeHistory` were already correct; only
|
|
100
|
+
* this footer label lagged). */
|
|
101
|
+
onModelSwitch?(model: string, provider?: string): void;
|
|
95
102
|
|
|
96
103
|
}
|
|
97
104
|
|
|
@@ -696,6 +703,17 @@ export class LaunchTui {
|
|
|
696
703
|
this.appendLedger(dim(`${mark} ${reason}`) + "\n", "notice");
|
|
697
704
|
this.draw();
|
|
698
705
|
},
|
|
706
|
+
onModelSwitch: (model: string, provider?: string) => {
|
|
707
|
+
// Mid-turn equivalent-pool fallback: the footer's model/provider label was
|
|
708
|
+
// frozen at construction time otherwise, so a post-call reroute (the model
|
|
709
|
+
// that ACTUALLY produces the rest of the reply) never showed in the status
|
|
710
|
+
// bar for the remainder of the turn — even though lastRouteDecision/
|
|
711
|
+
// routeHistory were already correct. Mutate the SAME footer object the
|
|
712
|
+
// render loop reads every frame; no separate re-render path needed.
|
|
713
|
+
this.footer.model = model;
|
|
714
|
+
this.footer.provider = provider;
|
|
715
|
+
this.draw();
|
|
716
|
+
},
|
|
699
717
|
onUsage: (u: { inputTokens: number; outputTokens: number }) => {
|
|
700
718
|
// Live cumulative token usage for the turn — shown in the final summary
|
|
701
719
|
// (and available to the footer meter).
|
|
@@ -1438,6 +1456,33 @@ export class LaunchTui {
|
|
|
1438
1456
|
* block shows only the most-recent lines, capped at ~30% of the screen height (a
|
|
1439
1457
|
* ceiling guards a tall terminal), so it grows with the stream and shrinks with the
|
|
1440
1458
|
* viewport. Returns [] when there is nothing to show. */
|
|
1459
|
+
/** gjc-parity multi-line parallel-subagent panel: when a fan-out `task` batch has
|
|
1460
|
+
* MORE THAN ONE concurrent slot live, render one status line PER active slot
|
|
1461
|
+
* (most-recently-touched first) instead of collapsing everything into a single
|
|
1462
|
+
* "+N more running" summary line (see `currentActivity()`). A single/detached
|
|
1463
|
+
* subagent still uses the plain status-line path — this panel exists specifically
|
|
1464
|
+
* to make a truly PARALLEL batch visibly read as parallel: every worker's latest
|
|
1465
|
+
* activity is on screen at once, capped so a large fan-out can't blow the frame. */
|
|
1466
|
+
private static readonly SUBAGENT_PANEL_MAX_ROWS = 6;
|
|
1467
|
+
private renderSubagentPanel(cols: number): string[] {
|
|
1468
|
+
if (this.subagentLiveSlots.size <= 1) return [];
|
|
1469
|
+
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
1470
|
+
const accent = this.theme.color ? accentPaint(this.theme) : (s: string) => s;
|
|
1471
|
+
const spin = this.theme.color ? accent(this.spinner.current()) : this.spinner.current();
|
|
1472
|
+
const order = [...this.subagentLiveOrder].reverse();
|
|
1473
|
+
const shown = order.slice(0, LaunchTui.SUBAGENT_PANEL_MAX_ROWS);
|
|
1474
|
+
const extra = order.length - shown.length;
|
|
1475
|
+
const wrapW = Math.max(8, cols - 4);
|
|
1476
|
+
const lines = shown.map(slot => {
|
|
1477
|
+
const raw = this.subagentLiveSlots.get(slot) ?? "";
|
|
1478
|
+
return ` ${spin} ${dim(truncateToWidth(raw, wrapW))}`;
|
|
1479
|
+
});
|
|
1480
|
+
const header = dim(`parallel · ${this.subagentLiveSlots.size} running`);
|
|
1481
|
+
const rows = [header, ...lines];
|
|
1482
|
+
if (extra > 0) rows.push(dim(` … +${extra} more running`));
|
|
1483
|
+
return rows;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1441
1486
|
private renderLiveBlock(label: string, text: string, cols: number, rows: number, ceiling: number, cacheKey = label): string[] {
|
|
1442
1487
|
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
1443
1488
|
if (!text.trim()) return [];
|
|
@@ -1616,6 +1661,14 @@ export class LaunchTui {
|
|
|
1616
1661
|
if (this.runningTool && this.liveToolOutput.trim()) {
|
|
1617
1662
|
tail.push(...this.renderLiveBlock("Output", this.liveToolOutput, cols, rows, 8));
|
|
1618
1663
|
}
|
|
1664
|
+
// Parallel subagent panel (gjc parity): a fan-out `task` batch with more than one
|
|
1665
|
+
// concurrent slot live gets its OWN multi-line block — one row per active worker —
|
|
1666
|
+
// so a genuinely parallel batch reads as parallel instead of a single rotating line.
|
|
1667
|
+
if (this.subagentActive) {
|
|
1668
|
+
tail.push(...this.renderSubagentPanel(cols));
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
|
|
1619
1672
|
|
|
1620
1673
|
// Live status field: unboxed thinking line + compact metrics row. The model's
|
|
1621
1674
|
// streamed activity is uniform across providers via streamingActivity and keeps
|
|
@@ -1778,9 +1831,16 @@ export class LaunchTui {
|
|
|
1778
1831
|
const phase = (this.tickCount * 0.05) % 1;
|
|
1779
1832
|
const grad = themeGradient(this.theme, idx);
|
|
1780
1833
|
const palette = [grad.from, grad.to];
|
|
1781
|
-
|
|
1782
1834
|
if (fit) {
|
|
1783
1835
|
bottom.push("");
|
|
1836
|
+
// Live status field: unboxed thinking line + compact metrics row. The
|
|
1837
|
+
// streamed activity is uniform across providers, with the ⟦esc⟧ cancel hint
|
|
1838
|
+
// right-aligned and no misleading step counter.
|
|
1839
|
+
if (this.subagentActive) {
|
|
1840
|
+
for (const line of this.renderSubagentPanel(innerWidth)) bottom.push(line);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
|
|
1784
1844
|
if (this.turnTitle) {
|
|
1785
1845
|
const arrow = this.unicode ? "▸" : ">";
|
|
1786
1846
|
const titleLine = ` ${arrow} ${this.turnTitle}`;
|
|
@@ -43,7 +43,7 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
|
|
|
43
43
|
{ command: "/logout", usage: "/logout <anthropic|openai|gemini|antigravity>", description: "Remove the stored OAuth token for a provider", group: "models" },
|
|
44
44
|
{ command: "/roles", usage: "/roles [tier model]", description: "Show or set model role tiers (smol/slow/plan)", group: "models" },
|
|
45
45
|
{ command: "/thinking", usage: "/thinking [level]", description: "Show or set thinking budget (low/medium/high/xhigh)", group: "models" },
|
|
46
|
-
{ command: "/route", usage: "/route [on|off|why]", description: "Show/toggle prompt-based model routing for this session,
|
|
46
|
+
{ command: "/route", usage: "/route [on|off|why|history [n]]", description: "Show/toggle prompt-based model routing for this session, explain the last routing decision, or list recent routing history", group: "models" },
|
|
47
47
|
{ command: "/agents", usage: "/agents [edit|role] [model|#N|thinking L|maxSteps N|reset]", description: "List subagent roles; use /agents edit for the interactive picker or pin role model/settings", group: "subagents" },
|
|
48
48
|
{ command: "/subagent", usage: "/subagent [role]", description: "Show the current subagent composition (per-role model · thinking · steps); alias of /agents", group: "subagents" },
|
|
49
49
|
{ command: "/view", usage: "/view <file> [a-b]", description: "Render a file with line numbers + light highlight", group: "code" },
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { isUsageLimitError, isRefusalError, isConnectionError, ConnectionContextError } from "./retry";
|
|
8
8
|
import { companyLabel } from "../ai/model-catalog";
|
|
9
|
+
import { ProviderHttpError, ProviderStreamError } from "../ai/providers/errors";
|
|
9
10
|
// Re-export: engine.ts and callers import the refusal predicate from here; the
|
|
10
11
|
// definition lives in retry.ts so defaultRetryable can fail fast without a cycle.
|
|
11
12
|
export { isRefusalError } from "./retry";
|
|
@@ -15,8 +16,8 @@ export { isRefusalError } from "./retry";
|
|
|
15
16
|
* mismatch) — when the PROVIDER says the prompt doesn't fit, the loop can react
|
|
16
17
|
* (reactive trim + one retry) instead of dying on an opaque 400 (round-6 #4). */
|
|
17
18
|
export function isContextOverflowError(err: unknown): boolean {
|
|
18
|
-
const msg =
|
|
19
|
-
const status = (err
|
|
19
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
20
|
+
const status = statusOf(err);
|
|
20
21
|
const pattern = /context[ _-]?length|context window|prompt is too long|input is too long|too many tokens|maximum (input|context)|exceeds.{0,30}(context|token)/i;
|
|
21
22
|
if (pattern.test(msg)) return true;
|
|
22
23
|
return status === 413; // payload-too-large is always an overflow signal
|
|
@@ -32,6 +33,43 @@ function formatDuration(ms: number): string {
|
|
|
32
33
|
return rem ? `~${hours}h ${rem}m` : `~${hours}h`;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/** Narrow an unknown error to one carrying a numeric `.status` field — covers the
|
|
37
|
+
* known structured classes AND ad-hoc errors a caller/test attaches `.status` to
|
|
38
|
+
* directly (`Object.assign(new Error(...), { status: 413 })`), matching the prior
|
|
39
|
+
* permissive cast-based read without an unchecked `as`. */
|
|
40
|
+
function hasNumericStatus(err: unknown): err is { status: number } {
|
|
41
|
+
return (
|
|
42
|
+
typeof err === "object" &&
|
|
43
|
+
err !== null &&
|
|
44
|
+
"status" in err &&
|
|
45
|
+
typeof err.status === "number"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True provider label for an error, when it's known: `ProviderHttpError`/
|
|
50
|
+
* `ProviderStreamError` always carry the ACTUAL backend's DISPLAY label (already
|
|
51
|
+
* `companyLabel`'d at every throw site — see `relabelProviderError`'s callers), so
|
|
52
|
+
* use it as-is rather than guessing from message text. `undefined` for anything else
|
|
53
|
+
* (bare `Error`, a re-wrapped doneReason string, …), letting the caller fall back to
|
|
54
|
+
* the regex-based guess below. */
|
|
55
|
+
function knownErrorProvider(err: unknown): string | undefined {
|
|
56
|
+
if (err instanceof ProviderHttpError || err instanceof ProviderStreamError) {
|
|
57
|
+
return err.provider;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function statusOf(err: unknown): number | undefined {
|
|
63
|
+
if (err instanceof ProviderHttpError || err instanceof ProviderStreamError) {
|
|
64
|
+
return err.status;
|
|
65
|
+
}
|
|
66
|
+
return hasNumericStatus(err) ? err.status : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function retryAfterMsOf(err: unknown): number | undefined {
|
|
70
|
+
return err instanceof ProviderHttpError ? err.retryAfterMs : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
35
73
|
export function friendlyProviderError(err: unknown): string {
|
|
36
74
|
if (err instanceof ConnectionContextError) {
|
|
37
75
|
const provider = companyLabel(err.provider);
|
|
@@ -45,23 +83,32 @@ export function friendlyProviderError(err: unknown): string {
|
|
|
45
83
|
if (isConnectionError(err)) {
|
|
46
84
|
return `Could not connect to the provider. Check the configured base URL, your network connection, or switch model with /model.`;
|
|
47
85
|
}
|
|
48
|
-
const msg =
|
|
49
|
-
const status = (err
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
86
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
87
|
+
const status = statusOf(err);
|
|
88
|
+
// Prefer the error's OWN carried provider (accurate even for compat backends like
|
|
89
|
+
// Tencent/groq that speak Anthropic's/OpenAI's wire protocol — see
|
|
90
|
+
// `relabelProviderError`); only guess from message text for errors that never
|
|
91
|
+
// carried a `.provider` field (bare Error, a re-wrapped doneReason string, …).
|
|
92
|
+
const provider = knownErrorProvider(err) ?? (
|
|
93
|
+
/antigravity/i.test(msg)
|
|
94
|
+
? "Antigravity"
|
|
95
|
+
: /anthropic/i.test(msg)
|
|
96
|
+
? "Anthropic"
|
|
97
|
+
: /openai/i.test(msg)
|
|
98
|
+
? "OpenAI"
|
|
99
|
+
: /gemini|google/i.test(msg)
|
|
100
|
+
? "Gemini"
|
|
101
|
+
: "the provider"
|
|
102
|
+
);
|
|
59
103
|
|
|
60
104
|
if (isUsageLimitError(err)) {
|
|
61
105
|
return `${provider} usage/quota limit reached — this window will not clear in seconds, so auto-retry was skipped. Switch model with /model (e.g. a local ollama model), use another provider, or wait for the limit window to reset.`;
|
|
62
106
|
}
|
|
107
|
+
if (status === 402 || /\b402\b/.test(msg) || /payment required|insufficient credit|billing (?:is )?not enabled|free trial quota/i.test(msg)) {
|
|
108
|
+
return `${provider} requires billing/payment on this account (HTTP 402) — free trial quota exhausted or postpaid billing not enabled. Enable billing on the ${provider} account, or switch model with /model.`;
|
|
109
|
+
}
|
|
63
110
|
if (status === 429 || /\b429\b/.test(msg) || /rate[ _]?limit/i.test(msg)) {
|
|
64
|
-
const retryAfterMs = (err
|
|
111
|
+
const retryAfterMs = retryAfterMsOf(err);
|
|
65
112
|
const retry = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs > 0
|
|
66
113
|
? ` Server requested retry after ${formatDuration(retryAfterMs)}.`
|
|
67
114
|
: "";
|
|
@@ -70,7 +117,7 @@ export function friendlyProviderError(err: unknown): string {
|
|
|
70
117
|
if (status === 401 || status === 403 || /\b40[13]\b/.test(msg)) {
|
|
71
118
|
return `${provider} rejected the credential (HTTP ${status ?? "401/403"}). Run 'jeo auth status', re-login with /provider login <name>, and for Antigravity prefer '/provider login antigravity' (gemini login only works when the Cloud Code Assist backend authorizes that token).`;
|
|
72
119
|
}
|
|
73
|
-
if ((err
|
|
120
|
+
if ((err instanceof Error && err.name === "TimeoutError") || /the operation timed out/i.test(msg)) {
|
|
74
121
|
return `${provider} did not complete the request within the call timeout (default 30min). This is expected for a HIGH/XHIGH-reasoning-effort completion that legitimately runs long on a hard problem — raise JEO_CALL_TIMEOUT_MS (ms) if this recurs, or lower the thinking level with /model.`;
|
|
75
122
|
}
|
|
76
123
|
if (/exceeded the overall deadline \(JEO_STREAM_MAX_MS\)/i.test(msg)) {
|