jeo-code 0.7.49 → 0.7.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.7.51] - 2026-07-06
10
+ _Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole._
11
+
12
+ ### Fixed
13
+ - **Credential-readiness gate on the routed model** (`src/commands/launch.ts`'s `runTurn`) — right after `routePrompt` resolves a decision, the SAME `describeProvider` readiness check `/roles`'s live-picker path and `jeo doctor` already use now verifies the routed model's provider actually has a usable credential. An unready provider vetoes the routed decision entirely (falls back to `sessionModel || defaultModel`, exactly as if routing hadn't engaged) and surfaces a one-time `[route] routed to '<model>' (<provider>) but that provider has no usable credential — falling back to the default model this turn. Run 'jeo auth login <provider>' or reconfigure routing.tiers/roles…` notice (keyed per-provider via the existing `warnOnce`, so a persistent misconfiguration doesn't spam every turn — the veto itself still silently applies every turn). `/route why` reflects the actual fallback reason instead of a phantom routed decision that was never really dispatched.
14
+
15
+ ### Verified
16
+ - `bun run typecheck` clean; `bun test` 2649 pass / 0 fail across 280 files — 2 consecutive full-suite runs, both clean.
17
+ - `test/launch-prompt-routing.test.ts` (+5 tests, 11 total in file): routed tier resolves to a credential-less provider → falls back to `defaultModel` (whose provider IS credentialed) instead of dispatching to the unreachable model; the veto notice names the attempted model, its provider, and the exact `jeo auth login <provider>` remediation; the gate does NOT false-positive when the routed provider IS credentialed (routing engages normally, no notice); `warnOnce` suppresses the notice on a second turn hitting the same unready provider while the veto keeps applying every turn regardless; `/route why` after a veto explains the actual fallback that was used, not the vetoed decision.
18
+ - Fixing 4 pre-existing tests in the same file that had never configured a credential for their `claude-haiku-4-5`/`claude-sonnet-4-6` test fixtures (they passed before only because nothing previously checked credential readiness) also independently proves the gate: adding `providers: { anthropic: "test-anthropic-key" }` to those configs is what made them pass again, confirming the new check is real and load-bearing, not a no-op.
19
+
20
+ ## [0.7.50] - 2026-07-06
21
+ _Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change._
22
+
23
+ ### Added
24
+ - **`jeo doctor` routing diagnostic** (`src/commands/doctor.ts`) — when `routing.enabled` is true but `roles.smol` is unset, `routePrompt`'s LLM-escalation path silently never fires every turn (it degrades to heuristic-only tier resolution, the same paradox `routePrompt` itself already guards against at runtime via its one-time `warnOnce`). `jeo doctor` now surfaces this proactively at onboarding time instead of only mid-session: a `routing: { enabled, smolConfigured, note? }` block in `--json` output, and a yellow `[routing]` note (or a terse green confirmation when `roles.smol` IS configured) printed after the OAuth section, before the final verdict. Purely informational — never affects the `ready`/`strict` exit-code gate.
25
+
26
+ ### Fixed
27
+ - **Prompt-cache-key collision on mid-session model switch** (`src/agent/prompt-router.ts`'s new `deriveCacheSessionKey`, wired into `src/commands/launch.ts`'s `runTurn`) — `sessionKey` (forwarded as `prompt_cache_key`/`session_id` to provider requests) was the bare `sessionId`, unchanged across turns. Provider-side prompt caches are keyed PER MODEL, so when `routePrompt` switches `activeModel` between turns in the same session (its entire reason for existing), every switch silently guaranteed a cache miss on the new model — undermining the exact cost/latency savings routing exists to deliver. The derived key is now `` `${sessionId}:${activeModel}` ``: stable (same key) across consecutive turns on the same model, distinct (different key) the instant the model changes, with zero effect on `--no-session` (still forwards `undefined` unchanged).
28
+
29
+ ### Verified
30
+ - `bun run typecheck` clean; `bun test` 2644 pass / 0 fail across 280 files — 2 consecutive full-suite runs, both clean.
31
+ - `test/doctor.test.ts` (+5 tests, 7 total): routing off → no `routing` block; routing on + `roles.smol` unset → note present with escalation-skip wording (both `--json` and human-output paths); routing on + `roles.smol` set → no note, `smolConfigured: true`; human-output note never flips the `ready`/`strict` verdict.
32
+ - `test/prompt-router.test.ts` (+4 tests, 36 total): `deriveCacheSessionKey` pure-function unit tests — same session+model → same key, different model → different key, key format, `--no-session` (undefined sessionId) short-circuit.
33
+ - `test/launch-prompt-routing.test.ts` (+2 tests, 6 total): real end-to-end proof through `runLaunchCommand` with a genuine persisted session (not `--no-session`) — two turns routed to the SAME model produce the SAME `sessionKey` (cache reuse preserved), two turns routed to DIFFERENT models produce DIFFERENT `sessionKey`s sharing the same session-id prefix (no false cross-model cache hit, still same session lineage).
34
+ - Independent verification pass (separate subagent, read-only, no code touched) re-derived every §0–§8 claim in the design doc directly from git history (`ff7816a^` pre-PromptRouter vs. current HEAD) rather than trusting the doc's own text — confirmed the core architecture claims, design-principle-5 tool-isolation guarantee (exhaustive grep: `RouteDecision` has no tool/permission field, zero cross-references between `routing.` and any subagent-toolset code), and the bilingual heuristic test corpus; also independently ran `graphify` itself (not grep proxies) to reproduce 3 of 5 named hub-degree figures from the doc almost exactly, while flagging that 2 of the doc's supporting dependency-direction claims (`state.ts`, `model-catalog*.ts` importing `model-manager.ts`) do not hold under direct import inspection.
35
+
9
36
  ## [0.7.49] - 2026-07-06
10
37
  _gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression._
11
38
 
package/README.ja.md CHANGED
@@ -259,11 +259,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
259
259
  ## 変更履歴 (Changelog)
260
260
 
261
261
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
262
+ - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
263
+ - **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
262
264
  - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
263
265
  - **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
264
266
  - **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
265
- - **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
266
- - **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
267
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -260,11 +260,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
260
260
  ## 변경 이력 (Changelog)
261
261
 
262
262
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
263
+ - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
264
+ - **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
263
265
  - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
264
266
  - **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
265
267
  - **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
266
- - **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
267
- - **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
268
268
 
269
269
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
270
270
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -262,11 +262,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
262
262
  ## Changelog
263
263
 
264
264
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
265
+ - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
266
+ - **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
265
267
  - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
266
268
  - **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
267
269
  - **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
268
- - **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
269
- - **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
270
270
 
271
271
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
272
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -259,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
259
259
  ## 更新日志 (Changelog)
260
260
 
261
261
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
262
+ - **[0.7.51]** (2026-07-06) — Closes a real correctness gap the user directly identified by asking "does routing only ever pick from models the user can actually use?" — the honest answer at v0.7.50 was no: `routePrompt` resolves a tier's model purely from config (`routing.tiers`/`roles.smol`/`roles.slow`/`defaultModel`) and never checks whether that model's provider has a usable credential. A user could configure `roles.smol`/`roles.slow` to a provider they never logged into (or whose OAuth token expired since setup), and routing would silently turn a WORKING session into a FAILING turn — `resolveCall` throwing `"No credential for provider …"` — the instant it engaged, even though the turn would have succeeded unrouted. Routing is opt-in specifically so it can never make things worse than routing being off; this closes that hole.
263
+ - **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
262
264
  - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
263
265
  - **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
264
266
  - **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
265
- - **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
266
- - **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
267
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.49",
3
+ "version": "0.7.51",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -271,3 +271,21 @@ export async function routePrompt(
271
271
  return null;
272
272
  }
273
273
  }
