jeo-code 0.7.51 → 0.7.54

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,47 @@ 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.54] - 2026-07-06
10
+ _Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead._
11
+
12
+ ### Fixed
13
+ - **`streamMaxMs()`/`callTimeoutMs()` shared default raised 300s → 30min** (`src/ai/model-manager.ts`'s `DEFAULT_CALL_TIMEOUT_MS`) — rather than reverting to opt-in-off (which would reopen a real gap for direct non-turn-wrapped callers like `jeo chat`, since `JEO_TURN_MAX_MS`'s turn-level stall-budget backstop only wraps `runAgentLoop` steps), the shared ceiling now matches `turnMaxMs()`'s own already-vetted 30min default AND the OBSERVED ~20-30min infra-side connection-duration cap on OpenAI's Codex/ChatGPT backend (a real boundary already handled elsewhere in this file as a retryable mid-stream socket close) — a real, evidenced ceiling, not an arbitrary pick. `JEO_STREAM_MAX_MS`/`JEO_CALL_TIMEOUT_MS` env overrides (including `0` to disable the overall stream deadline) are unchanged. The per-chunk idle watchdog (`JEO_STREAM_IDLE_MS`, still 300s) is untouched — this fix only raises the OVERALL ceiling that fires despite continuous activity, not the dead-stream detector.
14
+ - **`friendlyProviderError` now names the exact env var to raise** for both failure surfaces instead of a bare timeout string — a non-streaming `TimeoutError`/`"the operation timed out"` DOMException points at `JEO_CALL_TIMEOUT_MS`, and the stream overall-deadline message points at `JEO_STREAM_MAX_MS` (mentioning `0` disables it), both noting this is expected behavior for a deliberately long HIGH/XHIGH reasoning completion rather than an opaque failure.
15
+
16
+ ### Verified
17
+ - `bun run typecheck` clean; targeted suite (`round-b`, `provider-error`, `provider-error-taxonomy`, `cycle-and-turn-budget`, `retry`, `engine`, `transient-network-recovery`, `refusal-recovery`) 119/119 pass; full suite 2665/2665 pass across 280 files.
18
+ - `test/round-b.test.ts`: new regression test proves an ACTIVELY-emitting stream running past the OLD 300s threshold completes normally instead of being force-aborted (compressed-timescale reproduction of the exact reported failure mode); `streamMaxMs`/`callTimeoutMs` default-value tests updated to the new 30min figure; existing keepalive-forever-stream and healthy-stream tests re-confirmed unaffected.
19
+ - `test/provider-error.test.ts` (+2): a bare `TimeoutError` DOMException maps to `JEO_CALL_TIMEOUT_MS` guidance (raw DOMException text replaced); the stream overall-deadline message maps to `JEO_STREAM_MAX_MS` guidance including the disable-via-0 hint.
20
+ - `docs/deep-dive-stream-idle.md` gained a follow-up section documenting this regression's exact causal chain against the original (now partially superseded) investigation, so the "why not an overall cap" reasoning that got silently reversed in v0.7.42 stays discoverable.
21
+
22
+ ## [0.7.53] - 2026-07-06
23
+ _Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time._
24
+
25
+ ### Added
26
+ - **`jeo doctor` proactive routing-credential diagnostic** (`src/commands/doctor.ts`) — for every tier PromptRouter could actually route to (`routing.tiers.trivial.model` or `roles.smol`, `routing.tiers.standard.model` when explicitly set, `routing.tiers.complex.model` or `roles.slow`), doctor now resolves the model's provider and checks credential readiness with the SAME `describeProvider` call `launch.ts`'s runtime gate uses — surfacing "routing.tiers.\<tier\> resolves to '\<model\>' (\<provider\>) which has no usable credential" at `jeo doctor` time instead of only reactively mid-session on the first qualifying turn. `standard` is only checked when explicitly configured, since its unconfigured fallback is `defaultModel`, already covered by the existing provider-connectivity probes.
27
+
28
+ ### Changed
29
+ - **`jeo doctor --json`'s `routing` block: singular `note` → `notes` array** — the prior shape could only ever represent the one "roles.smol unconfigured" case; the new `notes: string[]` (omitted when empty, same as before) represents zero, one, or several simultaneous routing diagnostics (e.g. `roles.smol` set to an uncredentialed provider AND `routing.tiers.complex` also uncredentialed) without collapsing them into one string. Purely additive to `jeo doctor`'s informational output — never affects the `ready`/`--strict` exit code, matching the existing routing-diagnostic contract.
30
+
31
+ ## [0.7.52] - 2026-07-06
32
+ _Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code._
33
+
34
+ ### Added
35
+ - **Persistent routed-tier marker in the status bar** (`src/tui/components/status.ts`'s `StatusBarData.routedTier`/`renderStatusBar`, threaded through `LaunchTuiOptions`/`LaunchTui` in `src/tui/app.ts` and `src/commands/launch.ts`'s `runTurn`) — when PromptRouter picks THIS turn's model, a `⚡tier` marker (`~tier` in ASCII mode) renders right after the model name in the always-visible model bar, e.g. `claude-haiku-4-5 ⚡trivial`. Unlike the existing `[route] …` console notice (which intentionally stays silent for routine tier resolutions and only fires for interesting/escalated cases), the status-bar marker is unconditional whenever routing actually chose the model this turn — omitted when routing is off, the model is pinned, or the v0.7.51 credential-readiness gate vetoed the routed decision back to the default.
36
+ - **Subagent thinking visibility** (`TaskSubEvent`'s new `"thinking"` kind in `src/agent/task-tool.ts`, wired through `runSubagentOnce`'s `onReasoningStream` callback and handled in `LaunchTui.onSubagentEvent`) — a delegated subagent's own native extended-thinking stream now surfaces as a live, per-slot preview in the TUI (mirrors the main turn's dimmed "Thinking" block, scoped to that subagent's row instead of a single shared region), tail-sliced and whitespace-collapsed the same way tool/step previews already are. Transient only: never persisted to the ledger/scrollback (matches how the main turn's live reasoning preview also never appears in the final record), and the non-TTY streaming log (`formatTaskSubEvent` in `src/commands/launch/stream.ts`) explicitly no-ops on `"thinking"` instead of falling through to a bogus `"ROLE done: <reasoning text>"` line.
37
+
38
+ ### Fixed
39
+ - **`retry.failFastStatuses`/`retry.failFastPatterns` silently stripped from every saved config** (`src/agent/config-schema.ts`) — both fields existed on `Config.retry` (`state.ts`) and were actively read by `src/ai/model-manager.ts` to decide non-retryable errors, but were never declared on the NESTED `retry` `z.object`. Only the outer `ConfigSchema` calls `.passthrough()`; Zod's default strip-unknown-keys behavior silently dropped both fields on every config read/write that round-tripped through `parseConfig`, even though `config.json` faithfully stored them on disk — a user-configured fail-fast rule would quietly stop applying the moment anything else touched the config file. Fixed by declaring both fields on the nested object directly.
40
+ - **Ponytail-mode dead-code sweep** (six files, five independent findings, all confirmed zero-call-site via `grep` before deletion): `src/agent/output-util.ts` lost `TOOL_SPILL_THRESHOLD`, `truncateToolOutput`, `spillToolResult`, and `logPerformanceMetric` (the last of these means `.jeo/state/performance-metrics.json` is never actually written by anything in this codebase — `PerfMetric`'s read-side in `dev/self-analysis.ts` is kept as the interface contract, but the writer was dead weight, not a working feature); `src/agent/ts-language-service.ts` lost `resetLanguageServiceCache` (no caller); `src/agent/task-tool.ts` lost the `@deprecated` `TASK_TOOL_PROTOCOL_LINE` static snapshot (superseded by `taskToolProtocolLine(config)`); `src/agent/step-budget.ts` simplified a no-op `key.replace(/^JEO_/, "JEO_")` (replacing a prefix with itself) in both `envNum` and `envSet` to a plain lookup; `src/agent/opik-tracer.ts` removed a `try/finally` around `onStep` whose `finally` block was an empty comment with no actual cleanup; `src/ai/model-manager.ts`'s `composeAbort` dropped an unreachable manual-polyfill branch for `AbortSignal.any` (native since Bun 1.1.13; `src/cli.ts` hard-enforces `engines.bun: ">=1.3.14"` at process start), cutting it from 17 lines to 2 with identical behavior for every real input.
41
+
42
+ ### Verified
43
+ - `bun run typecheck` clean; `bun test` 2660 pass / 0 fail across 280 files on first run; a second full run reproduced 1 pre-existing flake (`test/hooks.test.ts`'s "disabled hooks never run", isolated-run-clean 3/3 — a cross-file `process.env.JEO_CONFIG_DIR` race under full-suite concurrency, not a regression from any change in this release).
44
+ - `test/status-bar.test.ts` (+4 tests): `routedTier` renders the `⚡` marker right after the model and before thinking; omitted entirely when routing didn't engage; ASCII fallback uses `~`; all three tier values (`trivial`/`standard`/`complex`) render verbatim.
45
+ - `test/tui-app.test.ts` (+3): `LaunchTui` threads `routedTier` into the persistent inline model bar and omits it when unset; `onSubagentEvent` with kind `"thinking"` drives the live per-slot preview but is never captured in the final scrollback record.
46
+ - `test/task-tool.test.ts` (+2): a subagent whose mocked `callLlm` invokes `onReasoning` emits live `"thinking"` events tagged to its own slot/index; a subagent with no reasoning stream emits zero `"thinking"` events (no false positives).
47
+ - `test/config-schema.test.ts` (+2, regression-focused): `retry.failFastStatuses`/`failFastPatterns` now survive `parseConfig` round-trip; a non-integer entry in `failFastStatuses` is rejected. The bug was confirmed empirically (bypassing the fix, both fields stripped silently) before applying it.
48
+ - Every dead-code deletion re-verified zero call sites via `grep` across `src/` and `test/` immediately before removal; the env-signal-composition simplifications (`composeAbort`, `envNum`/`envSet`) are exercised indirectly through existing abort-signal tests (`test/engine.test.ts`, `test/retry.test.ts`, `test/transient-network-recovery.test.ts`) and env-override tests (`test/step-budget.test.ts`), all passing unchanged — proving each simplification is behavior-preserving, not just "looks safe."
49
+
9
50
  ## [0.7.51] - 2026-07-06
10
51
  _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
52
 
package/README.ja.md CHANGED
@@ -78,8 +78,8 @@ jeo --tmux # 独立した tmux セッションで実行
78
78
  | `/model` · `/provider` | モデル/プロバイダ選択; `/model` でデフォルト/ロールバッジ、Ralph 風の入れ子ロール・thinking 選択、OpenAI Codex ロールプリセットを一つの流れで設定 |
79
79
  | `/provider login <name>` · `/logout` | 入力欄から OAuth ログイン/ログアウト |
80
80
  | `/agents [role]` · `/subagent` | ロール別(executor/planner/architect/critic)モデル・thinking・ステップ設定 |
81
- | `/thinking [level]` | デフォルト推論予算(minimal…xhigh)の表示/設定 |
82
- | `/fast [on|off|status]` | 現在のモデルが minimal/low 推論をサポートする場合に fast thinking モードを切替 |
81
+ | `/thinking [level]` | デフォルト推論予算(low…xhigh)の表示/設定 |
82
+ | `/fast [on|off|status]` | 現在のモデルが low 推論をサポートする場合に fast thinking モードを切替 |
83
83
  | `/skill` · `$<skill> [intent]` | ワークフロースキルの一覧/実行(`$team "task"` 形式) |
84
84
  | `/view` · `/diff` · `/find` · `/search` | コード表示、git diff、ファイル/パターン検索 |
85
85
  | `/new` · `/resume` · `/sessions` | セッション管理 |
@@ -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.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
263
+ - **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
264
+ - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
262
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.
263
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.
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.
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.
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.
267
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -79,8 +79,8 @@ jeo --tmux # 독립 tmux 세션에서 실행
79
79
  | `/model` · `/provider` | 모델/프로바이더 선택; `/model`에서 기본/역할 배지, Ralph식 하위 리스트 역할·thinking 선택, OpenAI Codex 역할 프리셋을 한 흐름으로 설정 |
80
80
  | `/provider login <name>` · `/logout` | 입력창에서 OAuth 로그인/로그아웃 |
81
81
  | `/agents [role]` · `/subagent` | 역할별(executor/planner/architect/critic) 모델·thinking·스텝 구성 |
82
- | `/thinking [level]` | 기본 추론 예산(minimal…xhigh) 조회/설정 |
83
- | `/fast [on|off|status]` | 현재 모델이 minimal/low 추론을 지원하면 fast thinking 모드를 켜고 끔 |
82
+ | `/thinking [level]` | 기본 추론 예산(low…xhigh) 조회/설정 |
83
+ | `/fast [on|off|status]` | 현재 모델이 low 추론을 지원하면 fast thinking 모드를 켜고 끔 |
84
84
  | `/skill` · `$<skill> [intent]` | 워크플로 스킬 목록/실행(`$team "작업"` 스타일) |
85
85
  | `/view` · `/diff` · `/find` · `/search` | 코드 보기, git diff, 파일/패턴 검색 |
86
86
  | `/new` · `/resume` · `/sessions` | 세션 관리 |
@@ -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.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
264
+ - **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
265
+ - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
263
266
  - **[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
267
  - **[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
- - **[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
- - **[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
- - **[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
268
 
269
269
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
270
270
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -79,8 +79,8 @@ Inside the `jeo` REPL (Tab autocompletes; `/` opens the palette).
79
79
  | `/model` · `/provider` | Pick model/provider; `/model` shows default/role badges, Ralph-style nested Set-as-role thinking choices, and the OpenAI Codex role preset in one flow |
80
80
  | `/provider login <name>` · `/logout` | OAuth login/logout from the input box |
81
81
  | `/agents [role]` · `/subagent` | Per-role (executor/planner/architect/critic) model · thinking · step config |
82
- | `/thinking [level]` | Show/set default reasoning budget (minimal…xhigh) |
83
- | `/fast [on|off|status]` | Toggle fast thinking mode when the active model advertises minimal/low reasoning |
82
+ | `/thinking [level]` | Show/set default reasoning budget (low…xhigh) |
83
+ | `/fast [on|off|status]` | Toggle fast thinking mode when the active model advertises low reasoning |
84
84
  | `/skill` · `$<skill> [intent]` | List/run workflow skills (`$team "task"` style) |
85
85
  | `/view` · `/diff` · `/find` · `/search` | Code view, git diff, file/pattern search |
86
86
  | `/new` · `/resume` · `/sessions` | Session management |
@@ -214,8 +214,10 @@ JEO_TUI_THEME=cosmic # cosmic/matrix/solar/red-claw/blue-crab/mono/au
214
214
  JEO_TUI_ALT_SCREEN=1 # legacy alt-screen turn (default: inline scrollback)
215
215
  JEO_STEP_BASE=24 # dynamic step budget: rolling base
216
216
  JEO_STEP_HARD_CAP=600 # absolute termination guarantee
217
- JEO_STREAM_MAX_MS=300000 # opt-in overall stream deadline (default off; bounds slow-drip streams)
217
+ JEO_STREAM_MAX_MS=1800000 # overall stream deadline (default 30min; bounds slow-drip streams, not active ones); 0 disables
218
218
  JEO_STREAM_IDLE_MS=300000 # per-chunk idle cap (default 300s); raise for slow/local backends silent before first token
219
+ JEO_CALL_TIMEOUT_MS=1800000 # non-streaming call wall cap (default 30min; compaction/subagents/goal-verify)
220
+ JEO_TURN_MAX_MS=1800000 # turn stall budget: max time WITHOUT tool progress (default 30min); 0 disables
219
221
  JEO_TOOL_OUTPUT_MAX=4000 # model-visible tool output cap (full output spills to artifacts)
220
222
  ```
221
223
 
@@ -262,11 +264,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
262
264
  ## Changelog
263
265
 
264
266
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
267
+ - **[0.7.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
268
+ - **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
269
+ - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
265
270
  - **[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
271
  - **[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.
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.
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.
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.
270
272
 
271
273
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
274
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -78,8 +78,8 @@ jeo --tmux # 在独立 tmux 会话中运行
78
78
  | `/model` · `/provider` | 选择模型/提供商;`/model` 在一个流程内显示默认/角色徽章、Ralph 风格嵌套角色·thinking 选择与 OpenAI Codex 角色预设 |
79
79
  | `/provider login <name>` · `/logout` | 在输入框内 OAuth 登录/登出 |
80
80
  | `/agents [role]` · `/subagent` | 按角色(executor/planner/architect/critic)配置模型·thinking·步数 |
81
- | `/thinking [level]` | 查看/设置默认推理预算(minimal…xhigh) |
82
- | `/fast [on|off|status]` | 当前模型支持 minimal/low 推理时切换 fast thinking 模式 |
81
+ | `/thinking [level]` | 查看/设置默认推理预算(low…xhigh) |
82
+ | `/fast [on|off|status]` | 当前模型支持 low 推理时切换 fast thinking 模式 |
83
83
  | `/skill` · `$<skill> [intent]` | 列出/运行工作流技能(`$team "任务"` 风格) |
84
84
  | `/view` · `/diff` · `/find` · `/search` | 代码查看、git diff、文件/模式搜索 |
85
85
  | `/new` · `/resume` · `/sessions` | 会话管理 |
@@ -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.54]** (2026-07-06) — Root-cause fix for a live-reported GPT-5.5 (Codex/ChatGPT OAuth backend) failure: "Error: stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted". Deep-dive traced this to a REGRESSION in v0.7.42 — a separate, correct fix for a genuinely-different bug (a connected-but-never-terminating stream) silently flipped `streamMaxMs()`'s default from opt-in-off to an ALWAYS-ON 300s overall wall-clock cap, which then killed any HIGH/XHIGH-reasoning-effort completion (GPT-5.5/o3-class) whose ACTIVELY-emitting generation legitimately ran past 5 minutes — OpenAI's own guidance states xhigh trades latency for depth by design. The non-streaming `call()` path (used by every subagent, compaction, and goal-verify call, since none wire `onModelStream`) carried the IDENTICAL bug with zero idle/activity tracking at all, exposed under a different, unhelpful raw `DOMException: The operation timed out.` message instead.
263
+ - **[0.7.53]** (2026-07-06) — Verified PromptRouter end-to-end (63 pre-existing tests re-run, all passing; a live `jeo doctor --json`/human-output smoke test confirms real wiring, not just mocks) and closed the one genuine remaining gap: the v0.7.51 per-turn credential-readiness veto protected a running session reactively, but nothing caught a misconfigured `roles.smol`/`roles.slow`/`routing.tiers.*.model` proactively at setup time.
264
+ - **[0.7.52]** (2026-07-06) — Two TUI visibility asks answered directly ("어느 모델이 라우팅됐는지 상시 표시" — persistently show which model routing picked, and "각 서브에이전트의 사고 과정도 보이게" — surface each subagent's own thinking) plus a Ponytail-mode minimal-code audit pass across six files that found one genuine correctness bug and five instances of dead/unreachable code.
262
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.
263
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.
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.
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.
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.
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.51",
3
+ "version": "0.7.54",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -122,7 +122,7 @@ export const ConfigSchema = z
122
122
  * regardless of project/cwd. A leading `~` is expanded; env `JEO_WIKI_ROOT`
123
123
  * overrides. Consumed by `resolveWikiRoot` and injected into the prompt. */
124
124
  wikiRoot: z.string().optional(),
125
- thinkingLevel: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional(),
125
+ thinkingLevel: z.enum(["low", "medium", "high", "xhigh"]).optional(),
126
126
  modelAliases: z.record(z.string()).optional(),
127
127
  /** Most-recently-selected models, newest first (drives the default + pickers). */
128
128
  recentModels: z.array(z.string()).optional(),
@@ -140,6 +140,18 @@ export const ConfigSchema = z
140
140
  rateLimitRetries: z.number().int().min(0).optional(),
141
141
  rateLimitMinDelayMs: z.number().int().min(0).optional(),
142
142
  rateLimitMaxServerDelayMs: z.number().int().min(0).optional(),
143
+ /** HTTP statuses to treat as NON-retryable even when defaultRetryable would
144
+ * retry them (e.g. pin 503 to fail fast instead of riding the backoff ladder).
145
+ * Bug fix: this field (and failFastPatterns below) existed on Config.retry in
146
+ * state.ts and was actively read by model-manager.ts, but was never declared
147
+ * on THIS nested z.object — since only the OUTER ConfigSchema calls
148
+ * `.passthrough()`, Zod's default strip-unknown-keys behavior silently dropped
149
+ * both fields on every config read/write that round-tripped through this
150
+ * schema, even though the user's config.json faithfully stored them on disk. */
151
+ failFastStatuses: z.array(z.number().int()).optional(),
152
+ /** Case-insensitive substrings; an error whose message matches any of these
153
+ * fails fast (non-retryable) even when the chosen predicate would retry it. */
154
+ failFastPatterns: z.array(z.string()).optional(),
143
155
  })
144
156
  .optional(),
145
157
  /**
@@ -155,7 +167,7 @@ export const ConfigSchema = z
155
167
  provider: z.enum(["anthropic", "openai", "gemini", "antigravity", "ollama"]).optional(),
156
168
  maxSteps: z.number().int().min(1).optional(),
157
169
  /** Per-role reasoning budget; absent = inherit the global thinkingLevel. */
158
- thinking: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional(),
170
+ thinking: z.enum(["low", "medium", "high", "xhigh"]).optional(),
159
171
  // ─── Custom-role declaration (SYSTEM-driven registry) ───
160
172
  // An entry under a NON-bundled id that sets any of these becomes a
161
173
  // first-class subagent role at runtime — no code change required.
@@ -189,9 +201,9 @@ export const ConfigSchema = z
189
201
  confidenceThreshold: z.number().min(0).max(1).optional(),
190
202
  tiers: z
191
203
  .object({
192
- trivial: z.object({ model: z.string().optional(), thinking: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional() }).optional(),
193
- standard: z.object({ model: z.string().optional(), thinking: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional() }).optional(),
194
- complex: z.object({ model: z.string().optional(), thinking: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional() }).optional(),
204
+ trivial: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
205
+ standard: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
206
+ complex: z.object({ model: z.string().optional(), thinking: z.enum(["low", "medium", "high", "xhigh"]).optional() }).optional(),
195
207
  })
196
208
  .optional(),
197
209
  })
@@ -250,8 +262,52 @@ function normalizeModelId(id: string | undefined, aliases: Record<string, string
250
262
  return trimmed;
251
263
  }
252
264
 
265
+ /** Migrate the retired "minimal" thinking level to "low" on every field that could carry
266
+ * it, BEFORE schema validation. Without this, a config.json persisted while "minimal"
267
+ * was still valid (thinkingLevel, any subagents.*.thinking, any routing.tiers.*.thinking)
268
+ * would fail the tightened enum and readGlobalConfig's schema-invalid path resets the
269
+ * ENTIRE config to defaults (modelAliases/subagents/routing/retry/hooks/notifications
270
+ * all silently lost, not just the one bad field) — see state.ts's salvageCredentials doc
271
+ * comment for why only oauth/providers survive that path. Mutates a shallow-cloned
272
+ * object only where needed; non-object/malformed input passes through untouched so the
273
+ * schema's own validation still reports the real error. */
274
+ function migrateMinimalThinking(raw: unknown): unknown {
275
+ if (!raw || typeof raw !== "object") return raw;
276
+ const cfg = raw as Record<string, unknown>;
277
+ let changed = false;
278
+ const out: Record<string, unknown> = { ...cfg };
279
+ if (out.thinkingLevel === "minimal") { out.thinkingLevel = "low"; changed = true; }
280
+ if (out.subagents && typeof out.subagents === "object") {
281
+ const subs = out.subagents as Record<string, unknown>;
282
+ const nextSubs: Record<string, unknown> = { ...subs };
283
+ for (const [roleId, role] of Object.entries(subs)) {
284
+ if (role && typeof role === "object" && (role as Record<string, unknown>).thinking === "minimal") {
285
+ nextSubs[roleId] = { ...(role as Record<string, unknown>), thinking: "low" };
286
+ changed = true;
287
+ }
288
+ }
289
+ if (changed) out.subagents = nextSubs;
290
+ }
291
+ if (out.routing && typeof out.routing === "object") {
292
+ const routing = out.routing as Record<string, unknown>;
293
+ if (routing.tiers && typeof routing.tiers === "object") {
294
+ const tiers = routing.tiers as Record<string, unknown>;
295
+ const nextTiers: Record<string, unknown> = { ...tiers };
296
+ let tiersChanged = false;
297
+ for (const [tierId, tier] of Object.entries(tiers)) {
298
+ if (tier && typeof tier === "object" && (tier as Record<string, unknown>).thinking === "minimal") {
299
+ nextTiers[tierId] = { ...(tier as Record<string, unknown>), thinking: "low" };
300
+ tiersChanged = true;
301
+ }
302
+ }
303
+ if (tiersChanged) { out.routing = { ...routing, tiers: nextTiers }; changed = true; }
304
+ }
305
+ }
306
+ return changed ? out : raw;
307
+ }
308
+
253
309
  export function parseConfig(raw: unknown): { ok: true; config: ValidatedConfig } | { ok: false; message: string } {
254
- const result = ConfigSchema.safeParse(raw);
310
+ const result = ConfigSchema.safeParse(migrateMinimalThinking(raw));
255
311
  if (result.success) {
256
312
  const config = result.data;
257
313
  const aliases = config.modelAliases || {};
@@ -337,7 +337,7 @@ export function wrapEvents(events: AgentLoopEvents | undefined, tracer: OpikTrac
337
337
  onStep(step: number) {
338
338
  currentStep = step;
339
339
  stepStartedAt = Date.now();
340
- try { base.onStep?.(step); } finally { /* tracer has no per-onStep write */ }
340
+ base.onStep?.(step);
341
341
  },
342
342
  onAssistant(raw: string, invocation: ToolInvocation | null) {
343
343
  base.onAssistant?.(raw, invocation);
@@ -1,64 +1,9 @@
1
- import * as fs from "node:fs/promises";
2
- import * as path from "node:path";
3
-
4
- export const TOOL_SPILL_THRESHOLD = 32_000;
5
-
6
- export function truncateToolOutput(output: string, limit = 8000): string {
7
- if (output.length <= limit) return output;
8
- const half = Math.floor(limit / 2);
9
- return output.slice(0, half) + "\n\n... (elided " + (output.length - limit) + " chars) ...\n\n" + output.slice(-half);
10
- }
11
-
12
- export async function spillToolResult(tool: string, output: string, cwd: string): Promise<string> {
13
- const artifactsDir = path.join(cwd, ".jeo", "artifacts");
14
- await fs.mkdir(artifactsDir, { recursive: true });
15
-
16
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
17
- const filename = "result-" + tool + "-" + ts + ".txt";
18
- const fullPath = path.join(artifactsDir, filename);
19
-
20
- await fs.writeFile(fullPath, output, "utf-8");
21
- return path.relative(cwd, fullPath);
22
- }
23
-
24
- /** One tool execution's performance record (consumed by dev self-analysis). */
1
+ /** One tool execution's performance record (consumed by dev self-analysis's read
2
+ * path — `.jeo/state/performance-metrics.json`, currently never populated by
3
+ * any writer in this codebase; the shape is kept as the read-side contract). */
25
4
  export interface PerfMetric {
26
5
  tool: string;
27
6
  duration: number;
28
7
  success: boolean;
29
8
  error?: string;
30
9
  }
31
-
32
- /** Append a tool performance metric to `.jeo/state/performance-metrics.json`
33
- * (bounded to the most recent 200 records). Best-effort: failures are ignored
34
- * so metrics can never break an agent turn. */
35
- export async function logPerformanceMetric(cwd: string, metric: PerfMetric): Promise<void> {
36
- try {
37
- const dir = path.join(cwd, ".jeo", "state");
38
- const file = path.join(dir, "performance-metrics.json");
39
- await fs.mkdir(dir, { recursive: true });
40
- let records: PerfMetric[] = [];
41
- try {
42
- const parsed = JSON.parse(await fs.readFile(file, "utf-8"));
43
- if (Array.isArray(parsed)) records = parsed;
44
- } catch { /* first write or corrupt file → start fresh */ }
45
- records.push(metric);
46
- if (records.length > 200) records = records.slice(-200);
47
- await fs.writeFile(file, JSON.stringify(records), "utf-8");
48
- } catch { /* best-effort */ }
49
- }
50
-
51
- /** Read spec-kit project context (`.specify/memory/constitution.md`) when the repo
52
- * was initialized with GitHub spec-kit, so the agent honors the project
53
- * constitution. Returns null when absent/unreadable; content is capped. */
54
- export async function loadSpecKitContext(cwd: string): Promise<string | null> {
55
- try {
56
- const file = path.join(cwd, ".specify", "memory", "constitution.md");
57
- const content = (await fs.readFile(file, "utf-8")).trim();
58
- if (!content) return null;
59
- const capped = content.length > 4000 ? content.slice(0, 4000) + "…" : content;
60
- return "\n\n<spec-kit-constitution>\n" + capped + "\n</spec-kit-constitution>";
61
- } catch {
62
- return null;
63
- }
64
- }
@@ -76,7 +76,7 @@ export interface Config {
76
76
  };
77
77
  };
78
78
 
79
- thinkingLevel?: "minimal" | "low" | "medium" | "high" | "xhigh";
79
+ thinkingLevel?: "low" | "medium" | "high" | "xhigh";
80
80
  /** Friendly model aliases, e.g. { fast: "ollama/qwen2.5:0.5b" }. Override built-ins. */
81
81
  modelAliases?: { [alias: string]: string };
82
82
  /** Most-recently-selected models, newest first (MRU; head == defaultModel). */
@@ -119,7 +119,7 @@ export interface Config {
119
119
  [roleId: string]: {
120
120
  model?: string;
121
121
  maxSteps?: number;
122
- thinking?: "minimal" | "low" | "medium" | "high" | "xhigh";
122
+ thinking?: "low" | "medium" | "high" | "xhigh";
123
123
  title?: string;
124
124
  description?: string;
125
125
  prompt?: string;
@@ -137,9 +137,9 @@ export interface Config {
137
137
  enabled?: boolean;
138
138
  confidenceThreshold?: number;
139
139
  tiers?: {
140
- trivial?: { model?: string; thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" };
141
- standard?: { model?: string; thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" };
142
- complex?: { model?: string; thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" };
140
+ trivial?: { model?: string; thinking?: "low" | "medium" | "high" | "xhigh" };
141
+ standard?: { model?: string; thinking?: "low" | "medium" | "high" | "xhigh" };
142
+ complex?: { model?: string; thinking?: "low" | "medium" | "high" | "xhigh" };
143
143
  };
144
144
  };
145
145
  gitAutoCommit?: boolean;
@@ -41,8 +41,7 @@ export interface ExtensionDecision {
41
41
  type EnvLike = Record<string, string | undefined>;
42
42
 
43
43
  function envNum(env: EnvLike, key: string, dflt: number, min: number, max: number): number {
44
- // `key` is the legacy JEO_* name; the JEO_* spelling is preferred when both are set.
45
- const raw = env[key.replace(/^JEO_/, "JEO_")] ?? env[key];
44
+ const raw = env[key];
46
45
  if (raw === undefined || raw === "") return dflt;
47
46
  const n = Number(raw);
48
47
  if (!Number.isFinite(n)) return dflt;
@@ -82,7 +81,7 @@ export function resolveStepBudgetConfig(
82
81
 
83
82
  /** True when `key` carries a non-empty value in `env`. */
84
83
  function envSet(env: EnvLike, key: string): boolean {
85
- const raw = env[key.replace(/^JEO_/, "JEO_")] ?? env[key];
84
+ const raw = env[key];
86
85
  return raw !== undefined && raw !== "";
87
86
  }
88
87
 
@@ -298,7 +298,7 @@ export function parseMaxSteps(input: string | undefined): number | undefined {
298
298
  * maxSteps). Pure — does not mutate `config`. Unknown roles are rejected by the
299
299
  * caller via `getSubagentRole`; this helper trusts the id it is given.
300
300
  */
301
- export type ThinkLevelValue = "minimal" | "low" | "medium" | "high" | "xhigh";
301
+ export type ThinkLevelValue = "low" | "medium" | "high" | "xhigh";
302
302
 
303
303
  /** Per-role reasoning level → explicit role override, else undefined (= INHERIT
304
304
  * the global thinkingLevel at call time — gjc's "(inherit)" semantics). */
@@ -35,7 +35,11 @@ import { ensureSessionNotifyEndpoint, type SessionNotifyEndpoint } from "./notif
35
35
  /** Lifecycle event emitted while a delegated subagent runs. */
36
36
  export interface TaskSubEvent {
37
37
  role: string;
38
- kind: "start" | "step" | "tool" | "done" | "error";
38
+ /** `"thinking"` streams the subagent's live reasoning/thought text (native
39
+ * extended-thinking models) — a transient live-preview beat, not persisted to
40
+ * the ledger (mirrors the main turn's dimmed "Thinking" block, scoped per
41
+ * subagent slot instead of a single shared region). */
42
+ kind: "start" | "step" | "tool" | "done" | "error" | "thinking";
39
43
  detail?: string;
40
44
  success?: boolean;
41
45
  /** Current nested subagent step, when known. */
@@ -142,9 +146,6 @@ export function taskToolProtocolLine(config?: Pick<Config, "subagents">): string
142
146
  );
143
147
  }
144
148
 
145
- /** @deprecated static snapshot (bundled roles only) — prefer taskToolProtocolLine(config). */
146
- export const TASK_TOOL_PROTOCOL_LINE = taskToolProtocolLine();
147
-
148
149
  /**
149
150
  * A concise, gjc-style label for a subagent's tool call — the actual TARGET (file / command /
150
151
  * glob), not just the bare tool name — so the parent's live monitor shows "read src/x.ts" or
@@ -300,6 +301,17 @@ export async function runSubagentOnce(
300
301
  tools: subagentToolset(role),
301
302
  events: {
302
303
  onStep: n => { currentStep = n; },
304
+ // Live reasoning preview (native extended-thinking models only — the JSON-protocol
305
+ // "reasoning" field the main turn also extracts from onModelStream is intentionally
306
+ // NOT wired here: a subagent's forming tool-call JSON is rarely useful mid-stream and
307
+ // doubling the stream sinks would double emit() calls for the same underlying delta).
308
+ // Tail-sliced + whitespace-collapsed to a compact one-line preview — the ledger never
309
+ // records this (see the `kind` doc comment); it only drives the live per-slot status.
310
+ onReasoningStream: textSoFar => {
311
+ const tail = textSoFar.length > 200 ? textSoFar.slice(textSoFar.length - 200) : textSoFar;
312
+ const preview = tail.replace(/\s+/g, " ").trim();
313
+ if (preview) emit({ role: role.id, kind: "thinking", detail: preview, step: currentStep, maxSteps, model });
314
+ },
303
315
  onAssistant: (_raw, invocation) => {
304
316
  if (invocation && invocation.tool && invocation.tool !== "done") {
305
317
  lastTarget = toolTarget(invocation.tool, invocation.arguments);
@@ -108,13 +108,6 @@ export async function languageServiceFor(cwd: string, absPath: string): Promise<
108
108
  return cached.service;
109
109
  }
110
110
 
111
- /** Drop a cached language service (tests / long-running sessions that need a
112
- * clean project graph after large structural changes). */
113
- export function resetLanguageServiceCache(cwd?: string): void {
114
- if (cwd) serviceCache.delete(cwd);
115
- else serviceCache.clear();
116
- }
117
-
118
111
  export interface SymbolSelector { text: string; occurrence: number }
119
112
 
120
113
  /** Parse a loose `"name"` or `"name#N"` selector (1-indexed occurrence, default 1). */
@@ -9,9 +9,9 @@
9
9
  import type { ProviderName } from "./types";
10
10
  import { openaiCompatDef } from "./providers/openai-compatible-catalog";
11
11
 
12
- export type ThinkLevel = "minimal" | "low" | "medium" | "high" | "xhigh";
12
+ export type ThinkLevel = "low" | "medium" | "high" | "xhigh";
13
13
 
14
- export const THINK_LEVELS: readonly ThinkLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
14
+ export const THINK_LEVELS: readonly ThinkLevel[] = ["low", "medium", "high", "xhigh"];
15
15
 
16
16
  export interface CatalogModel {
17
17
  /** jeo-facing canonical id (what a user types). */
@@ -31,8 +31,8 @@ export interface CatalogModel {
31
31
  company?: string;
32
32
  }
33
33
 
34
- const FULL: ThinkLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
35
- const STD: ThinkLevel[] = ["minimal", "low", "medium", "high"];
34
+ const FULL: ThinkLevel[] = ["low", "medium", "high", "xhigh"];
35
+ const STD: ThinkLevel[] = ["low", "medium", "high"];
36
36
 
37
37
  export const ANTIGRAVITY_MODELS = [
38
38
  "claude-opus-4-6-thinking",
@@ -92,8 +92,7 @@ export function providerModelFor(model: string): string {
92
92
  }
93
93
 
94
94
  /** Map the configured thinking level to a default max-token budget. */
95
- export function thinkingMaxTokens(level?: "minimal" | "low" | "medium" | "high" | "xhigh"): number {
96
- if (level === "minimal") return 4000;
95
+ export function thinkingMaxTokens(level?: "low" | "medium" | "high" | "xhigh"): number {
97
96
  if (level === "low") return 8000;
98
97
  if (level === "high") return 24000;
99
98
  if (level === "xhigh") return 31999;
@@ -117,7 +116,7 @@ const DEFAULT_MAX_OUTPUT_CAP = 64_000;
117
116
  * Uncatalogued models (local/live ids) keep the legacy thinking-table budget. */
118
117
  export function resolveMaxOutputTokens(
119
118
  model?: string,
120
- level?: "minimal" | "low" | "medium" | "high" | "xhigh",
119
+ level?: "low" | "medium" | "high" | "xhigh",
121
120
  ): number {
122
121
  const meta = model ? findCatalogEntry(expandAlias(model, ALIAS_DEFAULTS)) : undefined;
123
122
  if (!meta) return thinkingMaxTokens(level);
@@ -126,13 +125,13 @@ export function resolveMaxOutputTokens(
126
125
  return Math.min(findCatalogModel(meta.id)?.maxOutputTokens ?? cap, cap);
127
126
  }
128
127
 
129
- /** Map the thinking level to an OpenAI reasoning-effort tier. minimal/low/medium/high pass
128
+ /** Map the thinking level to an OpenAI reasoning-effort tier. low/medium/high pass
130
129
  * through unchanged and xhigh folds to high (the deepest tier the provider APIs accept), so
131
- * reasoning works at EVERY thinking level (gajae parity: minimal is a real effort). Only an
132
- * unset level returns undefined (reasoning off — the explicit /fast path). */
130
+ * reasoning works at EVERY thinking level (gajae parity). Only an unset level returns
131
+ * undefined (reasoning off — the explicit /fast path). */
133
132
  export function thinkingToReasoningEffort(
134
- level?: "minimal" | "low" | "medium" | "high" | "xhigh",
135
- ): "minimal" | "low" | "medium" | "high" | undefined {
133
+ level?: "low" | "medium" | "high" | "xhigh",
134
+ ): "low" | "medium" | "high" | undefined {
136
135
  if (!level) return undefined;
137
136
  return level === "xhigh" ? "high" : level;
138
137
  }
@@ -450,13 +449,18 @@ async function resolveCall(options: Partial<CallOptions>, kind: "request" | "str
450
449
  * streaming idle watchdog, this path collects an opaque buffered body (`response.json()`,
451
450
  * or an internally-streamed collect in codexResponsesCall / antigravity.call) and exposes
452
451
  * no per-chunk signal — so a wall clock is the only lever and a wire heartbeat cannot help.
453
- * Raised to 300s to match STREAM_IDLE_TIMEOUT_MS: non-interactive turns (callLlm WITHOUT
454
- * onToken — compaction, ralplan, deep-interview, memory distill, goal-verify, and subagent/
455
- * autopilot engine steps) route here, and a long reasoning completion legitimately exceeds
456
- * 120s; too-tight, it aborts an alive call, retries re-incur the same slow request, the
457
- * attempt budget exhausts, and the turn STOPS — the same false-failure the streaming
458
- * watchdog guards, on the path the wire heartbeat never reaches. */
459
- const DEFAULT_CALL_TIMEOUT_MS = 300_000;
452
+ * Raised 120s → 300s → 30min across two rounds of the SAME false-failure: non-interactive
453
+ * turns (callLlm WITHOUT onToken — compaction, ralplan, deep-interview, memory distill,
454
+ * goal-verify, and subagent/autopilot engine steps) route here, and a HIGH/XHIGH reasoning
455
+ * effort completion (GPT-5.5/o3-class) legitimately exceeds 300s — OpenAI's own guidance is
456
+ * that xhigh trades latency for depth deliberately. Too-tight, it aborts an alive call, the
457
+ * attempt budget exhausts, and the turn STOPS — exactly the false-failure the streaming
458
+ * watchdog guards, on the path the wire heartbeat never reaches. 30min matches both
459
+ * `turnMaxMs()`'s own vetted stall-budget default AND the OBSERVED ~20-30min infra-side
460
+ * connection-duration cap on OpenAI's Codex/ChatGPT backend (see socket-closed handling
461
+ * above) — genuinely dead connections still fail fast via TCP/TLS errors well before this;
462
+ * this wall clock exists only for the "server accepted the request and went silent" case. */
463
+ const DEFAULT_CALL_TIMEOUT_MS = 30 * 60_000;
460
464
 
461
465
  /** Per-chunk idle cap for streaming: a stream that emits NOTHING for this long is
462
466
  * aborted, but a healthy long generation (chunks keep arriving) runs unbounded —
@@ -472,25 +476,12 @@ const DEFAULT_CALL_TIMEOUT_MS = 300_000;
472
476
  * phase runs longer still; Ctrl-C remains the interactive escape for a truly dead one. */
473
477
  const STREAM_IDLE_TIMEOUT_MS = 300_000;
474
478
 
475
- /** Combine two abort signals into one. Preserves BOTH even when `AbortSignal.any`
476
- * is unavailable (manual fallback), so neither the caller's cancel nor the timeout
477
- * is silently dropped. */
479
+ /** Combine two abort signals into one — neither the caller's cancel nor the timeout
480
+ * is ever silently dropped. `AbortSignal.any` is native on every Bun this CLI runs on
481
+ * (shipped in Bun 1.1.13; `src/cli.ts` hard-enforces `engines.bun: ">=1.3.14"` at
482
+ * process start, so a manual polyfill fallback would be permanently unreachable). */
478
483
  function composeAbort(a: AbortSignal | undefined, b: AbortSignal): AbortSignal {
479
- if (!a) return b;
480
- if (typeof AbortSignal.any === "function") return AbortSignal.any([a, b]);
481
- if (a.aborted || b.aborted) return AbortSignal.abort();
482
- const ctrl = new AbortController();
483
- // Memory hygiene: `a` is typically the TURN-long abort signal — a once-listener
484
- // per model call would otherwise accumulate on it for the whole turn. Detach
485
- // BOTH listeners as soon as either side fires.
486
- const onAbort = () => {
487
- a.removeEventListener("abort", onAbort);
488
- b.removeEventListener("abort", onAbort);
489
- ctrl.abort();
490
- };
491
- a.addEventListener("abort", onAbort, { once: true });
492
- b.addEventListener("abort", onAbort, { once: true });
493
- return ctrl.signal;
484
+ return a ? AbortSignal.any([a, b]) : b;
494
485
  }
495
486
 
496
487
  /** Compose the caller's signal (if any) with a fresh per-attempt timeout. */
@@ -577,13 +568,17 @@ async function nextMaybeIdle(iter: AsyncIterator<string>, watchdog?: Promise<nev
577
568
  }
578
569
 
579
570
  /** Overall stream wall-clock from the environment. Defaults to the same hard bound as
580
- * callTimeoutMs() (DEFAULT_CALL_TIMEOUT_MS) so the streaming path has parity with the
581
- * non-streaming call()/fallback path — a stream that stays connected and keeps emitting
582
- * SOMETHING (SSE keepalives, reasoning deltas) re-arms the per-chunk idle watchdog
583
- * forever and would otherwise never resolve or reject. JEO_STREAM_MAX_MS explicitly set
584
- * to a positive number overrides the default; explicitly set to 0 disables the overall
585
- * deadline entirely (mirrors JEO_TURN_MAX_MS's own 0-disables convention) for advanced
586
- * users with genuinely long-running legitimate reasoning models. */
571
+ * callTimeoutMs() (DEFAULT_CALL_TIMEOUT_MS, 30min) so the streaming path has parity with
572
+ * the non-streaming call()/fallback path — a stream that stays connected and keeps
573
+ * emitting SOMETHING (SSE keepalives, reasoning deltas) re-arms the per-chunk idle
574
+ * watchdog forever and would otherwise never resolve or reject. Bug fix: this previously
575
+ * defaulted to 300s (DEFAULT_CALL_TIMEOUT_MS's OLD value, before it too was raised) —
576
+ * that false-failed any HIGH/XHIGH-reasoning-effort model (GPT-5.5/o3-class) whose
577
+ * ACTIVELY-emitting completion legitimately ran past 5 minutes, killing a healthy
578
+ * generation with "stream exceeded the overall deadline" despite continuous activity.
579
+ * JEO_STREAM_MAX_MS explicitly set to a positive number overrides the default; explicitly
580
+ * set to 0 disables the overall deadline entirely (mirrors JEO_TURN_MAX_MS's own
581
+ * 0-disables convention) for advanced users with genuinely long-running reasoning models. */
587
582
  export function streamMaxMs(env?: Record<string, string | undefined>): number | undefined {
588
583
  const raw = jeoEnv("STREAM_MAX_MS", env);
589
584
  if (raw === undefined || raw === "") return DEFAULT_CALL_TIMEOUT_MS;
@@ -99,14 +99,13 @@ function anthropicSystemBlocks(
99
99
  }
100
100
 
101
101
  /** Anthropic extended-thinking budget by reasoning effort (kept under max_tokens). Tiers match
102
- * gjc's ANTHROPIC_THINKING table: minimal/low/medium/high ALL enable thinking with scaling
103
- * depth — reasoning works at every thinking level (gajae parity: Minimal is a real effort).
102
+ * gjc's ANTHROPIC_THINKING table: low/medium/high ALL enable thinking with scaling
103
+ * depth — reasoning works at every thinking level (gajae parity).
104
104
  * Only an UNSET effort stays non-thinking (the explicit /fast off path). The "xhigh" row is
105
105
  * correct-by-table even though upstream currently folds xhigh→high before it arrives. */
106
106
  function anthropicThinkingBudget(effort: CallOptions["reasoningEffort"] | "xhigh", maxTokens: number): number | undefined {
107
107
  let budget: number;
108
108
  switch (effort) {
109
- case "minimal": budget = 1024; break;
110
109
  case "low": budget = 4096; break;
111
110
  case "medium": budget = 8192; break;
112
111
  case "high": budget = 16384; break;
@@ -161,10 +160,9 @@ function anthropicThinkingMode(model: string): AnthropicThinkingMode {
161
160
  }
162
161
 
163
162
  /** Map jeo's reasoning effort to Anthropic's adaptive/output_config effort literal. jeo folds
164
- * xhigh→high upstream, so only minimal/low/medium/high arrive here. (gjc: mapEffortToAnthropicAdaptiveEffort) */
163
+ * xhigh→high upstream, so only low/medium/high arrive here. (gjc: mapEffortToAnthropicAdaptiveEffort) */
165
164
  function anthropicAdaptiveEffort(effort: NonNullable<CallOptions["reasoningEffort"]>): "low" | "medium" | "high" {
166
165
  switch (effort) {
167
- case "minimal":
168
166
  case "low": return "low";
169
167
  case "medium": return "medium";
170
168
  case "high": return "high";
@@ -14,11 +14,10 @@ const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googlea
14
14
  /** Anthropic-style thinking budget for Claude served via CCA. gemini's budget fn
15
15
  * returns undefined for claude ids, which left antigravity Claude with NO thinking
16
16
  * requested (the opus "no reasoning" gap). Mirrors anthropic's effort→budget tiers —
17
- * minimal/low/medium/high ALL think (gajae parity: reasoning at every level); only an
17
+ * low/medium/high ALL think (gajae parity: reasoning at every level); only an
18
18
  * UNSET effort stays non-thinking. */
19
19
  function antigravityClaudeThinkingBudget(effort: CallOptions["reasoningEffort"]): number | undefined {
20
20
  switch (effort) {
21
- case "minimal": return 2000;
22
21
  case "low": return 4000;
23
22
  case "medium": return 10000;
24
23
  case "high": return 24000;
@@ -26,11 +26,10 @@ export function geminiThinkingBudget(model: string, effort?: CallOptions["reason
26
26
  const effectiveEffort = effort ?? geminiNamedEffort(m);
27
27
  let budget: number;
28
28
  switch (effectiveEffort) {
29
- // minimal/low/medium/high ALL enable thinking with scaling depth — reasoning works at
30
- // every thinking level (gajae parity: Minimal is a real effort). Only an UNSET effort
29
+ // low/medium/high ALL enable thinking with scaling depth — reasoning works at
30
+ // every thinking level (gajae parity). Only an UNSET effort
31
31
  // (and no in-name depth marker) falls through to the floor.
32
32
  // Tier values mirror gjc's GOOGLE_THINKING table (stream.ts).
33
- case "minimal": budget = Math.max(floor, 1024); break;
34
33
  case "low": budget = 4096; break;
35
34
  case "medium": budget = 8192; break;
36
35
  case "high": budget = 16384; break;
@@ -63,10 +62,9 @@ export function geminiUsesThinkingLevel(model: string): boolean {
63
62
  }
64
63
 
65
64
  /** Effort → Google `thinkingLevel` enum value (gjc mapEffortToGoogleThinkingLevel):
66
- * minimal→MINIMAL, low→LOW, medium→MEDIUM, high/xhigh→HIGH. */
67
- export function geminiThinkingLevel(effort: NonNullable<CallOptions["reasoningEffort"]> | "xhigh"): "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" {
65
+ * low→LOW, medium→MEDIUM, high/xhigh→HIGH. */
66
+ export function geminiThinkingLevel(effort: NonNullable<CallOptions["reasoningEffort"]> | "xhigh"): "LOW" | "MEDIUM" | "HIGH" {
68
67
  switch (effort) {
69
- case "minimal": return "MINIMAL";
70
68
  case "low": return "LOW";
71
69
  case "medium": return "MEDIUM";
72
70
  case "high":
@@ -80,7 +78,7 @@ export function geminiThinkingLevel(effort: NonNullable<CallOptions["reasoningEf
80
78
  * omitted so the model keeps its default (gjc parity: thinking not enabled → no config).
81
79
  * Other thinking-capable models → numeric thinkingBudget; pre-2.5 → undefined. */
82
80
  export type GeminiThinkingConfig =
83
- | { includeThoughts: true; thinkingLevel: "MINIMAL" | "LOW" | "MEDIUM" | "HIGH" }
81
+ | { includeThoughts: true; thinkingLevel: "LOW" | "MEDIUM" | "HIGH" }
84
82
  | { includeThoughts: true; thinkingBudget: number };
85
83
 
86
84
  export function geminiThinkingConfig(model: string, effort?: CallOptions["reasoningEffort"], maxTokens?: number): GeminiThinkingConfig | undefined {
@@ -21,7 +21,7 @@ import pkg from "../../../package.json";
21
21
 
22
22
  export const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
23
23
 
24
- export const VALID_REASONING_EFFORTS = new Set(["minimal", "low", "medium", "high"]);
24
+ export const VALID_REASONING_EFFORTS = new Set(["low", "medium", "high"]);
25
25
 
26
26
  const CODEX_ORIGINATOR = "codex_cli_rs";
27
27
 
package/src/ai/types.ts CHANGED
@@ -107,7 +107,7 @@ export interface CallOptions {
107
107
  /** Abort in-flight provider requests (Ctrl-C / timeout / supersede). */
108
108
  signal?: AbortSignal;
109
109
  /** Reasoning effort for reasoning models (o-series / gpt-5), mapped from thinkingLevel. */
110
- reasoningEffort?: "minimal" | "low" | "medium" | "high";
110
+ reasoningEffort?: "low" | "medium" | "high";
111
111
  /** How an OpenAI-compatible backend enables/streams native reasoning (gjc parity):
112
112
  * "openai" → `reasoning_effort`; "openrouter" → `reasoning: {effort}`; "qwen" →
113
113
  * `enable_thinking: true`; "zai" → `thinking: {type:"enabled"}`. Set per provider by
package/src/cli/runner.ts CHANGED
@@ -331,7 +331,7 @@ export function renderHelp(ctx: DispatchContext): string {
331
331
  lines.push(" --model <id> Use a session model for launch/one-shot.");
332
332
  lines.push(" --provider <name> Start launch on a provider default (anthropic/openai/gemini/antigravity/ollama).");
333
333
  lines.push(" --smol|--slow|--plan Start launch with the configured model role tier.");
334
- lines.push(" --thinking <level> Set thinking budget (minimal/low/medium/high/xhigh).");
334
+ lines.push(" --thinking <level> Set thinking budget (low/medium/high/xhigh).");
335
335
  lines.push("");
336
336
  return lines.join("\n");
337
337
  }
@@ -46,8 +46,8 @@ export async function runChatCommand(args: string[] = []): Promise<void> {
46
46
  let any = false;
47
47
  let usage: { inputTokens?: number; outputTokens?: number; durationMs?: number } | undefined;
48
48
  try {
49
- const effort = (thinking === "xhigh" ? "high" : thinking) as "minimal" | "low" | "medium" | "high" | undefined;
50
- const maxTokens = explicitMaxTokens ?? (thinking ? thinkingMaxTokens(thinking as "minimal" | "low" | "medium" | "high" | "xhigh") : undefined);
49
+ const effort = (thinking === "xhigh" ? "high" : thinking) as "low" | "medium" | "high" | undefined;
50
+ const maxTokens = explicitMaxTokens ?? (thinking ? thinkingMaxTokens(thinking as "low" | "medium" | "high" | "xhigh") : undefined);
51
51
  for await (const chunk of manager.stream([{ role: "user", content: message }], { model, maxTokens, reasoningEffort: effort, onUsage: u => { usage = u; } })) {
52
52
  process.stdout.write(chunk);
53
53
  any = true;
@@ -1,6 +1,7 @@
1
1
  import { readGlobalConfig } from "../agent/state";
2
2
  import { resolveCredential, snapshotProvider, type AuthProvider, type Credential } from "../auth";
3
- import { resolveProvider } from "../ai";
3
+ import { resolveProvider, describeProvider } from "../ai";
4
+
4
5
  import { effectiveCredentialForProvider } from "../ai/model-manager";
5
6
  import { resolveModelId } from "../ai/model-registry";
6
7
  import { meter } from "../tui/components/meter";
@@ -179,9 +180,37 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
179
180
  // turn.
180
181
  const routingEnabled = !!config.routing?.enabled;
181
182
  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;
183
+ const routingNotes: string[] = [];
184
+ if (routingEnabled && !smolConfigured) {
185
+ routingNotes.push(
186
+ "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.",
187
+ );
188
+ }
189
+ // Proactive credential-readiness check for every model routing could actually pick —
190
+ // mirrors launch.ts's runTurn per-turn veto gate (v0.7.51) but surfaced at onboarding/
191
+ // doctor time instead of only reactively on a session's first qualifying turn. Catches
192
+ // `roles.smol`/`roles.slow`/`routing.tiers.*.model` pointed at a provider the user never
193
+ // logged into (or whose credential was since removed). `standard` only checked when
194
+ // explicitly configured — its unconfigured fallback is `defaultModel`, already covered
195
+ // by the provider-connectivity probes above.
196
+ if (routingEnabled) {
197
+ const tierCandidates: { tier: string; model: string | undefined }[] = [
198
+ { tier: "trivial", model: config.routing?.tiers?.trivial?.model || config.roles?.smol },
199
+ { tier: "standard", model: config.routing?.tiers?.standard?.model },
200
+ { tier: "complex", model: config.routing?.tiers?.complex?.model || config.roles?.slow },
201
+ ];
202
+ for (const { tier, model } of tierCandidates) {
203
+ if (!model) continue;
204
+ const provider = resolveProvider(model);
205
+ const status = await describeProvider(provider, config);
206
+ if (!status.ready) {
207
+ routingNotes.push(
208
+ `routing.tiers.${tier} resolves to '${model}' (${provider}) which has no usable credential — run 'jeo auth login ${provider}' or reconfigure that tier/role for a provider you're logged into.`,
209
+ );
210
+ }
211
+ }
212
+ }
213
+
185
214
 
186
215
  // --- Gather (probes run concurrently → ~1× the slowest timeout, not N×) ---
187
216
  const probes: { name: string; credKind: string; result: ProbeResult }[] = [];
@@ -248,7 +277,8 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
248
277
  })),
