jeo-code 0.7.46 → 0.7.48

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,32 @@ 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.48] - 2026-07-06
10
+ _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._
11
+
12
+ ### Added
13
+ - **Forum-topic routing** (`topicId` in `notifications.telegram` config, `config-schema.ts`/`state.ts`) — every daemon push carries the configured `message_thread_id` so a supergroup with Topics enabled routes jeo's notifications into one dedicated topic instead of the general channel; inbound `/subagents`/`/steer`/`/cancel`/`/help` commands are topic-filtered to match.
14
+ - **Inline-keyboard Cancel buttons + callback taps** (`src/agent/notify/telegram-api.ts`: `answerCallbackQuery`, `replyMarkup` on `sendMessage`/`sendPhoto`; `src/agent/notify/telegram-daemon.ts`) — a subagent's `started` push and `/subagents` listing now attach a per-subagent inline ⏹ Cancel button; tapping it round-trips through the same cancel path as the existing `/cancel <sessionId> <subagentId>` text command, then acknowledges the tap via `answerCallbackQuery` so Telegram clears its loading spinner. `TelegramUpdate` extended with `callback_query`/`message_thread_id`.
15
+ - **Image attachments** (`TelegramApi.sendPhoto`, `TelegramDaemon#notifyPhoto`) — a session `{type:"photo"}` frame is now relayed to Telegram via `sendPhoto` (caption + topic + inline-keyboard `replyMarkup` all supported), closing the last gap from 0.7.37's "no image attachments" scope note.
16
+
17
+ ### Verified
18
+ - `bun run typecheck` clean; `bun test` 2498 pass / 0 fail.
19
+ - `test/notify-telegram-api.test.ts` (+56 lines): `sendPhoto`/`answerCallbackQuery` request shape, `messageThreadId`/`replyMarkup` forwarding.
20
+ - `test/notify-telegram-daemon.test.ts` (+184 lines across two commits): topic-scoped push/inbound-filter round-trip, inline Cancel button attached to a `started` push and to `/subagents` output, a `callback_query` tap correctly cancels the matching subagent and acknowledges, and a session photo frame relayed via `sendPhoto` with caption + topic + `replyMarkup`.
21
+
22
+ ## [0.7.47] - 2026-07-06
23
+ _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._
24
+
25
+ ### Added
26
+ - **`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.
27
+ - **`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.
28
+ - **`/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.
29
+ - **`routing` config field** (`ConfigSchema` in `config-schema.ts`, `Config` in `state.ts`) — `{enabled?, confidenceThreshold?, tiers?: {trivial|standard|complex: {model?, thinking?}}}`, all optional, default OFF.
30
+
31
+ ### Verified
32
+ - `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).
33
+ - 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.
34
+
9
35
  ## [0.7.46] - 2026-07-06
10
36
  _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._