274
+
275
+ /**
276
+ * Derive the provider-side prompt-cache correlation key for a turn (forwarded as
277
+ * `sessionKey` -> `prompt_cache_key`/`session_id` in loop.ts/model-manager.ts/
278
+ * openai-responses.ts). Provider-side prompt caches are keyed PER MODEL — reusing
279
+ * the bare `sessionId` across a mid-session model switch (routePrompt changing
280
+ * `activeModel` turn to turn) would send the same cache-correlation key to a
281
+ * DIFFERENT model/provider, guaranteeing a cache miss on every switch and silently
282
+ * undermining the cost/latency savings routing exists for (design doc §7 risk #4).
283
+ * Scoping the key to `${sessionId}:${model}` gives each model its own cache
284
+ * lineage within the session: same model across turns -> same key (cache reuse
285
+ * preserved), different model -> different key (no false cross-model cache hit).
286
+ * Deliberately excludes any turn counter/timestamp — the key must stay STABLE
287
+ * across consecutive turns that keep the same model.
288
+ */
289
+ export function deriveCacheSessionKey(sessionId: string, model: string): string {
290
+ return `${sessionId}:${model}`;
291
+ }
@@ -170,6 +170,19 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
170
170
  const defaultProvider = resolveProvider(resolvedModel);