249
278
  oauth: oauthHealth,
250
279
  ready,
251
- ...(routingEnabled ? { routing: { enabled: true, smolConfigured, ...(routingNote ? { note: routingNote } : {}) } } : {}),
280
+ ...(routingEnabled ? { routing: { enabled: true, smolConfigured, ...(routingNotes.length ? { notes: routingNotes } : {}) } } : {}),
281
+
252
282
  };
253
283
  console.log(JSON.stringify(report, null, 2));
254
284
  if (strict && !ready) process.exit(1);
@@ -292,11 +322,12 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
292
322
  }
293
323
  // Routing diagnostic: informational only — never affects ready/strict exit logic.
294
324
  if (routingEnabled) {
295
- if (routingNote) {
296
- console.log(`${chalk.yellow("[routing]")} ${routingNote}`);
325
+ if (routingNotes.length) {
326
+ for (const note of routingNotes) console.log(`${chalk.yellow("[routing]")} ${note}`);
297
327
  } else {
298
328
  console.log(`${chalk.green("[routing]")} enabled, roles.smol configured — LLM escalation available on ambiguous prompts.`);
299
329
  }
330
+
300
331
  console.log("");
301
332
  }
302
333
 
@@ -478,12 +478,12 @@ export async function runThinkingSlash(input: string, ctx: AgentsSlashCtx): Prom
478
478
  console.log(`Thinking level: ${sessionThinking ?? "medium"} (~${thinkingMaxTokens(sessionThinking)} max tokens/step)`);
479
479
  return result();
480
480
  }
