jeo-code 0.7.44 → 0.7.47
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 +28 -0
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/package.json +1 -1
- package/src/agent/config-schema.ts +18 -0
- package/src/agent/prompt-router.ts +273 -0
- package/src/agent/state.ts +11 -0
- package/src/agent/task-tool.ts +33 -25
- package/src/commands/launch/route-slash.ts +88 -0
- package/src/commands/launch.ts +39 -6
- package/src/tui/app.ts +49 -15
- package/src/tui/components/slash.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,34 @@ 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.47] - 2026-07-06
|
|
10
|
+
_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._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`src/agent/prompt-router.ts`** — `classifyPromptHeuristically` (pure, sync, zero-I/O): bilingual (EN/KR) signal detection for short factual questions, causal/debugging questions ("why"/"how"/"왜"/"어떻게" — deliberately NEVER classified as trivial despite being short-and-question-shaped), code fences, file-path mentions (single vs. multi-file), and deep-work keywords (design/architecture/refactor/debug/설계/아키텍처/리팩터/디버그); conflicting or weak signals resolve to `standard` at low confidence so escalation decides. `routePrompt` escalates to a real LLM classification only below `routing.confidenceThreshold` (default 0.6) AND only when `roles.smol` is configured (otherwise "escalate to a cheap model" would paradoxically call the expensive `defaultModel` — skipped with a one-time `warnOnce` notice instead); the escalation response is parsed via `tryExtractJsonObject` (never bare `JSON.parse`, since not every provider has native JSON mode) and the returned tier is strictly validated against the 3 literal values before being trusted. Tier→model resolution reuses `config.roles.smol`/`config.roles.slow` when `routing.tiers.*.model` isn't explicitly set — zero new config required to get working routing if role tiers are already configured. An image-bearing prompt whose resolved tier model lacks image support (`catalogMetadata(...)?.images === false`) fails open, returning `null` so the caller keeps its own multimodal-capable model. The entire function is wrapped in try/catch and returns `null` on any unexpected failure — routing can never break a turn.
|
|
14
|
+
- **`LaunchTui`/`runTurn` wiring** (`src/commands/launch.ts`) — the `activeModel`/`activeThinking` computation now calls `routePrompt` when routing is enabled (session override via `/route on|off`, else `config.routing.enabled`) AND the user has NOT manually pinned a session model via `/model` (explicit choice always wins, verified by a real end-to-end test through `runLaunchCommand` — routing never engages when `sessionModel` is set even with `routing.enabled: true`). A `[route] tier → model (...)` notice surfaces only when something actually changed (an LLM escalation occurred, or the tier isn't `standard`) — a routine unescalated turn stays silent.
|
|
15
|
+
- **`/route [status|on|off|why]`** (`src/commands/launch/route-slash.ts`, registered in `src/tui/components/slash.ts`) — session-local toggle (never persisted to `~/.jeo/config.json`, mirroring `/thinking`) plus a `why` subcommand that explains the last routing decision's tier, resolved model, confidence, source (heuristic/llm), and every fired signal.
|
|
16
|
+
- **`routing` config field** (`ConfigSchema` in `config-schema.ts`, `Config` in `state.ts`) — `{enabled?, confidenceThreshold?, tiers?: {trivial|standard|complex: {model?, thinking?}}}`, all optional, default OFF.
|
|
17
|
+
|
|
18
|
+
### Verified
|
|
19
|
+
- `bun run typecheck` clean; `bun test` 2479 pass / 0 fail (48 new: `test/prompt-router.test.ts` — 32 tests covering the full bilingual signal corpus incl. the causal-question and numeric-fraction-path false-positive fixes found during design, escalation fail-open on `callLlm` throwing/aborting/returning malformed or invalid-tier JSON, the image-capability gate, and the "no config at all resolves to defaultModel" safe-no-op case; `test/route-slash.test.ts` — 12 tests for every `/route` subcommand; `test/launch-prompt-routing.test.ts` — 4 real end-to-end tests through the actual `runLaunchCommand` entrypoint, mocking only `node:readline` input and `runAgentLoop`'s network boundary, proving the explicit-`/model`-always-wins guarantee and `roles.smol`/`routing.tiers.*.thinking` resolution against the REAL wiring, not a synthetic stub).
|
|
20
|
+
- Two independent subagent passes (config-schema + core router module in parallel, then launch.ts/slash wiring), every line independently re-read and cross-checked against a frozen, pre-verified implementation contract by the orchestrating session before merge — including two real bugs caught in the contract itself before implementation (a TDZ reference to `tui` before its declaration, and invalid `??`/`||` operator mixing) that both subagents correctly fixed as mechanical corrections.
|
|
21
|
+
|
|
22
|
+
## [0.7.46] - 2026-07-06
|
|
23
|
+
_Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below._
|
|
24
|
+
|
|
25
|
+
## [0.7.45] - 2026-07-06
|
|
26
|
+
_gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running._
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- **Concurrent executor fan-out** (`src/agent/task-tool.ts`) — a `task {role:"executor", tasks:[...]}` batch now runs with the same bounded concurrency as the read-only roles (`MAX_FANOUT` = 4), matching gjc's own default of parallelizing independent tasks. Removed the now-unsafe cross-task "chain previous output into next task's context" behavior, which assumed strict serial ordering (task i-1 always done before task i starts) that concurrent execution no longer guarantees; a task that genuinely depends on another's output now belongs in a sequential follow-up `task` call. `taskToolProtocolLine` documents the resulting expectation: scope each concurrent executor task to disjoint files, since jeo has no in-batch peer-coordination channel yet (overlapping scopes should run sequentially instead).
|
|
30
|
+
- **Per-slot concurrent subagent live-activity tracking** (`src/tui/app.ts`) — the single shared `subagentLive` string is replaced by `subagentLiveSlots` (a `Map` keyed on the event's fan-out `index`). The live status row now shows the most-recently-active slot plus a `(+N more running)` count so a parallel batch visibly reads as parallel, and — the more important fix — one worker reaching "done" now clears ONLY that worker's slot instead of nuking the `(sub)` marker for the whole still-running batch.
|
|
31
|
+
|
|
32
|
+
### Verified
|
|
33
|
+
- `bun run typecheck` clean; `bun test` 2479 pass / 0 fail.
|
|
34
|
+
- `test/task-tool.test.ts`: rewrote the two tests that pinned the old forced-serial/chaining behavior — an overlapping-sleep probe now proves the executor batch's calls genuinely overlap in wall-clock time (`maxConcurrent > 1`), and a follow-up test confirms no chain note leaks between concurrent executor tasks.
|
|
35
|
+
- `test/subagent-live-activity.test.ts`: two new tests pin the per-slot fix — concurrent slots render with a running count, and one slot finishing early does not clear the marker while a sibling slot is still active.
|
|
36
|
+
|
|
9
37
|
## [0.7.44] - 2026-07-06
|
|
10
38
|
_Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call._
|
|
11
39
|
|
package/README.ja.md
CHANGED
|
@@ -259,11 +259,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
259
259
|
## 変更履歴 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.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.
|
|
263
|
+
- **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
|
|
264
|
+
- **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
|
|
262
265
|
- **[0.7.44]** (2026-07-06) — Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call.
|
|
263
266
|
- **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
|
|
264
|
-
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
265
|
-
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
266
|
-
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -259,11 +259,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
259
259
|
## 변경 이력 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.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.
|
|
263
|
+
- **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
|
|
264
|
+
- **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
|
|
262
265
|
- **[0.7.44]** (2026-07-06) — Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call.
|
|
263
266
|
- **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
|
|
264
|
-
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
265
|
-
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
266
|
-
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -261,11 +261,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
261
261
|
## Changelog
|
|
262
262
|
|
|
263
263
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
264
|
+
- **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
|
|
265
|
+
- **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
|
|
266
|
+
- **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
|
|
264
267
|
- **[0.7.44]** (2026-07-06) — Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call.
|
|
265
268
|
- **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
|
|
266
|
-
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
267
|
-
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
268
|
-
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
269
269
|
|
|
270
270
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
271
271
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -259,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
259
259
|
## 更新日志 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.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.
|
|
263
|
+
- **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
|
|
264
|
+
- **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
|
|
262
265
|
- **[0.7.44]** (2026-07-06) — Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call.
|
|
263
266
|
- **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
|
|
264
|
-
- **[0.7.42]** (2026-07-06) — Fixes a genuine infinite hang on "thinking"/"executing" (reported under `jeo --tmux`, but the two root causes are tmux-agnostic): the interactive streaming model call had no absolute wall-clock, and the ~30-minute turn stall budget was a passive check that could never interrupt a blocked await in the first place. A third, lower-confidence but plausible tmux-specific trigger (synchronous TUI frame writes blocking on a backpressured tmux pane) gets a defensive, generic fix too.
|
|
265
|
-
- **[0.7.41]** (2026-07-06) — Refusal-handling workflow audit (follow-up to 0.7.39): fixes a HIGH-severity, long-masked bug where Gemini's real refusal error shape never matched jeo's refusal detector, plus Antigravity's opaque empty-response error, plus a category-aware fail-fast for deterministic ToS-severity refusals that previously spun the full 30-minute stall budget with zero chance of success.
|
|
266
|
-
- **[0.7.40]** (2026-07-06) — Fixes the release CI's standalone-binary build, broken since before v0.7.31 (last release where that job actually ran and passed) — the `release-binaries` job on v0.7.38 and v0.7.39 both failed silently on every platform target, so neither release has downloadable binaries attached.
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -161,6 +161,24 @@ export const ConfigSchema = z
|
|
|
161
161
|
plan: z.string().optional(),
|
|
162
162
|
})
|
|
163
163
|
.optional(),
|
|
164
|
+
/** Prompt-content-based per-turn model routing (PromptRouter). Opt-in — off unless
|
|
165
|
+
* `enabled: true`. Applies ONLY to the main interactive turn loop (never to
|
|
166
|
+
* subagents/`task`, which keep their own `subagents.*`/role-tier resolution
|
|
167
|
+
* untouched), and ONLY when the user has not manually pinned a session model
|
|
168
|
+
* via `/model` (explicit user choice always wins). */
|
|
169
|
+
routing: z
|
|
170
|
+
.object({
|
|
171
|
+
enabled: z.boolean().optional(),
|
|
172
|
+
confidenceThreshold: z.number().min(0).max(1).optional(),
|
|
173
|
+
tiers: z
|
|
174
|
+
.object({
|
|
175
|
+
trivial: z.object({ model: z.string().optional(), thinking: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional() }).optional(),
|
|
176
|
+
standard: z.object({ model: z.string().optional(), thinking: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional() }).optional(),
|
|
177
|
+
complex: z.object({ model: z.string().optional(), thinking: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional() }).optional(),
|
|
178
|
+
})
|
|
179
|
+
.optional(),
|
|
180
|
+
})
|
|
181
|
+
.optional(),
|
|
164
182
|
gitAutoCommit: z.boolean().optional(),
|
|
165
183
|
hooks: HookConfigSchema.optional(),
|
|
166
184
|
computer: z
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PromptRouter: per-turn, prompt-content-based model routing (heuristic-first,
|
|
3
|
+
* LLM-escalation-only-when-ambiguous, always fail-open).
|
|
4
|
+
*
|
|
5
|
+
* Why heuristic-first instead of always asking a model to classify: a classifier
|
|
6
|
+
* call on every turn would burn latency+cost on the common case (most turns are
|
|
7
|
+
* unambiguously trivial or unambiguously routine). The regex heuristic below
|
|
8
|
+
* resolves the vast majority of turns with zero I/O; only genuinely ambiguous
|
|
9
|
+
* prompts (conflicting or absent signals) pay for a cheap escalation call.
|
|
10
|
+
*
|
|
11
|
+
* Why fail-open everywhere: this module runs on EVERY qualifying turn (see
|
|
12
|
+
* launch.ts's `runTurn`). A routing bug or a flaky classifier call must never
|
|
13
|
+
* be able to break a real user turn — every internal step swallows its own
|
|
14
|
+
* failures and falls back to a safe default, and the top-level export wraps
|
|
15
|
+
* everything again as a final backstop.
|
|
16
|
+
*
|
|
17
|
+
* Why no static tier→model map: `resolveTierModel` computes inline off
|
|
18
|
+
* `config.roles`/`config.routing.tiers` so a user who already configured
|
|
19
|
+
* `roles.smol`/`roles.slow` (for `--smol`/`--slow` role-tier resolution
|
|
20
|
+
* elsewhere) gets working routing with zero additional config, and an
|
|
21
|
+
* unconfigured role falls through to `defaultModel` exactly like every other
|
|
22
|
+
* role-tier consumer in this codebase (see `resolveRoleModel`).
|
|
23
|
+
*/
|
|
24
|
+
import { callLlm } from "./loop";
|
|
25
|
+
import { resolveRoleModel } from "../ai/model-manager";
|
|
26
|
+
import { catalogMetadata } from "../ai/model-catalog";
|
|
27
|
+
import { tryExtractJsonObject } from "./json";
|
|
28
|
+
import type { Config } from "./state";
|
|
29
|
+
import type { ThinkLevel } from "../ai/model-catalog";
|
|
30
|
+
|
|
31
|
+
export type PromptTier = "trivial" | "standard" | "complex";
|
|
32
|
+
|
|
33
|
+
export interface HeuristicResult {
|
|
34
|
+
tier: PromptTier;
|
|
35
|
+
confidence: number; // 0..1
|
|
36
|
+
signals: string[]; // fired signal names, e.g. ["short-question","no-code-no-path"]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RouteDecision {
|
|
40
|
+
model: string;
|
|
41
|
+
thinking?: ThinkLevel; // undefined = do not override the session/global thinking level
|
|
42
|
+
tier: PromptTier;
|
|
43
|
+
confidence: number;
|
|
44
|
+
source: "heuristic" | "llm";
|
|
45
|
+
signals: string[];
|
|
46
|
+
/** Present only on the "smol role not configured" first-occurrence case — see warnOnce below. */
|
|
47
|
+
warning?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RoutePromptOptions {
|
|
51
|
+
hasImages?: boolean;
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Frozen, bilingual-validated classification signals (do not re-derive). ---
|
|
56
|
+
|
|
57
|
+
const RE_CAUSAL = /\b(why|how|root cause|how come)\b/i;
|
|
58
|
+
const RE_CAUSAL_KR = /(왜\s|근본\s*원인|이유가|어떻게)/;
|
|
59
|
+
const RE_TRIVIAL_QUESTION_WORD = /\b(what|who|when|where|which)\b/i;
|
|
60
|
+
const RE_TRIVIAL_QUESTION_WORD_KR = /(뭐|뭔가요|무엇|언제|어디|누구|몇\s|인가요|있나요)/;
|
|
61
|
+
const RE_CODE_FENCE = /```/;
|
|
62
|
+
const RE_PATH_TOKEN = /(?:^|[\s"'`(])@?(?:\.{0,2}[\w-]*\/)+[\w-]+(?:\.[\w-]+)*\.[A-Za-z]\w{0,7}\b/g;
|
|
63
|
+
const RE_DEEP_KEYWORDS_EN = /\b(design|architecture|refactor|debug|deep\s*dive|investigate|diagnose|redesign)\b/i;
|
|
64
|
+
const RE_DEEP_KEYWORDS_KR = /(설계|아키텍처|리팩터|디버그|딥\s*다이브|딥다이브|진단)/;
|
|
65
|
+
const RE_SENTENCE_BOUNDARY = /[.!?][ \n]|[.!?]$/g;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pure, synchronous, zero-I/O prompt classification. Every branch and threshold
|
|
69
|
+
* here is frozen per the PromptRouter contract (validated against a 17+-case
|
|
70
|
+
* bilingual corpus) — in particular: a causal ("why"/"how"/"왜"/"어떻게") question
|
|
71
|
+
* is NEVER treated as trivial just because it's short and question-shaped (a
|
|
72
|
+
* debugging question is the opposite of trivial), and the path-token regex is
|
|
73
|
+
* anchored so numeric fractions like "3/4.5" or "10/10" never false-positive as
|
|
74
|
+
* a file path (the trailing segment must end in a letter-led extension).
|
|
75
|
+
*/
|
|
76
|
+
export function classifyPromptHeuristically(prompt: string): HeuristicResult {
|
|
77
|
+
const trimmed = prompt.trim();
|
|
78
|
+
const signals: string[] = [];
|
|
79
|
+
let trivialScore = 0;
|
|
80
|
+
let complexScore = 0;
|
|
81
|
+
|
|
82
|
+
const isCausal = RE_CAUSAL.test(trimmed) || RE_CAUSAL_KR.test(trimmed);
|
|
83
|
+
|
|
84
|
+
// trivial signal 1: short AND a factual-lookup-shaped question (never for a causal question).
|
|
85
|
+
const isShort = trimmed.length < 40;
|
|
86
|
+
const looksLikeFactualQuestion =
|
|
87
|
+
!isCausal &&
|
|
88
|
+
(trimmed.includes("?") || RE_TRIVIAL_QUESTION_WORD.test(trimmed) || RE_TRIVIAL_QUESTION_WORD_KR.test(trimmed));
|
|
89
|
+
if (isShort && looksLikeFactualQuestion) {
|
|
90
|
+
trivialScore++;
|
|
91
|
+
signals.push("short-question");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// trivial signal 2: no code fence AND no file path mention.
|
|
95
|
+
const hasCodeFence = RE_CODE_FENCE.test(trimmed);
|
|
96
|
+
const pathMatches = [...trimmed.matchAll(RE_PATH_TOKEN)];
|
|
97
|
+
if (!hasCodeFence && pathMatches.length === 0) {
|
|
98
|
+
trivialScore++;
|
|
99
|
+
signals.push("no-code-no-path");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// complex signal 1: deep-work keyword.
|
|
103
|
+
if (RE_DEEP_KEYWORDS_EN.test(trimmed) || RE_DEEP_KEYWORDS_KR.test(trimmed)) {
|
|
104
|
+
complexScore++;
|
|
105
|
+
signals.push("deep-work-keyword");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// complex signal 1b: causal/debugging question.
|
|
109
|
+
if (isCausal) {
|
|
110
|
+
complexScore++;
|
|
111
|
+
signals.push("causal-question");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// complex signal 2: long or multi-sentence.
|
|
115
|
+
const questionMarkCount = (trimmed.match(/\?/g) ?? []).length;
|
|
116
|
+
const sentenceCount = (trimmed.match(RE_SENTENCE_BOUNDARY) ?? []).length;
|
|
117
|
+
if (trimmed.length >= 200 || questionMarkCount >= 2 || sentenceCount >= 3) {
|
|
118
|
+
complexScore++;
|
|
119
|
+
signals.push("long-or-multi-sentence");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// complex signal 3: multiple distinct file mentions.
|
|
123
|
+
const distinctPaths = new Set(pathMatches.map(m => m[0].trim()));
|
|
124
|
+
if (distinctPaths.size >= 2) {
|
|
125
|
+
complexScore++;
|
|
126
|
+
signals.push("multi-file");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Tier + confidence resolution (frozen thresholds).
|
|
130
|
+
if (trivialScore > 0 && complexScore > 0) {
|
|
131
|
+
return { tier: "standard", confidence: 0.35, signals }; // conflicting signals -> let escalation decide
|
|
132
|
+
}
|
|
133
|
+
if (complexScore >= 2) return { tier: "complex", confidence: 0.85, signals };
|
|
134
|
+
if (complexScore === 1) return { tier: "complex", confidence: 0.65, signals };
|
|
135
|
+
if (trivialScore >= 2) return { tier: "trivial", confidence: 0.85, signals };
|
|
136
|
+
if (trivialScore === 1) return { tier: "trivial", confidence: 0.65, signals };
|
|
137
|
+
return { tier: "standard", confidence: 0.9, signals }; // no signals at all -> common case, high confidence
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- warnOnce: module-level one-time warning surface (no existing helper for this). ---
|
|
141
|
+
|
|
142
|
+
const warnedKeys = new Set<string>();
|
|
143
|
+
|
|
144
|
+
/** Returns `message` the first time `key` is seen in this process, `undefined` every
|
|
145
|
+
* time after — lets a fail-open path surface a diagnostic exactly once instead of
|
|
146
|
+
* spamming it on every qualifying turn. */
|
|
147
|
+
export function warnOnce(key: string, message: string): string | undefined {
|
|
148
|
+
if (warnedKeys.has(key)) return undefined;
|
|
149
|
+
warnedKeys.add(key);
|
|
150
|
+
return message;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Test-only: clear warned-once state between test cases (mirrors
|
|
154
|
+
* src/tui/components/color.ts's resetAppearanceCache pattern). */
|
|
155
|
+
export function resetPromptRouterWarnings(): void {
|
|
156
|
+
warnedKeys.clear();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- Tier -> model/thinking resolution (inline off config.roles/config.routing.tiers; no static map). ---
|
|
160
|
+
|
|
161
|
+
function resolveTierModel(tier: PromptTier, config: Pick<Config, "defaultModel" | "roles" | "routing">): string {
|
|
162
|
+
const configured = config.routing?.tiers?.[tier]?.model;
|
|
163
|
+
if (configured) return configured;
|
|
164
|
+
if (tier === "trivial") return config.roles?.smol || config.defaultModel;
|
|
165
|
+
if (tier === "complex") return config.roles?.slow || config.defaultModel;
|
|
166
|
+
return config.defaultModel; // "standard" always falls through to defaultModel unless explicitly configured
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resolveTierThinking(tier: PromptTier, config: Pick<Config, "defaultModel" | "roles" | "routing">): ThinkLevel | undefined {
|
|
170
|
+
return config.routing?.tiers?.[tier]?.thinking; // undefined = no override, caller keeps session/global level
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const VALID_TIERS: readonly PromptTier[] = ["trivial", "standard", "complex"];
|
|
174
|
+
function isPromptTier(value: unknown): value is PromptTier {
|
|
175
|
+
return typeof value === "string" && (VALID_TIERS as readonly string[]).includes(value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Hard cap on how much of the prompt is embedded in the escalation classifier call —
|
|
179
|
+
* never send megabytes of user text to a cheap classifier model. */
|
|
180
|
+
const ESCALATION_PROMPT_CHAR_CAP = 2000;
|
|
181
|
+
|
|
182
|
+
function buildClassifierPrompt(prompt: string): string {
|
|
183
|
+
const capped = prompt.length > ESCALATION_PROMPT_CHAR_CAP ? prompt.slice(0, ESCALATION_PROMPT_CHAR_CAP) : prompt;
|
|
184
|
+
return (
|
|
185
|
+
`Classify the complexity of the following user request for a coding assistant into exactly one ` +
|
|
186
|
+
`tier: "trivial" (a quick factual lookup, no repo work), "standard" (routine coding/editing work), ` +
|
|
187
|
+
`or "complex" (multi-file, architectural, or root-cause debugging work). ` +
|
|
188
|
+
`Respond with ONLY a JSON object of the shape {"tier":"trivial"|"standard"|"complex"}.\n\n` +
|
|
189
|
+
`Request:\n${capped}`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Attempt LLM-based re-classification for an ambiguous heuristic result. Fail-open:
|
|
194
|
+
* ANY failure (throw, abort/timeout, malformed JSON, invalid tier value) resolves to
|
|
195
|
+
* `undefined` so the caller falls back to the heuristic tier silently. */
|
|
196
|
+
async function escalateToLlm(prompt: string, smolModel: string, signal: AbortSignal | undefined): Promise<PromptTier | undefined> {
|
|
197
|
+
try {
|
|
198
|
+
const raw = await callLlm([{ role: "user", content: buildClassifierPrompt(prompt) }], {
|
|
199
|
+
model: smolModel,
|
|
200
|
+
jsonMode: true,
|
|
201
|
+
maxTokens: 200,
|
|
202
|
+
signal,
|
|
203
|
+
});
|
|
204
|
+
const parsed = tryExtractJsonObject<{ tier?: unknown }>(raw);
|
|
205
|
+
if (parsed && isPromptTier(parsed.tier)) return parsed.tier;
|
|
206
|
+
return undefined;
|
|
207
|
+
} catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Stable key for the one-time "escalation skipped, roles.smol unconfigured" notice. */
|
|
213
|
+
const SMOL_UNCONFIGURED_WARNING_KEY = "prompt-router:smol-unconfigured";
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Route a single turn's prompt to a tier-appropriate model. Heuristic-first: only
|
|
217
|
+
* escalates to a real LLM call when the heuristic's confidence is below
|
|
218
|
+
* `config.routing?.confidenceThreshold` (default 0.6). Returns `null` (never
|
|
219
|
+
* throws) when routing should not override the caller's own already-computed
|
|
220
|
+
* model — either because of an unexpected internal failure (defensive backstop;
|
|
221
|
+
* every internal step already fails open on its own) or because the resolved
|
|
222
|
+
* tier's model lacks image support for an image-bearing turn (the caller must
|
|
223
|
+
* keep whatever multimodal-capable model it already had).
|
|
224
|
+
*/
|
|
225
|
+
export async function routePrompt(
|
|
226
|
+
prompt: string,
|
|
227
|
+
config: Pick<Config, "defaultModel" | "roles" | "routing">,
|
|
228
|
+
opts: RoutePromptOptions = {},
|
|
229
|
+
): Promise<RouteDecision | null> {
|
|
230
|
+
try {
|
|
231
|
+
const heuristic = classifyPromptHeuristically(prompt);
|
|
232
|
+
const threshold = config.routing?.confidenceThreshold ?? 0.6;
|
|
233
|
+
|
|
234
|
+
let tier = heuristic.tier;
|
|
235
|
+
let source: "heuristic" | "llm" = "heuristic";
|
|
236
|
+
let warning: string | undefined;
|
|
237
|
+
|
|
238
|
+
if (heuristic.confidence < threshold) {
|
|
239
|
+
const smolModel = resolveRoleModel("smol", config);
|
|
240
|
+
if (smolModel === config.defaultModel) {
|
|
241
|
+
// roles.smol not configured -> "escalate to a cheap model" would actually call
|
|
242
|
+
// defaultModel (expensive) — the exact paradox the design doc identifies. Skip
|
|
243
|
+
// escalation, keep the heuristic result, and surface a one-time warning.
|
|
244
|
+
warning = warnOnce(
|
|
245
|
+
SMOL_UNCONFIGURED_WARNING_KEY,
|
|
246
|
+
"[route] confidence below threshold but roles.smol is not configured — skipping LLM escalation, using heuristic tier (set roles.smol to enable escalation)",
|
|
247
|
+
);
|
|
248
|
+
} else {
|
|
249
|
+
const escalated = await escalateToLlm(prompt, smolModel, opts.signal);
|
|
250
|
+
if (escalated) {
|
|
251
|
+
tier = escalated;
|
|
252
|
+
source = "llm";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const model = resolveTierModel(tier, config);
|
|
258
|
+
if (opts.hasImages && catalogMetadata(model)?.images === false) return null;
|
|
259
|
+
|
|
260
|
+
const decision: RouteDecision = {
|
|
261
|
+
model,
|
|
262
|
+
thinking: resolveTierThinking(tier, config),
|
|
263
|
+
tier,
|
|
264
|
+
confidence: heuristic.confidence,
|
|
265
|
+
source,
|
|
266
|
+
signals: heuristic.signals,
|
|
267
|
+
};
|
|
268
|
+
if (warning) decision.warning = warning;
|
|
269
|
+
return decision;
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
package/src/agent/state.ts
CHANGED
|
@@ -122,6 +122,17 @@ export interface Config {
|
|
|
122
122
|
* `defaultModel`. Env `JEO_SMOL_MODEL`/`JEO_SLOW_MODEL`/`JEO_PLAN_MODEL` fill gaps.
|
|
123
123
|
*/
|
|
124
124
|
roles?: { smol?: string; slow?: string; plan?: string };
|
|
125
|
+
/** Prompt-content-based per-turn model routing (PromptRouter) — see prompt-router.ts.
|
|
126
|
+
* Opt-in (undefined/false = off). Never affects subagent/role-tier model resolution. */
|
|
127
|
+
routing?: {
|
|
128
|
+
enabled?: boolean;
|
|
129
|
+
confidenceThreshold?: number;
|
|
130
|
+
tiers?: {
|
|
131
|
+
trivial?: { model?: string; thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" };
|
|
132
|
+
standard?: { model?: string; thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" };
|
|
133
|
+
complex?: { model?: string; thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" };
|
|
134
|
+
};
|
|
135
|
+
};
|
|
125
136
|
gitAutoCommit?: boolean;
|
|
126
137
|
hooks?: HookConfig;
|
|
127
138
|
computer?: {
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -62,22 +62,28 @@ export interface TaskToolOptions {
|
|
|
62
62
|
/** Optional live sink (e.g. plain-stream rendering of nested progress). */
|
|
63
63
|
onEvent?: (ev: TaskSubEvent) => void;
|
|
64
64
|
/** Mid-turn steering drain (gjc parity): an additional user query typed while a
|
|
65
|
-
* subagent works is forwarded live.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* sees each message exactly once. Unconsumed messages stay for the parent. */
|
|
65
|
+
* subagent works is forwarded live. A single-task run forwards to the one active
|
|
66
|
+
* subagent. Any fan-out batch (both roles now run CONCURRENTLY, bounded at
|
|
67
|
+
* MAX_FANOUT) routes through a broadcast hub (createSteerHub) so every running
|
|
68
|
+
* worker sees each message exactly once. Unconsumed messages stay for the parent. */
|
|
69
69
|
steer?: () => string[];
|
|
70
70
|
/** When present, a `task` call with `detached: true` registers a background run
|
|
71
71
|
* here and returns immediately; the parent controls it via the `subagent` tool. */
|
|
72
72
|
registry?: SubagentRegistry;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** Max concurrent
|
|
75
|
+
/** Max concurrent subagents in a fan-out batch (both read-only AND the mutating
|
|
76
|
+
* executor role — gjc parity: gjc's own `task` tool runs independent tasks
|
|
77
|
+
* concurrently by default and only sequences work that shares a large evolving
|
|
78
|
+
* artifact). A model batching executor tasks is expected to scope each one to
|
|
79
|
+
* disjoint files (documented in `taskToolProtocolLine`); overlapping scopes
|
|
80
|
+
* should either run sequentially (separate `task` calls) or coordinate — jeo has
|
|
81
|
+
* no in-batch peer channel yet, so overlapping-scope tasks MUST be sequential. */
|
|
76
82
|
const MAX_FANOUT = 4;
|
|
77
83
|
|
|
78
|
-
/** Hard cap on a
|
|
79
|
-
*
|
|
80
|
-
*
|
|
84
|
+
/** Hard cap on a fan-out BATCH SIZE (queue length, not concurrency): an unbounded
|
|
85
|
+
* queue behind MAX_FANOUT workers would still monopolize the parent turn for a
|
|
86
|
+
* long time. Split larger efforts into sequential task calls. */
|
|
81
87
|
const MAX_SERIAL_EXECUTOR = 6;
|
|
82
88
|
|
|
83
89
|
/** Minimum distinct, non-filler words a spawn-gate justification must contain.
|
|
@@ -128,7 +134,7 @@ export function taskToolProtocolLine(config?: Pick<Config, "subagents">): string
|
|
|
128
134
|
return (
|
|
129
135
|
`task {role, task|tasks[], context?} — delegate to a subagent ` +
|
|
130
136
|
`(role: ${subagentRoleIds(config).join("|")}; executor can edit, planner/architect/critic are read-only). ` +
|
|
131
|
-
`Pass 'tasks' (array) to fan out —
|
|
137
|
+
`Pass 'tasks' (array) to fan out — ALL roles run concurrently (bounded); scope each executor task to disjoint files (no shared-file coordination channel between concurrent tasks — use sequential task calls if scopes overlap). Integrate the findings yourself.`
|
|
132
138
|
);
|
|
133
139
|
}
|
|
134
140
|
|
|
@@ -399,23 +405,25 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
399
405
|
if (items.length === 0) {
|
|
400
406
|
return { success: false, output: "", error: "task fan-out requires a non-empty 'tasks' array of assignments." };
|
|
401
407
|
}
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
// the parent. Split larger efforts into
|
|
408
|
+
// A fan-out BATCH SIZE cap (queue length, not concurrency — see MAX_FANOUT
|
|
409
|
+
// below): an unbounded queue behind bounded concurrency would still
|
|
410
|
+
// monopolize the parent turn. Split larger executor efforts into
|
|
411
|
+
// sequential task calls.
|
|
405
412
|
if (!role.readOnly && items.length > MAX_SERIAL_EXECUTOR) {
|
|
406
413
|
return {
|
|
407
414
|
success: false,
|
|
408
415
|
output: "",
|
|
409
416
|
error:
|
|
410
|
-
`Executor fan-out of ${items.length} exceeds the
|
|
411
|
-
`
|
|
417
|
+
`Executor fan-out of ${items.length} exceeds the batch cap of ${MAX_SERIAL_EXECUTOR}. ` +
|
|
418
|
+
`Split into ≤${MAX_SERIAL_EXECUTOR}-task batches or sequential task calls.`,
|
|
412
419
|
};
|
|
413
420
|
}
|
|
414
421
|
// Spawn-gate lite (plan/gjc-inheritance.md B9, gjc spawn-gate 계승): a batch
|
|
415
422
|
// wider than MAX_FANOUT is refused BEFORE any subagent launches unless the
|
|
416
423
|
// model justifies the parallelism — silent capping hid the cost decision.
|
|
417
424
|
// NOTE: the justification permits a LARGER QUEUE only; running concurrency
|
|
418
|
-
// stays bounded at MAX_FANOUT (
|
|
425
|
+
// stays bounded at MAX_FANOUT regardless (both roles, since v0.7.45 — see
|
|
426
|
+
// MAX_FANOUT's docstring).
|
|
419
427
|
if (items.length > MAX_FANOUT) {
|
|
420
428
|
const justification = typeof args.justification === "string" ? args.justification.trim() : "";
|
|
421
429
|
if (!isMeaningfulJustification(justification)) {
|
|
@@ -428,9 +436,9 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
428
436
|
};
|
|
429
437
|
}
|
|
430
438
|
}
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
const limit =
|
|
439
|
+
// Both roles fan out CONCURRENTLY, bounded at MAX_FANOUT (gjc parity — see
|
|
440
|
+
// MAX_FANOUT's docstring for the disjoint-file-scope expectation this relies on).
|
|
441
|
+
const limit = Math.min(items.length, MAX_FANOUT);
|
|
434
442
|
// Load project context ONCE per batch instead of re-scanning AGENTS.md for
|
|
435
443
|
// every fan-out task (redundant IO + duplicated tokens).
|
|
436
444
|
const batchContext = await loadProjectContext(cwd);
|
|
@@ -446,18 +454,18 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
446
454
|
while (true) {
|
|
447
455
|
const i = next++;
|
|
448
456
|
if (i >= items.length) return;
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
457
|
+
// No cross-task chaining: with bounded CONCURRENT execution, task i-1 is
|
|
458
|
+
// not guaranteed to have finished before task i starts (removed — it was
|
|
459
|
+
// dead/misleading once the executor stopped being forced-serial). Each
|
|
460
|
+
// task runs isolated on its own slice of context; a task that genuinely
|
|
461
|
+
// depends on another's output belongs in a sequential follow-up call.
|
|
462
|
+
results[i] = await runOne(role, items[i]!.task, items[i]!.context, cwd, { slot: { index: i + 1, total: items.length }, projectContext: batchContext, steer: workerSteer });
|
|
454
463
|
}
|
|
455
464
|
|
|
456
465
|
};
|
|
457
466
|
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
458
467
|
const ok = results.filter(r => r.success).length;
|
|
459
|
-
const
|
|
460
|
-
const head = `[${role.title} fan-out] ${ok}/${items.length} completed (${mode}).`;
|
|
468
|
+
const head = `[${role.title} fan-out] ${ok}/${items.length} completed (concurrency ${limit}).`;
|
|
461
469
|
const combined = results.map((r, i) => `### Task ${i + 1}/${items.length}\n${r.output}`).join("\n\n");
|
|
462
470
|
return { success: ok === items.length, output: `${head}\n\n${combined}` };
|
|
463
471
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/route` slash-command handler extracted from launch.ts.
|
|
3
|
+
*
|
|
4
|
+
* Handles `/route [status|on|off|why]` (default/`status`: report the effective
|
|
5
|
+
* session routing state + the last decision, if any; `on`/`off`: toggle a
|
|
6
|
+
* SESSION-LOCAL override of `config.routing.enabled`; `why`: explain the last
|
|
7
|
+
* routing decision in detail). This block shares mutable REPL state
|
|
8
|
+
* (`sessionRouteOverride`, `lastRouteDecision`) with `runTurn`'s own routing
|
|
9
|
+
* insertion, so the caller passes the current values in via an explicit
|
|
10
|
+
* context object and reads back a result object instead of this function
|
|
11
|
+
* closing over REPL state directly.
|
|
12
|
+
*
|
|
13
|
+
* Session-level toggle only — `sessionRouteOverride` is never persisted to
|
|
14
|
+
* `~/.jeo/config.json` (mirrors `/thinking` being session-only vs. `/model
|
|
15
|
+
* save` being the explicit opt-in for persistence). `/route` has no `save`
|
|
16
|
+
* subcommand in v1.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { RouteDecision } from "../../agent/prompt-router";
|
|
20
|
+
|
|
21
|
+
export interface RouteSlashCtx {
|
|
22
|
+
sessionRouteOverride: boolean | undefined;
|
|
23
|
+
/** `turnConfig.routing?.enabled ?? false`, read fresh by the caller. */
|
|
24
|
+
routingConfigEnabled: boolean;
|
|
25
|
+
lastRouteDecision: RouteDecision | { note: string } | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RouteSlashResult {
|
|
29
|
+
/** Present only when changed by "on"/"off". */
|
|
30
|
+
sessionRouteOverride?: boolean | undefined;
|
|
31
|
+
/** Lines to print. */
|
|
32
|
+
lines: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const USAGE = "Usage: /route [status|on|off|why]";
|
|
36
|
+
|
|
37
|
+
function isRealDecision(decision: RouteDecision | { note: string } | null): decision is RouteDecision {
|
|
38
|
+
return !!decision && "model" in decision;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function explainDecision(decision: RouteDecision | { note: string } | null): string[] {
|
|
42
|
+
if (!isRealDecision(decision)) {
|
|
43
|
+
return [decision?.note ?? "No routing decision has been made yet this session."];
|
|
44
|
+
}
|
|
45
|
+
const lines = [
|
|
46
|
+
`tier: ${decision.tier}`,
|
|
47
|
+
`model: ${decision.model}`,
|
|
48
|
+
`source: ${decision.source}`,
|
|
49
|
+
`confidence: ${decision.confidence.toFixed(2)}`,
|
|
50
|
+
`signals: ${decision.signals.join(", ") || "none"}`,
|
|
51
|
+
];
|
|
52
|
+
if (decision.thinking) lines.push(`thinking: ${decision.thinking}`);
|
|
53
|
+
if (decision.warning) lines.push(`warning: ${decision.warning}`);
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Handle `/route [status|on|off|why]`. Extracted for the same reason as
|
|
59
|
+
* `/model`: shares REPL-local routing state with `runTurn` via an explicit
|
|
60
|
+
* ctx/result object rather than closing over it.
|
|
61
|
+
*/
|
|
62
|
+
export function runRouteSlash(input: string, ctx: RouteSlashCtx): RouteSlashResult {
|
|
63
|
+
const rest = input.slice("/route".length).trim();
|
|
64
|
+
const [sub] = rest.split(/\s+/).filter(Boolean);
|
|
65
|
+
const effective = ctx.sessionRouteOverride ?? ctx.routingConfigEnabled;
|
|
66
|
+
|
|
67
|
+
if (!sub || sub === "status") {
|
|
68
|
+
const lines = [`routing: ${effective ? "on" : "off"} (this session)`];
|
|
69
|
+
if (isRealDecision(ctx.lastRouteDecision)) {
|
|
70
|
+
lines.push(`last decision: ${ctx.lastRouteDecision.tier} → ${ctx.lastRouteDecision.model} (${ctx.lastRouteDecision.source}: ${ctx.lastRouteDecision.signals.join(", ") || "none"}, confidence ${ctx.lastRouteDecision.confidence.toFixed(2)})`);
|
|
71
|
+
}
|
|
72
|
+
return { lines };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (sub === "on") {
|
|
76
|
+
return { sessionRouteOverride: true, lines: ["routing: on (this session)"] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (sub === "off") {
|
|
80
|
+
return { sessionRouteOverride: false, lines: ["routing: off (this session)"] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (sub === "why") {
|
|
84
|
+
return { lines: explainDecision(ctx.lastRouteDecision) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { lines: [`Unknown /route subcommand: ${sub}`, USAGE] };
|
|
88
|
+
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -47,6 +47,7 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
|
|
|
47
47
|
import { describeModel, describeAllProviders, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId } from "../ai";
|
|
48
48
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
49
49
|
import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
|
|
50
|
+
import { routePrompt, type RouteDecision } from "../agent/prompt-router";
|
|
50
51
|
|
|
51
52
|
import { listAliases } from "../ai/model-registry";
|
|
52
53
|
import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/openai-compatible-catalog";
|
|
@@ -204,6 +205,7 @@ import {
|
|
|
204
205
|
import { handleUndoSlash } from "./launch/git-slash";
|
|
205
206
|
import { runSessionSlash } from "./launch/session-slash";
|
|
206
207
|
import { runModelSlash } from "./launch/model-slash";
|
|
208
|
+
import { runRouteSlash } from "./launch/route-slash";
|
|
207
209
|
import { runAgentsSlash, runRolesSlash, runFastSlash, runThinkingSlash } from "./launch/agents-slash";
|
|
208
210
|
import { wikiRootPromptLine, decideWikiSlash } from "./launch/wiki-slash";
|
|
209
211
|
import { decideThemeSlash, buildConfigPanelLines, runEvolveSimulation } from "./launch/system-slash";
|
|
@@ -642,6 +644,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
642
644
|
let sessionModel: string | undefined = initialSessionModel;
|
|
643
645
|
// Session thinking-level override (`/thinking`); falls back to the config level.
|
|
644
646
|
let sessionThinking: "minimal" | "low" | "medium" | "high" | "xhigh" | undefined = flags.thinking ?? cfg.thinkingLevel;
|
|
647
|
+
// PromptRouter session override: undefined = follow config.routing.enabled, true/false = /route on|off wins this session.
|
|
648
|
+
let sessionRouteOverride: boolean | undefined;
|
|
649
|
+
// Last routing decision (or the reason none applied) — /route why reads this.
|
|
650
|
+
let lastRouteDecision: RouteDecision | { note: string } | null = null;
|
|
645
651
|
// Cache of live, credential-validated models per provider (refreshed by live pickers).
|
|
646
652
|
let liveModelsCache: ProviderModelsResult[] | null = null;
|
|
647
653
|
const getLiveModels = async (force = false): Promise<ProviderModelsResult[]> => {
|
|
@@ -844,7 +850,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
844
850
|
// global default (rememberModelPatch), so re-reading turnConfig.defaultModel here let
|
|
845
851
|
// that write silently switch THIS running session's model mid-run. Per-session model
|
|
846
852
|
// selection must stay session-local: running sessions never influence each other.
|
|
847
|
-
const
|
|
853
|
+
const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
|
|
854
|
+
const routed = routingEnabled && !sessionModel
|
|
855
|
+
? await routePrompt(userInput, turnConfig, { hasImages: !!images?.length }).catch(() => null)
|
|
856
|
+
: null;
|
|
857
|
+
lastRouteDecision = routed ?? (routingEnabled && !sessionModel ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
|
|
858
|
+
// Explicit /model always wins: `routed` is only ever computed when `!sessionModel`.
|
|
859
|
+
const activeModel = routed?.model ?? (sessionModel || defaultModel);
|
|
860
|
+
const activeThinking = routed?.thinking ?? sessionThinking;
|
|
848
861
|
const contextTokens = catalogMetadata(activeModel)?.contextTokens;
|
|
849
862
|
|
|
850
863
|
// Resolve provider + dirty count up front — both are cheap and feed the live
|
|
@@ -852,7 +865,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
852
865
|
// config file. (gjc parity P1.B5: dirty count per-turn, not per-render.)
|
|
853
866
|
const { provider: activeProvider } = await describeModel(activeModel, turnConfig);
|
|
854
867
|
const turnDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
|
|
855
|
-
const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking:
|
|
868
|
+
const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: activeThinking }) : null;
|
|
869
|
+
if (routed?.warning) {
|
|
870
|
+
if (tui) tui.events().onNotice?.(routed.warning);
|
|
871
|
+
else console.log(routed.warning);
|
|
872
|
+
}
|
|
873
|
+
if (routed && (routed.source === "llm" || routed.tier !== "standard")) {
|
|
874
|
+
const routeNotice = `[route] ${routed.tier} → ${routed.model} (${routed.source}: ${routed.signals.join(", ") || "none"}, confidence ${routed.confidence.toFixed(2)})`;
|
|
875
|
+
if (tui) tui.events().onNotice?.(routeNotice);
|
|
876
|
+
else console.log(routeNotice);
|
|
877
|
+
}
|
|
856
878
|
tui?.setTurnTitle(userInput); // gjc-parity turn title → HUD + tmux pane title (no LLM call)
|
|
857
879
|
// `beforeLen` marks where this turn's appended messages start; it is re-read
|
|
858
880
|
// AFTER compaction (which mutates history) and consumed by the post-turn
|
|
@@ -1134,8 +1156,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1134
1156
|
tools,
|
|
1135
1157
|
maxSteps: flags.maxSteps,
|
|
1136
1158
|
model: activeModel,
|
|
1137
|
-
maxTokens: resolveMaxOutputTokens(activeModel,
|
|
1138
|
-
reasoningEffort:
|
|
1159
|
+
maxTokens: resolveMaxOutputTokens(activeModel, activeThinking),
|
|
1160
|
+
reasoningEffort: activeThinking ? thinkingToReasoningEffort(activeThinking) : undefined,
|
|
1139
1161
|
signal: ac.signal,
|
|
1140
1162
|
sessionKey: sessionId,
|
|
1141
1163
|
steer: drainSteer,
|
|
@@ -1154,8 +1176,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1154
1176
|
maxSteps: Math.min(6, flags.maxSteps > 0 ? flags.maxSteps : 6),
|
|
1155
1177
|
budget: { maxExtensions: 0 },
|
|
1156
1178
|
model: activeModel,
|
|
1157
|
-
maxTokens: resolveMaxOutputTokens(activeModel,
|
|
1158
|
-
reasoningEffort:
|
|
1179
|
+
maxTokens: resolveMaxOutputTokens(activeModel, activeThinking),
|
|
1180
|
+
reasoningEffort: activeThinking ? thinkingToReasoningEffort(activeThinking) : undefined,
|
|
1159
1181
|
signal: ac.signal,
|
|
1160
1182
|
sessionKey: sessionId,
|
|
1161
1183
|
steer: drainSteer,
|
|
@@ -3964,6 +3986,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3964
3986
|
if (modelResult.lastPickIndex !== undefined) lastPickIndex = modelResult.lastPickIndex;
|
|
3965
3987
|
continue;
|
|
3966
3988
|
}
|
|
3989
|
+
if (input.startsWith("/route") && (input === "/route" || input[6] === " ")) {
|
|
3990
|
+
const cfgNow = await readGlobalConfig();
|
|
3991
|
+
const routeResult = runRouteSlash(input, {
|
|
3992
|
+
sessionRouteOverride,
|
|
3993
|
+
routingConfigEnabled: !!cfgNow.routing?.enabled,
|
|
3994
|
+
lastRouteDecision,
|
|
3995
|
+
});
|
|
3996
|
+
if (routeResult.sessionRouteOverride !== undefined) sessionRouteOverride = routeResult.sessionRouteOverride;
|
|
3997
|
+
for (const line of routeResult.lines) console.log(line);
|
|
3998
|
+
continue;
|
|
3999
|
+
}
|
|
3967
4000
|
if (input.startsWith("/view") && (input === "/view" || input[5] === " ")) {
|
|
3968
4001
|
for (const line of await handleViewSlash(input, cwd)) console.log(line);
|
|
3969
4002
|
continue;
|
package/src/tui/app.ts
CHANGED
|
@@ -249,12 +249,20 @@ export class LaunchTui {
|
|
|
249
249
|
private workflowStatus: { skill: string; phase: string; detail?: string } | null = null;
|
|
250
250
|
// Cumulative token usage for the live turn (engine onUsage event).
|
|
251
251
|
private turnUsage: { inputTokens: number; outputTokens: number } | null = null;
|
|
252
|
-
// True while
|
|
252
|
+
// True while at least one delegated subagent SLOT is in flight — drives the `(sub)`
|
|
253
|
+
// status marker. Derived from `subagentLiveSlots.size` (see below) rather than a
|
|
254
|
+
// plain boolean so a fan-out batch where ONE worker finishes early no longer
|
|
255
|
+
// clears the marker while its siblings are still visibly running.
|
|
253
256
|
private subagentActive = false;
|
|
254
|
-
// Latest nested subagent activity
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
257
|
+
// Latest nested subagent activity, PER CONCURRENT SLOT (fan-out `task` batches — both
|
|
258
|
+
// the executor and read-only roles — now run several workers at once; a single
|
|
259
|
+
// shared string clobbered on every event made concurrent subagents look sequential
|
|
260
|
+
// and, worse, cleared the whole `(sub)` marker the instant ANY one worker finished).
|
|
261
|
+
// Keyed by the event's fan-out `index` (1-based); a single/detached run with no
|
|
262
|
+
// index shares the fixed key 0. `subagentLiveOrder` tracks touch order so the
|
|
263
|
+
// status row can show the MOST RECENTLY active slot plus a "+N more" count.
|
|
264
|
+
private readonly subagentLiveSlots = new Map<number, string>();
|
|
265
|
+
private readonly subagentLiveOrder: number[] = [];
|
|
258
266
|
// Bounded activity-history ring: one plain-text entry per ledger append, with a
|
|
259
267
|
// turn-relative timestamp. Powers Ctrl+O's "recent activity" tail so the detail
|
|
260
268
|
// view ALWAYS answers "what has been happening", even before the first reply.
|
|
@@ -936,8 +944,17 @@ export class LaunchTui {
|
|
|
936
944
|
}
|
|
937
945
|
// A delegated subagent's LATEST nested event beats the parent's static
|
|
938
946
|
// "Task: <role> …" card title — a long task otherwise reads as a stall even
|
|
939
|
-
// though the subagent is actively reading/editing/running underneath.
|
|
940
|
-
|
|
947
|
+
// though the subagent is actively reading/editing/running underneath. When a
|
|
948
|
+
// fan-out batch has MULTIPLE concurrent slots live, append a "+N more running"
|
|
949
|
+
// count so a parallel batch visibly reads as parallel instead of looking like
|
|
950
|
+
// one subagent whose label keeps randomly changing.
|
|
951
|
+
if (this.subagentActive && this.subagentLiveSlots.size > 0) {
|
|
952
|
+
const mostRecent = this.subagentLiveOrder[this.subagentLiveOrder.length - 1];
|
|
953
|
+
const line = (mostRecent !== undefined ? this.subagentLiveSlots.get(mostRecent) : undefined)
|
|
954
|
+
?? this.subagentLiveSlots.values().next().value!;
|
|
955
|
+
const extra = this.subagentLiveSlots.size - 1;
|
|
956
|
+
return extra > 0 ? `${line} (+${extra} more running)` : line;
|
|
957
|
+
}
|
|
941
958
|
// Waiting on the model and no tool is mid-flight → make the pause legible.
|
|
942
959
|
if (this.thinking && !running) {
|
|
943
960
|
const elapsed = this.currentStepStartedAt ? ((Date.now() - this.currentStepStartedAt) / 1000).toFixed(1) : "0.0";
|
|
@@ -993,30 +1010,47 @@ export class LaunchTui {
|
|
|
993
1010
|
const last = this.unicode ? "└─" : "`-";
|
|
994
1011
|
const detail = (e.detail ?? "").split("\n").find(l => l.trim().length > 0)?.trim().slice(0, 140) ?? "";
|
|
995
1012
|
const summary = e.summary ? ` — ${e.summary}` : "";
|
|
1013
|
+
// Fan-out slot key: the event's 1-based `index` when present (concurrent batch
|
|
1014
|
+
// worker), else the fixed key 0 (single-task/detached run — matches the prior
|
|
1015
|
+
// single-string behavior for the non-batch case).
|
|
1016
|
+
const slot = e.index ?? 0;
|
|
1017
|
+
const touchSlot = (line: string) => {
|
|
1018
|
+
this.subagentLiveSlots.set(slot, line);
|
|
1019
|
+
const at = this.subagentLiveOrder.indexOf(slot);
|
|
1020
|
+
if (at !== -1) this.subagentLiveOrder.splice(at, 1);
|
|
1021
|
+
this.subagentLiveOrder.push(slot);
|
|
1022
|
+
this.subagentActive = true;
|
|
1023
|
+
};
|
|
996
1024
|
// No `step N/M` marker on nested lines — step counters carry no meaning
|
|
997
1025
|
// under the dynamic budget (user feedback). A tree branch keeps subagent
|
|
998
1026
|
// activity readable in scrollback and visually separate from parent tools.
|
|
999
1027
|
switch (e.kind) {
|
|
1000
1028
|
case "start":
|
|
1001
|
-
this.
|
|
1002
|
-
this.subagentLive = `${roleLabel} ${this.unicode ? "▸" : ">"} ${detail || "starting"}`;
|
|
1029
|
+
touchSlot(`${roleLabel} ${this.unicode ? "▸" : ">"} ${detail || "starting"}`);
|
|
1003
1030
|
this.appendLedger(`${badge} ${this.unicode ? "▸" : ">"} ${roleLabel} · ${detail}\n`, "subagent");
|
|
1004
1031
|
break;
|
|
1005
1032
|
case "step":
|
|
1006
|
-
|
|
1033
|
+
touchSlot(`${roleLabel} ${this.unicode ? "·" : "-"} ${detail || "working"}`);
|
|
1007
1034
|
this.appendLedger(` ${badge} ${branch} ${roleLabel} · ${detail || "working"}\n`, "subagent");
|
|
1008
1035
|
break;
|
|
1009
1036
|
case "tool":
|
|
1010
|
-
|
|
1037
|
+
touchSlot(`${roleLabel} ${e.success === false ? bad : ok} ${detail || "tool"}`);
|
|
1011
1038
|
this.appendLedger(` ${badge} ${branch} ${roleLabel} ${e.success === false ? bad : ok} ${detail || "tool"}${summary}\n`, "subagent");
|
|
1012
1039
|
break;
|
|
1013
1040
|
case "error":
|
|
1014
|
-
|
|
1041
|
+
touchSlot(`${roleLabel} ${bad} ${detail || "error"}`);
|
|
1015
1042
|
this.appendLedger(` ${badge} ${branch} ${roleLabel} ${bad} ${detail || "error"}\n`, "subagent");
|
|
1016
1043
|
break;
|
|
1017
1044
|
case "done":
|
|
1018
|
-
this
|
|
1019
|
-
|
|
1045
|
+
// Clear ONLY this slot — a fan-out batch where one worker finishes early
|
|
1046
|
+
// must keep showing its still-running siblings, not drop the `(sub)`
|
|
1047
|
+
// marker for the whole batch (the bug this per-slot map fixes).
|
|
1048
|
+
this.subagentLiveSlots.delete(slot);
|
|
1049
|
+
{
|
|
1050
|
+
const at = this.subagentLiveOrder.indexOf(slot);
|
|
1051
|
+
if (at !== -1) this.subagentLiveOrder.splice(at, 1);
|
|
1052
|
+
}
|
|
1053
|
+
this.subagentActive = this.subagentLiveSlots.size > 0;
|
|
1020
1054
|
this.appendLedger(`${badge} ${last} ${roleLabel} done${e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : ""}${e.success === false ? " (incomplete)" : ""}: ${detail}\n`, "subagent");
|
|
1021
1055
|
break;
|
|
1022
1056
|
}
|
|
@@ -1030,7 +1064,7 @@ export class LaunchTui {
|
|
|
1030
1064
|
this.livePromptInput = ""; // fresh turn: no next-prompt draft yet
|
|
1031
1065
|
this.livePromptHint = []; // fresh turn: no mid-turn command preview yet
|
|
1032
1066
|
this.livePromptHighlight = undefined; // fresh turn: no active trigger token
|
|
1033
|
-
this.
|
|
1067
|
+
this.subagentLiveSlots.clear(); this.subagentLiveOrder.length = 0; this.subagentActive = false; // fresh turn: no nested subagent in flight
|
|
1034
1068
|
this.activityLog.length = 0; // per-turn ring: timestamps are turn-relative
|
|
1035
1069
|
this.spinner.updateStep(0, this.footer.maxSteps);
|
|
1036
1070
|
// completed ledger lines are flushed into normal scrollback as they happen, so a
|
|
@@ -43,6 +43,7 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
|
|
|
43
43
|
{ command: "/logout", usage: "/logout <anthropic|openai|gemini|antigravity>", description: "Remove the stored OAuth token for a provider", group: "models" },
|
|
44
44
|
{ command: "/roles", usage: "/roles [tier model]", description: "Show or set model role tiers (smol/slow/plan)", group: "models" },
|
|
45
45
|
{ command: "/thinking", usage: "/thinking [level]", description: "Show or set thinking budget (minimal/low/medium/high/xhigh)", group: "models" },
|
|
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" },
|
|
46
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" },
|
|
47
48
|
{ command: "/subagent", usage: "/subagent [role]", description: "Show the current subagent composition (per-role model · thinking · steps); alias of /agents", group: "subagents" },
|
|
48
49
|
{ command: "/view", usage: "/view <file> [a-b]", description: "Render a file with line numbers + light highlight", group: "code" },
|