171
171
  const ollamaBase = config.ollamaBaseUrl ?? "http://localhost:11434";
172
172
 
173
+ // Routing diagnostic (design doc §7 risk #2): routing.enabled without roles.smol
174
+ // means routePrompt()'s LLM-escalation path silently never fires — resolveRoleModel
175
+ // falls back to defaultModel, which is exactly the "cheap escalation call is actually
176
+ // the expensive default model" paradox routePrompt() itself guards against at runtime
177
+ // (see prompt-router.ts's warnOnce/SMOL_UNCONFIGURED_WARNING_KEY). Surface it here
178
+ // proactively, at onboarding time, instead of only mid-session on the first qualifying
179
+ // turn.
180
+ const routingEnabled = !!config.routing?.enabled;
181
+ const smolConfigured = !!config.roles?.smol;
182
+ const routingNote = routingEnabled && !smolConfigured
183
+ ? "routing is enabled but roles.smol is unset — LLM escalation for ambiguous prompts will never fire; heuristic-only tier resolution applies every turn. Set roles.smol to enable escalation."
184
+ : undefined;
185
+
173
186
  // --- Gather (probes run concurrently → ~1× the slowest timeout, not N×) ---
174
187
  const probes: { name: string; credKind: string; result: ProbeResult }[] = [];
175
188
  const cloud = ["anthropic", "openai", "gemini"] as AuthProvider[];
@@ -235,6 +248,7 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
235
248
  })),
236
249
  oauth: oauthHealth,
237
250
  ready,
251
+ ...(routingEnabled ? { routing: { enabled: true, smolConfigured, ...(routingNote ? { note: routingNote } : {}) } } : {}),
238
252
  };
239
253
  console.log(JSON.stringify(report, null, 2));
240
254
  if (strict && !ready) process.exit(1);
@@ -276,6 +290,15 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
276
290
  for (const line of oauthLines) console.log(line);
277
291
  console.log("");
278
292
  }
293
+ // Routing diagnostic: informational only — never affects ready/strict exit logic.
294
+ if (routingEnabled) {
295
+ if (routingNote) {
296
+ console.log(`${chalk.yellow("[routing]")} ${routingNote}`);
297
+ } else {
298
+ console.log(`${chalk.green("[routing]")} enabled, roles.smol configured — LLM escalation available on ambiguous prompts.`);
299
+ }
300
+ console.log("");
301
+ }
279
302
 
280
303
  // Final verdict
281
304
  if (ready) {
@@ -45,10 +45,10 @@ import { callLlm, type Message } from "../agent/loop";
45
45
  import { friendlyProviderError } from "../util/provider-error";
46
46
  import { readGlobalConfig, saveConfigPatch, resolveWikiRoot } from "../agent/state";
47
47
  import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
48
- import { describeModel, describeAllProviders, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId } from "../ai";
48
+ import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId } from "../ai";
49
49
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
50
50
  import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
51
- import { routePrompt, type RouteDecision } from "../agent/prompt-router";
51
+ import { routePrompt, deriveCacheSessionKey, warnOnce, type RouteDecision } from "../agent/prompt-router";
52
52
 
53
53
  import { listAliases } from "../ai/model-registry";
54
54
  import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/openai-compatible-catalog";
@@ -884,13 +884,52 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
884
884
  // that write silently switch THIS running session's model mid-run. Per-session model
885
885
  // selection must stay session-local: running sessions never influence each other.
886
886
  const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
887
- const routed = routingEnabled && !sessionModel
887
+ let routed = routingEnabled && !sessionModel
888
888
  ? await routePrompt(userInput, turnConfig, { hasImages: !!images?.length }).catch(() => null)
889
889
  : null;