481
- if (arg === "minimal" || arg === "low" || arg === "medium" || arg === "high" || arg === "xhigh") {
481
+ if (arg === "low" || arg === "medium" || arg === "high" || arg === "xhigh") {
482
482
  sessionThinking = arg;
483
483
  sessionThinkingChanged = true;
484
484
  console.log(`Thinking set to ${arg} (~${thinkingMaxTokens(arg)} max tokens/step)`);
485
485
  } else {
486
- console.log(`Invalid level '${arg}'. Use: minimal | low | medium | high | xhigh.`);
486
+ console.log(`Invalid level '${arg}'. Use: low | medium | high | xhigh.`);
487
487
  }
488
488
  return result();
489
489
  }
@@ -49,19 +49,18 @@ export function isProviderName(input: string | undefined): input is ProviderName
49
49
  }
50
50
 
51
51
  export function isThinkingLevel(input: string | undefined): input is ThinkLevel {
52
- return input === "minimal" || input === "low" || input === "medium" || input === "high" || input === "xhigh";
52
+ return input === "low" || input === "medium" || input === "high" || input === "xhigh";
53
53
  }
54
54
 
55
55
 
56
56
  export function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefined {
57
57
  const supported = catalogMetadata(modelId)?.thinking ?? [];
58
- if (supported.includes("minimal")) return "minimal";
59
58
  if (supported.includes("low")) return "low";
60
59
  // Digit-count agnostic (gemini-10+ / 2.6+ stay reasoning) — mirrors the gates in
61
60
  // gemini.ts and inferCatalogMetadata. Last resort for prefixed ids (models/gemini-…)
62
61
  // the catalog lookup above misses; catalogued ids already returned via thinking caps.
63
62
  const g = modelId.toLowerCase().match(/gemini-(\d+)(?:\.(\d+))?/);
64
- if (g && (Number(g[1]) >= 3 || (Number(g[1]) === 2 && Number(g[2] ?? 0) >= 5))) return "minimal";
63
+ if (g && (Number(g[1]) >= 3 || (Number(g[1]) === 2 && Number(g[2] ?? 0) >= 5))) return "low";
65
64
  return undefined;
66
65
  }