11
37
 
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.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.
263
+ - **[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.
262
264
  - **[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.
263
265
  - **[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
266
  - **[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
- - **[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
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -46,7 +46,8 @@
46
46
  - **동적 스텝 버짓** — 최근 도구 호출이 새로운 진전을 보이는 동안 연장되고, 정체되면 정리 요약으로 수렴. 서브에이전트는 정확한 스텝 계약 유지.
47
47
  - **인라인 TUI** — 완료된 작업은 실제 스크롤백으로 흘러가고(턴 중에도 tmux 휠 스크롤 가능), 에이전트가 실행 중이어도 기존 쿼리 입력창이 그대로 보이며 편집됩니다. Ctrl+O 상세 토글, 테마, 클립보드 이미지 붙여넣기(Ctrl+V), CJK/이모지 안전 폭 계산.
48
48
  - **브라우저 도구** — Playwright 기반 헤드리스 Chromium 자동화가 1급 에이전트 도구로 포함: 이름 붙은 탭을 재사용하며 `open`/`close`/`run`/`act`, 스크린샷보다 `observe`로 태깅된 요소 id를 우선 사용. `npx playwright install chromium`을 한 번 실행해야 함(번들 아님 — jeo 자체는 여전히 네이티브 의존성 0, 브라우저 바이너리는 Playwright의 별도 다운로드).
49
- - **원격 서브에이전트 가시성(Telegram)** — 봇을 한 번 페어링(`jeo notify setup`)하면, `jeo daemon start`가 서브에이전트 상태 전환(시작 → 완료/실패/취소)마다 메시지를 보내고 `/subagents`, `/steer <id> <subagentId> <msg>`, `/cancel <id> <subagentId>`를 되받습니다 — 명령은 페어링된 채팅에서만 허용됩니다.
49
+ - **원격 서브에이전트 가시성(Telegram)** — 봇을 한 번 페어링(`jeo notify setup`)하면, `jeo daemon start`가 서브에이전트 상태 전환(시작 → 완료/실패/취소)마다 메시지를 보내고 `/subagents`, `/steer <id> <subagentId> <msg>`, `/cancel <id> <subagentId>`를 되받습니다. 이제 Telegram 포럼 토픽, 인라인 키보드, 이미지 첨부 파일 지원 등 `gjc`와의 완전한 패리티를 제공하며, 명령은 페어링된 채팅에서만 허용됩니다.
50
+
50
51
 
51
52
  ## 설치
52
53
 
@@ -182,7 +183,7 @@ jeo daemon stop # SIGTERM으로 종료
182
183
  └─────────────────────┘ └─────────────────────┘ └─────────────────────┘
183
184
  ```
184
185
 
185
- 옵트인이며 지연 바인딩됩니다: `notifications.enabled`가 설정되고 detached 서브에이전트(`task {detached:true}`)가 실제로 실행되어야만 동작합니다. 데몬은 살아있는 세션 디스커버리 파일을 스캔해 세션별로 루프백 WebSocket을 연결하고, 서브에이전트 상태 *전환* 시점(시작 → 완료/실패/취소)에만 메시지를 보냅니다 — "여전히 실행 중" 같은 반복 알림은 없습니다. 수신되는 Telegram 명령은 페어링된 채팅에서만 허용되며, 그 외는 조용히 무시됩니다.
186
+ 옵트인이며 지연 바인딩됩니다: `notifications.enabled`가 설정되고 detached 서브에이전트(`task {detached:true}`)가 실제로 실행되어야만 동작합니다. 데몬은 살아있는 세션 디스커버리 파일을 스캔해 세션별로 루프백 WebSocket을 연결하고, 서브에이전트 상태 *전환* 시점(시작 → 완료/실패/취소)에만 메시지를 보냅니다 — "여전히 실행 중" 같은 반복 알림은 없습니다. 이제 Telegram 포럼 토픽, 인라인 키보드, 이미지 첨부 파일 지원 등 `gjc`와의 완전한 패리티를 제공합니다. 수신되는 Telegram 명령은 페어링된 채팅에서만 허용되며, 그 외는 조용히 무시됩니다.
186
187
 
187
188
  | 명령 | 동작 |
188
189
  | --- | --- |
@@ -259,11 +260,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
259
260
  ## 변경 이력 (Changelog)
260
261
 
261
262
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
263
+ - **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
264
+ - **[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.
262
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.
263
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
- - **[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
268
 
268
269
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
270
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -46,7 +46,8 @@ Run `jeo` inside a repository and it reads files, edits them, runs commands, and
46
46
  - **Dynamic step budget** — turns extend while the tool window shows novel progress and consolidate gracefully when stalled; subagents keep exact step contracts.
47
47
  - **Inline TUI** — completed work flushes into real scrollback (tmux wheel works mid-turn), the normal query input box stays visible and editable while the agent runs, Ctrl+O toggles full detail, themes, clipboard image paste (Ctrl+V), CJK/emoji-safe width math.
48
48
  - **Browser tool** — headless Chromium automation (Playwright) as a first-class agent tool: `open`/`close`/`run`/`act` on named, reused tabs, with `observe`-tagged element ids preferred over screenshots for driving pages. Requires `npx playwright install chromium` once (not bundled — jeo stays zero native deps itself, the browser binary is Playwright's separate download).
49
- - **Remote subagent visibility (Telegram)** — pair a bot once (`jeo notify setup`), then `jeo daemon start` pushes a message on every subagent state edge (started → done/failed/cancelled) and accepts `/subagents`, `/steer <id> <subagentId> <msg>`, `/cancel <id> <subagentId>` back — commands are authorized to the paired chat only.
49
+ - **Remote subagent visibility (Telegram)** — pair a bot once (`jeo notify setup`), then `jeo daemon start` pushes a message on every subagent state edge (started → done/failed/cancelled) and accepts `/subagents`, `/steer <id> <subagentId> <msg>`, `/cancel <id> <subagentId>` back. Telegram Daemon now supports full `gjc` parity, including forum topics, inline keyboards, and image attachments — commands are authorized to the paired chat only.
50
+
50
51
 
51
52
  ## Install
52
53
 
@@ -183,7 +184,7 @@ jeo daemon stop # SIGTERM it
183
184
  └─────────────────────┘ └─────────────────────┘ └─────────────────────┘
184
185
  ```
185
186
 
186
- Opt-in and lazy: nothing binds until `notifications.enabled` is set AND a detached subagent (`task {detached:true}`) actually runs. The daemon scans live session discovery files, connects a loopback WebSocket per session, and pushes a message only on a subagent state *edge* (started → completed/failed/cancelled) — never a repeated "still running" ping. Inbound Telegram commands are authorized to the paired chat only; anything else is dropped silently.
187
+ Opt-in and lazy: nothing binds until `notifications.enabled` is set AND a detached subagent (`task {detached:true}`) actually runs. The daemon scans live session discovery files, connects a loopback WebSocket per session, and pushes a message only on a subagent state *edge* (started → completed/failed/cancelled) — never a repeated "still running" ping. Telegram Daemon now supports full `gjc` parity, including forum topics, inline keyboards, and image attachments. Inbound Telegram commands are authorized to the paired chat only; anything else is dropped silently.
187
188
 
188
189
  | Command | Effect |
189
190
  | --- | --- |
@@ -261,11 +262,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
261
262
  ## Changelog
262
263
 
263
264
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
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.
264
267
  - **[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.
265
268
  - **[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.
266
269
  - **[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.
267
- - **[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.
268
- - **[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.
269
270
 
270
271
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
271
272
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -259,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
259
259
  ## 更新日志 (Changelog)
260
260
 
261
261
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
262
+ - **[0.7.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.
263
+ - **[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.
262
264
  - **[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.
263
265
  - **[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
266
  - **[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
- - **[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
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.46",
3
+ "version": "0.7.48",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -96,6 +96,9 @@ export const ConfigSchema = z
96
96
  .object({
97
97
  botToken: z.string().optional(),
98
98
  chatId: z.string().optional(),
99
+ /** Forum-topic thread id (message_thread_id) for supergroups with
100
+ * topics enabled — daemon pushes go into this topic. */
101
+ topicId: z.number().optional(),
99
102
  })
100
103
  .optional(),
101
104
  })
@@ -161,6 +164,24 @@ export const ConfigSchema = z
161
164
  plan: z.string().optional(),
162
165
  })
163
166
  .optional(),
167
+ /** Prompt-content-based per-turn model routing (PromptRouter). Opt-in — off unless
168
+ * `enabled: true`. Applies ONLY to the main interactive turn loop (never to
169
+ * subagents/`task`, which keep their own `subagents.*`/role-tier resolution
170
+ * untouched), and ONLY when the user has not manually pinned a session model
171
+ * via `/model` (explicit user choice always wins). */
172
+ routing: z
173
+ .object({
174
+ enabled: z.boolean().optional(),
175
+ confidenceThreshold: z.number().min(0).max(1).optional(),
176
+ tiers: z
177
+ .object({
178
+ trivial: z.object({ model: z.string().optional(), thinking: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional() }).optional(),
179
+ standard: z.object({ model: z.string().optional(), thinking: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional() }).optional(),
180
+ complex: z.object({ model: z.string().optional(), thinking: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional() }).optional(),
181
+ })
182
+ .optional(),
183
+ })
184
+ .optional(),
164
185
  gitAutoCommit: z.boolean().optional(),
165
186
  hooks: HookConfigSchema.optional(),
166
187
  computer: z
@@ -4,16 +4,16 @@
4
4
  # notify
5
5
 
6
6
  ## Purpose
7
- Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, deliberately scoped to jeo's subagent surface only — no forum topics, inline keyboards, or image attachments; see root `CHANGELOG.md` 0.7.34 for what jeo intentionally does not replicate from gjc's full notification stack).
7
+ Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, scoped to jeo's subagent surface). Supports gjc's richer notification surface: forum topics (`message_thread_id`), inline keyboards (per-subagent ⏹ Cancel buttons + `callback_query` handling), and image attachments (`sendPhoto`, relayed from a session `{type:"photo"}` frame).
8
8
 
9
9
  ## Key Files
10
10
  | File | Description |
11
11
  |------|-------------|
12
12
  | `paths.ts` | Path helpers under `<jeoHome>/notifications` (daemon lock/log, per-session discovery files) |
13
- | `telegram-api.ts` | Minimal, injectable-`fetch` Telegram Bot API client (`getMe`/`sendMessage`/`getUpdates`) + `maskToken` |
13
+ | `telegram-api.ts` | Minimal, injectable-`fetch` Telegram Bot API client (`getMe`/`sendMessage`/`sendPhoto`/`answerCallbackQuery`/`getUpdates`) + `maskToken`; `sendMessage`/`sendPhoto` accept `messageThreadId` (forum topics) and `replyMarkup` (inline keyboards) |
14
14
  | `session-endpoint.ts` | Per-turn, loopback-only WebSocket server exposing ONE `SubagentRegistry` (discovery file + `snapshot`/`steer`/`cancel`/`list` protocol); lazy `ensureSessionNotifyEndpoint`/`stopSessionNotifyEndpoint` wiring, no-op unless `notifications.enabled` |
15
15
  | `daemon-control.ts` | Daemon lifecycle: pid+startedAt lock singleton, `daemonInvocation` self-spawn argv (mirrors `memory.ts`'s `distillInvocation`), `startDaemon`/`stopDaemon`/`reloadDaemon`/`daemonStatus` |
16
- | `telegram-daemon.ts` | The managed daemon (`TelegramDaemon` class + `runNotifyDaemonForeground` CLI entrypoint): scans session discovery files, connects a WS per session, pushes Telegram messages on subagent status EDGES only, and dispatches inbound `/subagents`/`/steer`/`/cancel`/`/help` commands back over the matching session's WebSocket |
16
+ | `telegram-daemon.ts` | The managed daemon (`TelegramDaemon` class + `runNotifyDaemonForeground` CLI entrypoint): scans session discovery files, connects a WS per session, pushes Telegram messages on subagent status EDGES only (with an inline ⏹ Cancel button), and dispatches inbound `/subagents`/`/steer`/`/cancel`/`/help` commands AND inline-button `callback_query` taps back over the matching session's WebSocket. All pushes carry the configured forum `topicId`, and inbound is topic-filtered; a session `{type:"photo"}` frame is relayed via `sendPhoto` |
17
17
 
18
18
  ## Subdirectories
19
19
  *(None)*
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Minimal Telegram Bot HTTP API client (gjc `telegram-reference.ts`/`telegram-cli.ts`
3
- * parity, scoped to the three calls the jeo daemon actually needs: identity
4
- * verification, outbound push, and inbound long-poll). Deliberately thin — no forum
5
- * topics, no inline keyboards, no rate-limit pool; jeo's daemon sends plain text.
3
+ * parity). Scoped to the calls the jeo daemon needs — identity verification,
4
+ * outbound push, inbound long-poll — plus gjc's richer surface: forum topics
5
+ * (`message_thread_id`), inline keyboards (`reply_markup` + `answerCallbackQuery`),
6
+ * and image attachments (`sendPhoto`, photo given as a URL or `file_id`).
6
7
  */
7
8
 
8
9
  export interface TelegramChat {
@@ -12,6 +13,31 @@ export interface TelegramChat {
12
13
  username?: string;
13
14
  }
14
15
 
16
+ /** Inline keyboard primitives (gjc parity). `callback_data` is capped by Telegram
17
+ * at 64 bytes; the daemon keeps its payloads short (`cancel:<shortId>:<id>`). */
18
+ export interface InlineKeyboardButton {
19
+ text: string;
20
+ callback_data?: string;
21
+ url?: string;
22
+ }
23
+
24
+ export interface InlineKeyboardMarkup {
25
+ inline_keyboard: InlineKeyboardButton[][];
26
+ }
27
+
28
+ /** Button-press callback delivered via `getUpdates` when a user taps an inline
29
+ * keyboard button. `message` carries the originating chat/topic. */
30
+ export interface TelegramCallbackQuery {
31
+ id: string;
32
+ from: { id: number; username?: string; is_bot: boolean };
33
+ message?: {
34
+ message_id: number;
35
+ chat: TelegramChat;
36
+ message_thread_id?: number;
37
+ };
38
+ data?: string;
39
+ }
40
+
15
41
  export interface TelegramUpdate {
16
42
  update_id: number;
17
43
  message?: {
@@ -20,7 +46,11 @@ export interface TelegramUpdate {
20
46
  chat: TelegramChat;
21
47
  text?: string;
22
48
  from?: { id: number; username?: string; is_bot: boolean };
49
+ /** Forum-topic thread this message belongs to (supergroups with topics on). */
50
+ message_thread_id?: number;
51
+ is_topic_message?: boolean;
23
52
  };
53
+ callback_query?: TelegramCallbackQuery;
24
54
  }
25
55
 
26
56
  export interface TelegramGetMeResult {
@@ -40,6 +70,24 @@ export interface TelegramGetUpdatesResult {
40
70
  description?: string;
41
71
  }
42
72
 
73
+ /** Shared outbound options: target a forum topic and/or attach an inline keyboard. */
74
+ export interface SendMessageOptions {
75
+ messageThreadId?: number;
76
+ replyMarkup?: InlineKeyboardMarkup;
77
+ disableWebPagePreview?: boolean;
78
+ }
79
+
80
+ export interface SendPhotoOptions {
81
+ caption?: string;
82
+ messageThreadId?: number;
83
+ replyMarkup?: InlineKeyboardMarkup;
84
+ }
85
+
86
+ export interface AnswerCallbackQueryOptions {
87
+ text?: string;
88
+ showAlert?: boolean;
89
+ }
90
+
43
91
  export type FetchLike = typeof fetch;
44
92
 
45
93
  export class TelegramApi {
@@ -52,18 +100,59 @@ export class TelegramApi {
52
100
  return `https://api.telegram.org/bot${this.token}/${method}`;
53
101
  }
54
102
 
103
+ private async postJson<T>(method: string, body: Record<string, unknown>): Promise<T> {
104
+ const res = await this.fetchImpl(this.url(method), {
105
+ method: "POST",
106
+ headers: { "content-type": "application/json" },
107
+ body: JSON.stringify(body),
108
+ });
109
+ return (await res.json()) as T;
110
+ }
111
+
55
112
  async getMe(): Promise<TelegramGetMeResult> {
56
113
  const res = await this.fetchImpl(this.url("getMe"));
57
114
  return (await res.json()) as TelegramGetMeResult;
58
115
  }
59
116
 
60
- async sendMessage(chatId: string | number, text: string): Promise<TelegramSendResult> {
61
- const res = await this.fetchImpl(this.url("sendMessage"), {
62
- method: "POST",
63
- headers: { "content-type": "application/json" },
64
- body: JSON.stringify({ chat_id: chatId, text, disable_web_page_preview: true }),
65
- });
66
- return (await res.json()) as TelegramSendResult;
117
+ async sendMessage(
118
+ chatId: string | number,
119
+ text: string,
120
+ options: SendMessageOptions = {},
121
+ ): Promise<TelegramSendResult> {
122
+ const body: Record<string, unknown> = {
123
+ chat_id: chatId,
124
+ text,
125
+ disable_web_page_preview: options.disableWebPagePreview ?? true,
126
+ };
127
+ if (options.messageThreadId !== undefined) body.message_thread_id = options.messageThreadId;
128
+ if (options.replyMarkup) body.reply_markup = options.replyMarkup;
129
+ return this.postJson<TelegramSendResult>("sendMessage", body);
130
+ }
131
+
132
+ /** Send a photo. `photo` is a public URL or a Telegram `file_id` (no local
133
+ * multipart upload — jeo pushes URLs/ids, matching gjc's URL-attachment path). */
134
+ async sendPhoto(
135
+ chatId: string | number,
136
+ photo: string,
137
+ options: SendPhotoOptions = {},
138
+ ): Promise<TelegramSendResult> {
139
+ const body: Record<string, unknown> = { chat_id: chatId, photo };
140
+ if (options.caption !== undefined) body.caption = options.caption;
141
+ if (options.messageThreadId !== undefined) body.message_thread_id = options.messageThreadId;
142
+ if (options.replyMarkup) body.reply_markup = options.replyMarkup;
143
+ return this.postJson<TelegramSendResult>("sendPhoto", body);
144
+ }
145
+
146
+ /** Acknowledge an inline-button tap (clears the client's loading spinner and
147
+ * optionally shows a toast/alert). Telegram requires this within ~15s. */
148
+ async answerCallbackQuery(
149
+ callbackQueryId: string,
150
+ options: AnswerCallbackQueryOptions = {},
151
+ ): Promise<TelegramSendResult> {
152
+ const body: Record<string, unknown> = { callback_query_id: callbackQueryId };
153
+ if (options.text !== undefined) body.text = options.text;
154
+ if (options.showAlert !== undefined) body.show_alert = options.showAlert;
155
+ return this.postJson<TelegramSendResult>("answerCallbackQuery", body);
67
156
  }
68
157
 
69
158
  /** Long-poll for new updates. `offset` should be `last_update_id + 1`. */
@@ -1,9 +1,5 @@
1
1
  /**
2
- * The managed Telegram daemon (gjc `notifications/telegram-daemon.ts` parity,
3
- * scoped down to jeo's subagent-remote-control surface — no forum topics, no
4
- * inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo
5
- * intentionally does not replicate from gjc's full notification stack).
6
- *
2
+ * The managed Telegram daemon (gjc `notifications/telegram-daemon.ts` parity).
7
3
  * Runs as ONE long-lived background process (singleton, enforced by
8
4
  * `daemon-control.ts`'s lock file — Telegram allows only one `getUpdates`
9
5
  * long-poll owner per bot token). It:
@@ -12,19 +8,28 @@
12
8
  * discovery files and connects a loopback WebSocket to each
13
9
  * (`SessionNotifyEndpoint` on the other end);
14
10
  * - sends a Telegram message on each subagent state EDGE (started → a
15
- * terminal state), not on every poll tick;
11
+ * terminal state), not on every poll tick, attaching an inline "Cancel"
12
+ * keyboard button for the running subagent;
16
13
  * - long-polls Telegram `getUpdates` and dispatches `/subagents`,
17
14
  * `/steer <sessionId> <subagentId> <message>`, `/cancel <sessionId>
18
- * <subagentId>`, `/help` back into the matching session's WebSocket.
15
+ * <subagentId>`, `/help` — plus inline-keyboard button taps
16
+ * (`callback_query`) — back into the matching session's WebSocket.
17
+ *
18
+ * gjc-parity surface added on top of the plain-text core: forum topics (all
19
+ * pushes carry the configured `message_thread_id`, inbound is topic-filtered),
20
+ * inline keyboards (cancel buttons + `callback_query` handling), and image
21
+ * attachments (a session may push a `{type:"photo"}` frame relayed via
22
+ * `sendPhoto`).
19
23
  */
20
24
  import * as fs from "node:fs/promises";
21
25
  import * as path from "node:path";
22
26
  import { readGlobalConfig } from "../state";
23
27
  import { notifySessionsDir } from "./paths";
24
28
  import { acquireDaemonLock, isPidAlive } from "./daemon-control";
25
- import { TelegramApi, type TelegramUpdate } from "./telegram-api";
29
+ import { TelegramApi, type TelegramUpdate, type TelegramCallbackQuery, type InlineKeyboardMarkup, type InlineKeyboardButton } from "./telegram-api";
26
30
  import type { SubagentRecord } from "../subagent-registry";
27
31
 
32
+
28
33
  function sleep(ms: number): Promise<void> {
29
34
  const { promise, resolve } = Promise.withResolvers<void>();
30
35
  setTimeout(resolve, ms);
@@ -109,7 +114,47 @@ export const HELP_TEXT =
109
114
  "/subagents — list running/recent subagents across every connected session\n" +
110
115
  "/steer <sessionId> <subagentId> <message> — send a live message into a running subagent\n" +
111
116
  "/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
112
- "/help — show this message";
117
+ "/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
118
+ "/help — show this message\n" +
119
+ "(running subagents also carry an inline ⏹ Cancel button — tap it instead of typing /cancel)";
120
+
121
+ // ── Inline keyboards / callback data (gjc parity) ───────────────────────────────
122
+
123
+ /** Encode an inline-button cancel target. Kept short to stay under Telegram's
124
+ * 64-byte `callback_data` cap. */
125
+ export function cancelCallbackData(shortId: string, subagentId: string): string {
126
+ return `cancel:${shortId}:${subagentId}`;
127
+ }
128
+
129
+ /** Parse a `callback_data` payload produced by `cancelCallbackData`. */
130
+ export function parseCallbackData(
131
+ data: string,
132
+ ): { action: "cancel"; shortId: string; subagentId: string } | undefined {
133
+ const m = /^cancel:([^:]+):([\s\S]+)$/.exec(data);
134
+ if (!m) return undefined;
135
+ return { action: "cancel", shortId: m[1]!, subagentId: m[2]! };
136
+ }
137
+
138
+ /** One ⏹ Cancel button (its own row) targeting a single running subagent. */
139
+ function cancelButton(shortId: string, rec: SubagentRecord): InlineKeyboardButton {
140
+ return { text: `⏹ Cancel ${rec.role} '${rec.id}'`, callback_data: cancelCallbackData(shortId, rec.id) };
141
+ }
142
+
143
+ /** Inline keyboard with one ⏹ Cancel button per RUNNING subagent across every
144
+ * connected session; returns undefined when nothing is running (no keyboard to
145
+ * attach). */
146
+ export function buildSubagentsKeyboard(
147
+ sessions: Array<{ sessionId: string; records: SubagentRecord[] }>,
148
+ ): InlineKeyboardMarkup | undefined {
149
+ const rows: InlineKeyboardButton[][] = [];
150
+ for (const s of sessions) {
151
+ for (const r of s.records) {
152
+ if (r.status === "running") rows.push([cancelButton(shortSessionId(s.sessionId), r)]);
153
+ }
154
+ }
155
+ return rows.length ? { inline_keyboard: rows } : undefined;
156
+ }
157
+
113
158
 
114
159
  // ── Stateful daemon (network/ws — DI'd for tests) ───────────────────────────────
115
160
 
@@ -131,6 +176,9 @@ export interface TelegramDaemonOptions {
131
176
  isPidAlive?: (pid: number) => boolean;
132
177
  scanIntervalMs?: number;
133
178
  ackTimeoutMs?: number;
179
+ /** Forum-topic thread id (message_thread_id). When set, every push is sent
180
+ * into this topic and inbound messages/taps from other topics are ignored. */
181
+ topicId?: number;
134
182
  }
135
183
 
136
184
  export class TelegramDaemon {
@@ -145,14 +193,37 @@ export class TelegramDaemon {
145
193
  this.WS = opts.WebSocketImpl ?? WebSocket;
146
194
  }
147
195
 
148
- private async notify(text: string): Promise<void> {
196
+ private async notify(text: string, replyMarkup?: InlineKeyboardMarkup): Promise<void> {
149
197
  try {
150
- await this.opts.telegram.sendMessage(this.opts.chatId, text);
198
+ await this.opts.telegram.sendMessage(this.opts.chatId, text, {
199
+ messageThreadId: this.opts.topicId,
200
+ replyMarkup,
201
+ });
151
202
  } catch {
152
203
  // best-effort — a failed push must never crash the daemon loop.
153
204
  }
154
205
  }
155
206
 
207
+ private async notifyPhoto(photo: string, caption?: string, replyMarkup?: InlineKeyboardMarkup): Promise<void> {
208
+ try {
209
+ await this.opts.telegram.sendPhoto(this.opts.chatId, photo, {
210
+ messageThreadId: this.opts.topicId,
211
+ caption,
212
+ replyMarkup,
213
+ });
214
+ } catch {
215
+ // best-effort — a failed photo push must never crash the daemon loop.
216
+ }
217
+ }
218
+
219
+ private async answerCallback(id: string, text?: string): Promise<void> {
220
+ try {
221
+ await this.opts.telegram.answerCallbackQuery(id, text !== undefined ? { text } : {});
222
+ } catch {
223
+ // best-effort — acknowledging the tap is nice-to-have, not load-bearing.
224
+ }
225
+ }
226
+
156
227
  async scanSessions(): Promise<void> {
157
228
  const readdir = this.opts.readdir ?? (d => fs.readdir(d));
158
229
  let files: string[];
@@ -205,11 +276,28 @@ export class TelegramDaemon {
205
276
  const next = msg.subagents as SubagentRecord[];
206
277
  const events = diffSubagentTransitions(conn.lastRecords, next);
207
278
  conn.lastRecords = next;
279
+ const shortId = shortSessionId(conn.sessionId);
208
280
  for (const ev of events) {
209
- await this.notify(formatNotifyEvent(shortSessionId(conn.sessionId), conn.cwd, ev));
281
+ const keyboard =
282
+ ev.kind === "started" ? { inline_keyboard: [[cancelButton(shortId, ev.record)]] } : undefined;
283
+ await this.notify(formatNotifyEvent(shortId, conn.cwd, ev), keyboard);
210
284
  }
211
285
  return;
212
286
  }
287
+ if (msg.type === "photo" && typeof msg.url === "string") {
288
+ const replyMarkup =
289
+ msg.replyMarkup &&
290
+ typeof msg.replyMarkup === "object" &&
291
+ "inline_keyboard" in (msg.replyMarkup as object)
292
+ ? (msg.replyMarkup as InlineKeyboardMarkup)
293
+ : undefined;
294
+ await this.notifyPhoto(
295
+ msg.url,
296
+ typeof msg.caption === "string" ? msg.caption : undefined,
297
+ replyMarkup,
298
+ );
299
+ return;
300
+ }
213
301
  if (msg.type === "ack" && typeof msg.reqId === "string") {
214
302
  const resolve = this.pendingAcks.get(msg.reqId);
215
303
  if (resolve) {
@@ -250,7 +338,7 @@ export class TelegramDaemon {
250
338
  const trimmed = text.trim();
251
339
  if (trimmed === "/subagents") {
252
340
  const sessions = [...this.sessions.values()].map(c => ({ sessionId: c.sessionId, cwd: c.cwd, records: c.lastRecords }));
253
- await this.notify(formatSubagentsList(sessions));
341
+ await this.notify(formatSubagentsList(sessions), buildSubagentsKeyboard(sessions));
254
342
  return;
255
343
  }
256
344
  if (trimmed === "/help" || trimmed === "/start") {
@@ -285,14 +373,61 @@ export class TelegramDaemon {
285
373
  /** Trust boundary: a bot's username is publicly discoverable, so ANY Telegram
286
374
  * user can message it. Only the paired chat (the one that ran
287
375
  * `jeo notify setup`) may steer/cancel subagents; everything else is dropped
288
- * without a reply (replying would leak that the bot is live). */
376
+ * without a reply (replying would leak that the bot is live). When a forum
377
+ * topic is configured, messages/taps from other topics are also ignored. */
289
378
  async handleUpdate(update: TelegramUpdate): Promise<void> {
379
+ if (update.callback_query) {
380
+ await this.handleCallbackQuery(update.callback_query);
381
+ return;
382
+ }
290
383
  const msg = update.message;
291
384
  if (!msg?.text) return;
292
385
  if (String(msg.chat.id) !== this.opts.chatId) return;
386
+ if (
387
+ this.opts.topicId !== undefined &&
388
+ msg.message_thread_id !== undefined &&
389
+ msg.message_thread_id !== this.opts.topicId
390
+ ) {
391
+ return;
392
+ }
293
393
  await this.handleInboundText(msg.text);
294
394
  }
295
395
 
396
+ /** Handle an inline-button tap. Same chat/topic trust boundary as text, and the
397
+ * tap is always acknowledged (Telegram shows a spinner until `answerCallbackQuery`). */
398
+ async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<void> {
399
+ const chatId = cb.message?.chat.id;
400
+ if (chatId !== undefined && String(chatId) !== this.opts.chatId) {
401
+ await this.answerCallback(cb.id);
402
+ return;
403
+ }
404
+ if (
405
+ this.opts.topicId !== undefined &&
406
+ cb.message?.message_thread_id !== undefined &&
407
+ cb.message.message_thread_id !== this.opts.topicId
408
+ ) {
409
+ await this.answerCallback(cb.id);
410
+ return;
411
+ }
412
+ const parsed = cb.data ? parseCallbackData(cb.data) : undefined;
413
+ if (!parsed) {
414
+ await this.answerCallback(cb.id);
415
+ return;
416
+ }
417
+ const conn = this.findSession(parsed.shortId);
418
+ if (!conn) {
419
+ await this.answerCallback(cb.id, `No connected session matches '${parsed.shortId}'.`);
420
+ return;
421
+ }
422
+ const ok = await this.sendRequest(conn, { type: "cancel", ids: [parsed.subagentId] });
423
+ await this.answerCallback(cb.id, ok ? `Cancelled '${parsed.subagentId}'.` : "Cancel failed.");
424
+ await this.notify(
425
+ ok
426
+ ? `⏹ Cancelled '${parsed.subagentId}' via button.`
427
+ : `Cancel failed — '${parsed.subagentId}' is unknown or already finished.`,
428
+ );
429
+ }
430
+
296
431
  private async pollTelegramLoop(): Promise<void> {
297
432
  while (!this.stopped) {
298
433
  let res;
@@ -346,11 +481,12 @@ export async function runNotifyDaemonForeground(): Promise<void> {
346
481
  const config = await readGlobalConfig();
347
482
  const botToken = config.notifications?.telegram?.botToken;
348
483
  const chatId = config.notifications?.telegram?.chatId;
484
+ const topicId = config.notifications?.telegram?.topicId;
349
485
  if (!config.notifications?.enabled || !botToken || !chatId) {
350
486
  process.stderr.write("[jeo notify-daemon] notifications not configured — run `jeo notify setup` first.\n");
351
487
  return;
352
488
  }
353
- const daemon = new TelegramDaemon({ chatId, telegram: new TelegramApi(botToken) });
489
+ const daemon = new TelegramDaemon({ chatId, topicId, telegram: new TelegramApi(botToken) });
354
490
  let shuttingDown = false;
355
491
  const shutdown = () => {
356
492
  if (shuttingDown) return;
@@ -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
+ }
@@ -64,6 +64,8 @@ export interface Config {
64
64
  telegram?: {
65
65
  botToken?: string;
66
66
  chatId?: string;
67
+ /** Forum-topic thread id (message_thread_id) for daemon pushes. */
68
+ topicId?: number;
67
69
  };
68
70
  };
69
71
 
@@ -122,6 +124,17 @@ export interface Config {
122
124
  * `defaultModel`. Env `JEO_SMOL_MODEL`/`JEO_SLOW_MODEL`/`JEO_PLAN_MODEL` fill gaps.
123
125
  */
124
126
  roles?: { smol?: string; slow?: string; plan?: string };
127
+ /** Prompt-content-based per-turn model routing (PromptRouter) — see prompt-router.ts.
128
+ * Opt-in (undefined/false = off). Never affects subagent/role-tier model resolution. */
129
+ routing?: {
130
+ enabled?: boolean;
131
+ confidenceThreshold?: number;
132
+ tiers?: {
133
+ trivial?: { model?: string; thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" };
134
+ standard?: { model?: string; thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" };
135
+ complex?: { model?: string; thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" };
136
+ };
137
+ };
125
138
  gitAutoCommit?: boolean;
126
139
  hooks?: HookConfig;
127
140
  computer?: {
@@ -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
+ }
@@ -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 activeModel = sessionModel || defaultModel;
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: sessionThinking }) : null;
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, sessionThinking),
1138
- reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
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, sessionThinking),
1158
- reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
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;
@@ -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" },