890
- lastRouteDecision = routed ?? (routingEnabled && !sessionModel ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
890
+ // Fail-open credential gate: `routePrompt` only resolves a tier's model from
891
+ // config (routing.tiers/roles.smol/roles.slow/defaultModel) — it deliberately
892
+ // stays pure/config-only (see its narrowed `Pick<Config,...>` param) and never
893
+ // checks whether that model's provider actually has a usable credential. A user
894
+ // can configure `roles.smol`/`roles.slow`/`routing.tiers.*.model` to a provider
895
+ // they never logged into (or whose OAuth token expired since setup), and without
896
+ // this gate that misconfiguration would silently turn a WORKING session
897
+ // (defaultModel) into a FAILING turn ("No credential for provider …") the
898
+ // instant routing engages — even though the turn would have succeeded unrouted.
899
+ // Uses the SAME `describeProvider` readiness check `/roles`'s live-picker path
900
+ // and `jeo doctor` already use — routing must never make a turn worse than
901
+ // routing being off, matching the "fail-open everywhere" contract documented at
902
+ // the top of prompt-router.ts. Nulling `routed` here (rather than patching its
903
+ // `.model`) lets the EXISTING `activeModel`/`lastRouteDecision` fallback lines
904
+ // below handle the rest unchanged.
905
+ let routeCredentialNotice: string | undefined;
906
+ let routeVetoNote: string | undefined;
907
+ if (routed) {
908
+ const routedProvider = resolveProvider(routed.model);
909
+ const status = await describeProvider(routedProvider, turnConfig);
910
+ if (!status.ready) {
911
+ routeVetoNote = `routed model '${routed.model}' (${routedProvider}) has no usable credential this turn — fell back to '${sessionModel || defaultModel}'`;
912
+ routeCredentialNotice = warnOnce(
913
+ `prompt-router:provider-not-ready:${routedProvider}`,
914
+ `[route] routed to '${routed.model}' (${routedProvider}) but that provider has no usable credential — falling back to the default model this turn. Run 'jeo auth login ${routedProvider}' or reconfigure routing.tiers/roles for a provider you're logged into.`,
915
+ );
916
+ routed = null;
917
+ }
918
+ }
919
+ lastRouteDecision = routed ?? (routeVetoNote ? { note: routeVetoNote } : routingEnabled && !sessionModel ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
891
920
  // Explicit /model always wins: `routed` is only ever computed when `!sessionModel`.
892
921
  const activeModel = routed?.model ?? (sessionModel || defaultModel);
893
922
  const activeThinking = routed?.thinking ?? sessionThinking;
923
+ // Provider-side prompt caches are keyed PER MODEL — reusing the bare `sessionId`
924
+ // across a mid-session model switch (routePrompt changing activeModel turn to
925
+ // turn) sends the same cache-correlation key to a different model/provider,
926
+ // guaranteeing a cache miss on every switch and silently undermining routing's
927
+ // own cost/latency goal (design doc §7 risk #4). Scope the key to the resolved
928
+ // model so each model keeps its own stable cache lineage within the session.
929
+ // `sessionId` is undefined only under `--no-session` — in that case there is no
930
+ // provider-side cache lineage to protect anyway, so forward `undefined` unchanged
931
+ // (matches the prior behavior of `sessionKey: sessionId` for that mode).
932
+ const turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
894
933
  const contextTokens = catalogMetadata(activeModel)?.contextTokens;
895
934
 
896
935
  // Resolve provider + dirty count up front — both are cheap and feed the live
@@ -899,7 +938,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
899
938
  const { provider: activeProvider } = await describeModel(activeModel, turnConfig);
900
939
  const turnDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
901
940
  const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: activeThinking }) : null;
902
- if (routed?.warning) {
941
+ if (routeCredentialNotice) {
942
+ if (tui) tui.events().onNotice?.(routeCredentialNotice);
943
+ else console.log(routeCredentialNotice);
944
+ } else if (routed?.warning) {
903
945
  if (tui) tui.events().onNotice?.(routed.warning);
904
946
  else console.log(routed.warning);
905
947
  }
@@ -1201,7 +1243,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1201
1243
  maxTokens: resolveMaxOutputTokens(activeModel, activeThinking),
1202
1244
  reasoningEffort: activeThinking ? thinkingToReasoningEffort(activeThinking) : undefined,
1203
1245
  signal: ac.signal,
1204
- sessionKey: sessionId,
1246
+ sessionKey: turnSessionKey,
1205
1247
  steer: drainSteer,
1206
1248
  events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
1207
1249
  });
@@ -1221,7 +1263,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1221
1263
  maxTokens: resolveMaxOutputTokens(activeModel, activeThinking),
1222
1264
  reasoningEffort: activeThinking ? thinkingToReasoningEffort(activeThinking) : undefined,
1223
1265
  signal: ac.signal,
1224
- sessionKey: sessionId,
1266
+ sessionKey: turnSessionKey,
1225
1267
  steer: drainSteer,
1226
1268
  events: wrapEvents(withToolDetailCapture(tui ? tui.events() : streamEvents), opik),
1227
1269
  });