67
66
 
@@ -114,7 +113,7 @@ export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchF
114
113
  const { value, nextIndex } = takeValue(args, i, "--thinking=");
115
114
  const normalized = value?.toLowerCase();
116
115
  if (isThinkingLevel(normalized)) flags.thinking = normalized;
117
- else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
116
+ else flags.errors.push("--thinking must be one of: low, medium, high, xhigh");
118
117
  i = nextIndex;
119
118
  } else if (a === "--smol" || a === "--slow" || a === "--plan") {
120
119
  flags.modelRole = a.slice(2) as ModelRole;
@@ -124,7 +124,7 @@ export async function runModelSlash(input: string, ctx: ModelSlashCtx): Promise<
124
124
  if (modelThinking) {
125
125
  const level = (modelThinking[1] ?? "").toLowerCase();
126
126
  if (!isThinkingLevel(level)) {
127
- console.log("Usage: /model thinking <minimal|low|medium|high|xhigh>");
127
+ console.log("Usage: /model thinking <low|medium|high|xhigh>");
128
128
  return result();
129
129
  }
130
130
  sessionThinking = level;
@@ -53,11 +53,18 @@ export function formatTaskSubEvent(e: TaskSubEvent): string {
53
53
  if (e.kind === "step") return ` ${badge} ${chalk.cyan(`├─ ${roleLabel}`)} · ${detail || "working"}`;
54
54
  if (e.kind === "tool") return ` ${badge} ${e.success === false ? chalk.red("├─") : chalk.green("├─")} ${roleLabel} ${e.success === false ? chalk.red("✗") : chalk.green("✓")} ${detail || "tool"}${summary}`;
55
55
  if (e.kind === "error") return ` ${badge} ${chalk.red("├─")} ${roleLabel} ${chalk.red("✗")} ${detail || "error"}`;
56
+ // "thinking" is a live-only TUI preview (never persisted to the ledger — see
57
+ // TaskSubEvent.kind's doc comment) and has no place in an append-only, non-TTY
58
+ // log stream: a reasoning delta stream would otherwise fall through to the
59
+ // "done" branch below and print a bogus "ROLE done: <reasoning text>" line for
60
+ // every emitted preview. Empty string signals the caller to skip the line.
61
+ if (e.kind === "thinking") return "";
56
62
  return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${tokTag}${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
57
63
  }
58
64
 
59
65
  export function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
60
- log(formatTaskSubEvent(e));
66
+ const line = formatTaskSubEvent(e);
67
+ if (line) log(line);
61
68
  }
62
69
 
63
70
  export function createStreamEvents(
@@ -648,7 +648,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
648
648
  };
649
649
  let sessionModel: string | undefined = initialSessionModel;
650
650
  // Session thinking-level override (`/thinking`); falls back to the config level.
651
- let sessionThinking: "minimal" | "low" | "medium" | "high" | "xhigh" | undefined = flags.thinking ?? cfg.thinkingLevel;
651
+ let sessionThinking: "low" | "medium" | "high" | "xhigh" | undefined = flags.thinking ?? cfg.thinkingLevel;
652
652
  // PromptRouter session override: undefined = follow config.routing.enabled, true/false = /route on|off wins this session.
653
653
  let sessionRouteOverride: boolean | undefined;
654
654
  // Last routing decision (or the reason none applied) — /route why reads this.
@@ -937,7 +937,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
937
937
  // config file. (gjc parity P1.B5: dirty count per-turn, not per-render.)
938
938
  const { provider: activeProvider } = await describeModel(activeModel, turnConfig);
939
939
  const turnDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
940
- const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: activeThinking }) : null;
940
+ const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: activeThinking, routedTier: routed?.tier }) : null;
941
941
  if (routeCredentialNotice) {
942
942
  if (tui) tui.events().onNotice?.(routeCredentialNotice);
943
943
  else console.log(routeCredentialNotice);
@@ -2832,7 +2832,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2832
2832
  ): Promise<ThinkLevel | "inherit" | undefined> => {
2833
2833
  const levels = buildThinkingLevelChoices(current, {
2834
2834
  inheritLabel,
2835
- tokenHint: lvl => (lvl === "minimal" ? undefined : `~${Math.round(thinkingMaxTokens(lvl as ThinkLevel) / 1000)}k tokens`),
2835
+ tokenHint: lvl => `~${Math.round(thinkingMaxTokens(lvl as ThinkLevel) / 1000)}k tokens`,
2836
2836
  });
2837
2837
  const picked = await pickFromOptions(title, levels);
2838
2838
  return picked === "inherit" || isThinkingLevel(picked) ? picked : undefined;
@@ -2849,7 +2849,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2849
2849
  return true;
2850
2850
  }
2851
2851
  if (!isThinkingLevel(level)) {
2852
- console.log(`Usage: /model subagent ${role.id} thinking <inherit|minimal|low|medium|high|xhigh>`);
2852
+ console.log(`Usage: /model subagent ${role.id} thinking <inherit|low|medium|high|xhigh>`);
2853
2853
  return true;
2854
2854
  }
2855
2855
  await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { thinking: level }) }));
package/src/tui/app.ts CHANGED
@@ -68,6 +68,12 @@ export interface LaunchTuiOptions {
68
68
  dirtyCount?: number;
69
69
  /** Thinking-level label ("high", …) for the gjc-style model status bar. */
70
70
  thinking?: string;
71
+ /** PromptRouter's resolved tier when routing chose THIS turn's `model` (undefined
72
+ * when routing didn't engage — pinned model, routing off, or the credential
73
+ * gate fell back). Renders a persistent `⚡tier` marker in the status bar so the
74
+ * routed model stays visible without depending on the transient `[route]`
75
+ * console notice. See `StatusBarData.routedTier` in components/status.ts. */
76
+ routedTier?: "trivial" | "standard" | "complex";
71
77
  }
72
78
 
73
79
  export interface AgentEventsLike {
@@ -361,6 +367,9 @@ export class LaunchTui {
361
367
  private readonly inline: boolean;
362
368
  // Thinking-level label for the gjc-style model status bar.
363
369
  private readonly thinkingLevel?: string;
370
+ // PromptRouter's resolved tier when routing chose this turn's model; undefined when
371
+ // routing didn't engage. Renders the persistent `⚡tier` marker in the status bar.
372
+ private readonly routedTier?: "trivial" | "standard" | "complex";
364
373
 
365
374
  constructor(opts: LaunchTuiOptions) {
366
375
  // Backpressure-aware wrapper around the underlying writer (real stdout, or the
@@ -383,6 +392,7 @@ export class LaunchTui {
383
392
  this.renderer = new Renderer(this.write, undefined, { reserve: this.inline });
384
393
  this.spinner = new Spinner(undefined, { unicode: this.unicode });
385
394
  this.thinkingLevel = opts.thinking;
395
+ this.routedTier = opts.routedTier;
386
396
  this.footer = {
387
397
  model: opts.model,
388
398
  provider: opts.provider,
@@ -1041,6 +1051,11 @@ export class LaunchTui {
1041
1051
  touchSlot(`${roleLabel} ${bad} ${detail || "error"}`);
1042
1052
  this.appendLedger(` ${badge} ${branch} ${roleLabel} ${bad} ${detail || "error"}\n`, "subagent");
1043
1053
  break;
1054
+ case "thinking":
1055
+ // Live-only preview (mirrors the main turn's dimmed "Thinking" block) — never
1056
+ // persisted to the ledger; see TaskSubEvent.kind's doc comment for why.
1057
+ touchSlot(`${roleLabel} ${this.unicode ? "…" : "..."} ${detail || "thinking"}`);
1058
+ break;
1044
1059
  case "done":
1045
1060
  // Clear ONLY this slot — a fan-out batch where one worker finishes early
1046
1061
  // must keep showing its still-running siblings, not drop the `(sub)`
@@ -1632,6 +1647,7 @@ export class LaunchTui {
1632
1647
  return renderStatusBar({
1633
1648
  model: `${this.footer.model}${this.footer.provider ? ` (${this.footer.provider})` : ""}`,
1634
1649
  thinking: this.thinkingLevel,
1650
+ routedTier: this.routedTier,
1635
1651
  branch: this.footer.branch,
1636
1652
  dirtyCount: this.footer.dirtyCount,
1637
1653
  cwd: this.footer.cwd,
@@ -6,7 +6,7 @@
6
6
  * - `/model gpt` → live (logged-in) model ids + aliases + catalog ids
7
7
  * - `/provider an` → provider names; second arg → that provider's live models
8
8
  * - `/agents exec` → subagent role ids; second arg → live model ids
9
- * - `/thinking h` → minimal/low/medium/high/xhigh
9
+ * - `/thinking h` → low/medium/high/xhigh
10
10
  *
11
11
  * Pure + synchronous: the dynamic data (live models from the OAuth-authenticated
12
12
  * accounts, alias snapshot) is passed in via `CompletionContext`, so the readline
@@ -59,7 +59,7 @@ const PREVIEW_LABEL: Record<string, string> = {
59
59
  path: "Paths",
60
60
  };
61
61
  const MAX_COMPLETIONS = 50;
62
- const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh"];
62
+ const THINKING_LEVELS = ["low", "medium", "high", "xhigh"];
63
63
 
64
64
  /** Static half of a completion context (no network/config needed). */
65
65
  export function staticCompletionContext(): Omit<CompletionContext, "liveModels" | "aliases" | "modelsForProvider"> {
@@ -107,11 +107,10 @@ export function renderLiveModelPicker(list: SelectList<PickEntry>, opts: RenderS
107
107
  return renderSelectList(list, { title: "Select a live model", rows: 12, showTabs: true, ...opts });
108
108
  }
109
109
 
110
- /** The five reasoning levels in display order, lightest → heaviest. */
111
- export const THINKING_LEVEL_ORDER = ["minimal", "low", "medium", "high", "xhigh"] as const;
110
+ /** The four reasoning levels in display order, lightest → heaviest. */
111
+ export const THINKING_LEVEL_ORDER = ["low", "medium", "high", "xhigh"] as const;
112
112
 
113
113
  const THINKING_LEVEL_DESCRIPTION: Record<string, string> = {
114
- minimal: "lightest reasoning",
115
114
  low: "light reasoning",
116
115
  medium: "moderate reasoning",
117
116
  high: "deep reasoning",
@@ -42,7 +42,7 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
42
42
  { command: "/login", usage: "/login [provider]", description: "OAuth login — opens a provider picker showing live login status (account · expiry) for each provider (alias of /provider login)", group: "models" },
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
- { command: "/thinking", usage: "/thinking [level]", description: "Show or set thinking budget (minimal/low/medium/high/xhigh)", group: "models" },
45
+ { command: "/thinking", usage: "/thinking [level]", description: "Show or set thinking budget (low/medium/high/xhigh)", group: "models" },
46
46
  { command: "/route", usage: "/route [on|off|why]", description: "Show/toggle prompt-based model routing for this session, or explain the last routing decision", 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" },
@@ -17,6 +17,14 @@ export interface StatusBarData {
17
17
  model: string;
18
18
  /** Thinking level label ("high", …); omitted when unset. */
19
19
  thinking?: string;
20
+ /** PromptRouter's resolved tier ("trivial"/"standard"/"complex") when THIS turn's
21
+ * model was chosen by routing rather than the session/default model directly —
22
+ * omitted when routing didn't engage this turn (pinned model, routing off, or
23
+ * the credential-readiness gate fell back). Renders as a persistent `⚡tier`
24
+ * marker so the routed model stays visible without depending on the transient
25
+ * `[route] …` console notice, which intentionally stays silent for routine
26
+ * unescalated `standard`-tier turns. */
27
+ routedTier?: "trivial" | "standard" | "complex";
20
28
  branch?: string;
21
29
  /** Uncommitted-change count for the `?N` dirty flag; omit/0 = clean. */
22
30
  dirtyCount?: number;
@@ -81,6 +89,7 @@ export function renderStatusBar(d: StatusBarData): string {
81
89
  // Left identity segment (plain text; painted as one bg block at the end).
82
90
  const bits: string[] = [];
83
91
  let modelBit = `${unicode ? "⬢" : "*"} ${d.model}`;
92
+ if (d.routedTier) modelBit += ` ${unicode ? "⚡" : "~"}${d.routedTier}`;
84
93
  if (d.thinking) modelBit += ` · ${unicode ? "◔" : "@"} ${d.thinking}`;
85
94
  bits.push(modelBit);
86
95
  if (d.branch) {
@@ -57,6 +57,12 @@ export function friendlyProviderError(err: unknown): string {
57
57
  if (status === 401 || status === 403 || /\b40[13]\b/.test(msg)) {
58
58
  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).`;
59
59
  }
60
+ if ((err as { name?: string })?.name === "TimeoutError" || /the operation timed out/i.test(msg)) {
61
+ 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.`;
62
+ }
63
+ if (/exceeded the overall deadline \(JEO_STREAM_MAX_MS\)/i.test(msg)) {
64
+ return `${provider}'s stream hit the overall deadline (default 30min; JEO_STREAM_MAX_MS) despite staying active. Raise JEO_STREAM_MAX_MS (ms), set it to 0 to disable the overall cap, or lower the thinking level with /model.`;
65
+ }
60
66
  if (isContextOverflowError(err)) {
61
67
  return `${provider} rejected the request: the conversation no longer fits the model's context window. Run /compact, drop large attachments, or start a fresh session.`;
62
68
  }