jeo-code 0.7.48 → 0.7.50

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,43 @@ 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.50] - 2026-07-06
10
+ _Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change._
11
+
12
+ ### Added
13
+ - **`jeo doctor` routing diagnostic** (`src/commands/doctor.ts`) — when `routing.enabled` is true but `roles.smol` is unset, `routePrompt`'s LLM-escalation path silently never fires every turn (it degrades to heuristic-only tier resolution, the same paradox `routePrompt` itself already guards against at runtime via its one-time `warnOnce`). `jeo doctor` now surfaces this proactively at onboarding time instead of only mid-session: a `routing: { enabled, smolConfigured, note? }` block in `--json` output, and a yellow `[routing]` note (or a terse green confirmation when `roles.smol` IS configured) printed after the OAuth section, before the final verdict. Purely informational — never affects the `ready`/`strict` exit-code gate.
14
+
15
+ ### Fixed
16
+ - **Prompt-cache-key collision on mid-session model switch** (`src/agent/prompt-router.ts`'s new `deriveCacheSessionKey`, wired into `src/commands/launch.ts`'s `runTurn`) — `sessionKey` (forwarded as `prompt_cache_key`/`session_id` to provider requests) was the bare `sessionId`, unchanged across turns. Provider-side prompt caches are keyed PER MODEL, so when `routePrompt` switches `activeModel` between turns in the same session (its entire reason for existing), every switch silently guaranteed a cache miss on the new model — undermining the exact cost/latency savings routing exists to deliver. The derived key is now `` `${sessionId}:${activeModel}` ``: stable (same key) across consecutive turns on the same model, distinct (different key) the instant the model changes, with zero effect on `--no-session` (still forwards `undefined` unchanged).
17
+
18
+ ### Verified
19
+ - `bun run typecheck` clean; `bun test` 2644 pass / 0 fail across 280 files — 2 consecutive full-suite runs, both clean.
20
+ - `test/doctor.test.ts` (+5 tests, 7 total): routing off → no `routing` block; routing on + `roles.smol` unset → note present with escalation-skip wording (both `--json` and human-output paths); routing on + `roles.smol` set → no note, `smolConfigured: true`; human-output note never flips the `ready`/`strict` verdict.
21
+ - `test/prompt-router.test.ts` (+4 tests, 36 total): `deriveCacheSessionKey` pure-function unit tests — same session+model → same key, different model → different key, key format, `--no-session` (undefined sessionId) short-circuit.
22
+ - `test/launch-prompt-routing.test.ts` (+2 tests, 6 total): real end-to-end proof through `runLaunchCommand` with a genuine persisted session (not `--no-session`) — two turns routed to the SAME model produce the SAME `sessionKey` (cache reuse preserved), two turns routed to DIFFERENT models produce DIFFERENT `sessionKey`s sharing the same session-id prefix (no false cross-model cache hit, still same session lineage).
23
+ - Independent verification pass (separate subagent, read-only, no code touched) re-derived every §0–§8 claim in the design doc directly from git history (`ff7816a^` pre-PromptRouter vs. current HEAD) rather than trusting the doc's own text — confirmed the core architecture claims, design-principle-5 tool-isolation guarantee (exhaustive grep: `RouteDecision` has no tool/permission field, zero cross-references between `routing.` and any subagent-toolset code), and the bilingual heuristic test corpus; also independently ran `graphify` itself (not grep proxies) to reproduce 3 of 5 named hub-degree figures from the doc almost exactly, while flagging that 2 of the doc's supporting dependency-direction claims (`state.ts`, `model-catalog*.ts` importing `model-manager.ts`) do not hold under direct import inspection.
24
+
25
+ ## [0.7.49] - 2026-07-06
26
+ _gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression._
27
+
28
+ ### Added
29
+ - **Per-session forum topics** (`src/agent/notify/topic-registry.ts`, new) — a pure, injectable-create `TopicRegistry` gives each interactive session its own Telegram forum topic (numeric `topicId`, rename-dedup via `applyName`), persisted across daemon restarts (`notifyTopicsPath()`, gjc `telegram-topics.json` parity) and reused indefinitely across `--resume` (jeo topics are never deleted, unlike gjc's). Two independent fail-closed gates protect it: the paired chat must be a confirmed private DM (`TelegramApi.getChat`, checked once per daemon lifetime and cached, fails closed on error) before a topic is ever attempted, and a per-connection `topicFailed` flag remembers a `createForumTopic` failure (e.g. Threaded Mode off in @BotFather) so it degrades to the flat/global `topicId` instead of retrying every single frame.
30
+ - **Main-session mirroring** (`session-endpoint.ts` refactored from a per-TURN to a per-SESSION lifecycle) — `startSessionNotifyEndpoint` now starts ONE endpoint for the whole interactive REPL, with `attachRegistry`/`detachRegistry` swapping the live per-turn `SubagentRegistry` in/out at turn boundaries instead of one endpoint per turn. New push frames: `sendIdentity` (one-time `identity_header` naming the session's topic), `sendContextUpdate` (`turn_start`/`turn_end`, honors a new session-local `notifyRedact` toggle), `sendTurnStream` (the finished reply, rendered through the new `telegram-html.ts` converter).
31
+ - **Inbound steering** (`onUserMessage` wiring in `launch.ts`) — a free-text Telegram reply in a session's own topic steers that RUNNING turn exactly like a local mid-turn Enter when one is active, or injects as the next prompt when idle — but only once the local readline buffer is confirmed empty, so it never hijacks a keystroke the user is actively typing locally. Attached photos/documents are downloaded daemon-side and resolved into real `ImageAttachment`s through the same `attachImagePaths` path local drag-drop already uses.
32
+ - **In-thread config commands** (`src/agent/notify/config-commands.ts`, new) — `/verbose`, `/lean`, `/verbosity lean|verbose`, `/redact on|off` typed into a session's own topic flip that session's local `notifyVerbosity`/`notifyRedact` (never persisted to `~/.jeo/config.json`, mirroring gjc's session-local semantics) via a new `onConfigCommand` callback; anything else typed in that topic (including a caption-less media message) falls through to free-text steering instead.
33
+ - **Shared `RateLimitPool`** (`src/agent/notify/rate-limit-pool.ts`, new) — a host-wide token-bucket scheduler (burst + steady refill) with 4 priority lanes (`ask`>`finalized`>`live`>`idle`), per-session round-robin fairness so one session's stream can't starve another, and coalescing of same-`coalesceKey` items. Every daemon push now routes through it (`flushPool`); current callers only submit to `finalized` in practice (subagent pushes + main-session mirroring) — the other 3 lanes exist for architectural completeness (jeo has no `ask` tool or incremental token-stream to mirror yet).
34
+ - **Telegram HTML formatting** (`src/agent/notify/telegram-html.ts`, new) — `markdownToTelegramHtml` (a bounded markdown subset rendered into Telegram's allowed HTML tag set, escape-first-tag-second) plus `truncateTelegramHtml`/`splitTelegramHtml`, which never break a tag or entity mid-string when a rendered reply exceeds the 4096-char message cap.
35
+ - **Telegram Bot API additions** (`telegram-api.ts`): `createForumTopic`/`editForumTopic`, `getFile`/`downloadFile` (path-traversal-guarded — rejects any `..`, leading `/`, or `\` segment in the untrusted remote `file_path` before building the download URL), `getChat`, `setMessageReaction` (a 👀 reaction acknowledges an inbound message the daemon successfully routed).
36
+ - **`notifications.verbosity`/`notifications.redact`/`notifications.telegram.perSessionTopics`** config fields (`config-schema.ts`/`state.ts`) — all optional; unset behaves exactly as before (lean, not redacted, flat/global topic only).
37
+
38
+ ### Fixed
39
+ - A genuine **pre-existing test-isolation bug**, surfaced only under full-280-file-suite load and never in isolation: an orphaned `mock.module("../src/agent/loop", ...)` leaked from an unrelated, never-restored test elsewhere in the suite intermittently shadowed the real LLM call for whichever test happened to run nearby. `test/launch-telegram-remote.test.ts`'s turn-completion assertion no longer depends on a specific credential-error string arriving within a fixed window (fragile under that pollution); it now races the unconditional `turn_end` context-update frame against a credential error and accepts either terminal outcome — the loop recovers and reaches its next prompt either way, which is the actual contract being tested.
40
+
41
+ ### Verified
42
+ - `bun run typecheck` clean; `bun test` 2633 pass / 0 fail across 280 files — 4 consecutive full-suite runs, all clean (chasing down the flake above required reproducing it under real full-suite load first).
43
+ - New: `test/notify-topic-registry.test.ts` (18 tests), `test/notify-rate-limit-pool.test.ts` (9), `test/notify-config-commands.test.ts` (21), `test/notify-telegram-html.test.ts` (42), `test/launch-telegram-remote.test.ts` (6, real spawned-process end-to-end through the actual CLI: session registration, `context_update` on turn start/end, remote free-text injection while idle, remote injection while mid-turn steers the running turn, `config_command` mutates session-local verbosity/redact without touching global config).
44
+ - Extended: `test/notify-session-endpoint.test.ts` (20 tests, session-scoped construction + attach/detach lifecycle), `test/notify-telegram-api.test.ts` (26 — `createForumTopic`/`editForumTopic`/`getFile`/`downloadFile` incl. path-traversal rejection, `getChat`/`setMessageReaction` request shape), `test/notify-telegram-daemon.test.ts` (54 — topic resolution/creation/rename, rate-limit-pool flush wiring, inbound photo/document download+relay, in-thread config-command dispatch vs. free-text fallthrough, 👀 reaction on accept).
45
+
9
46
  ## [0.7.48] - 2026-07-06
10
47
  _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
48
 
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.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
263
+ - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
262
264
  - **[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
265
  - **[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
266
  - **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
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.
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.
267
267
 
268
268
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
269
269
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -260,11 +260,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
260
260
  ## 변경 이력 (Changelog)
261
261
 
262
262
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
263
+ - **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
264
+ - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
263
265
  - **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
264
266
  - **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
265
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.
266
- - **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
267
- - **[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.
268
268
 
269
269
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
270
270
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -262,11 +262,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
262
262
  ## Changelog
263
263
 
264
264
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
265
+ - **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
266
+ - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
265
267
  - **[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
268
  - **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
267
269
  - **[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.
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.
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.
270
270
 
271
271
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
272
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -259,11 +259,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
259
259
  ## 更新日志 (Changelog)
260
260
 
261
261
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
262
+ - **[0.7.50]** (2026-07-06) — Design-doc audit of v0.7.47's PromptRouter, dispatched as two parallel subagents (independent re-verification + gap implementation) against the original Korean-language design report. The core proposal was confirmed shipped correctly (heuristic classifier, escalation, `/model`-always-wins, tool-selection isolation — all independently re-derived from git history and grep evidence, not just re-read from the doc). Two gaps the design doc itself had flagged as open questions (§7 risks #2 and #4) were real and are fixed here; a third finding (an internal contradiction in the design doc's own §3.2 vs §3.4 text on image-attachment gating — the SHIPPED code was already correct, only the doc's prose disagreed with itself) required no code change.
263
+ - **[0.7.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
262
264
  - **[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
265
  - **[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
266
  - **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
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.
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.
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.48",
3
+ "version": "0.7.50",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -92,13 +92,27 @@ export const ConfigSchema = z
92
92
  notifications: z
93
93
  .object({
94
94
  enabled: z.boolean().optional(),
95
+ /** Session-local default notification verbosity; a running session's
96
+ * in-memory value (mutable via a `/verbosity` config_command from
97
+ * Telegram) starts from this and reverts here on restart. */
98
+ verbosity: z.enum(["lean", "verbose"]).optional(),
99
+ /** Session-local default redaction of mirrored turn/context text sent
100
+ * to Telegram; same session-local-only mutability as `verbosity`. */
101
+ redact: z.boolean().optional(),
95
102
  telegram: z
96
103
  .object({
97
104
  botToken: z.string().optional(),
98
105
  chatId: z.string().optional(),
99
106
  /** Forum-topic thread id (message_thread_id) for supergroups with
100
- * topics enabled — daemon pushes go into this topic. */
107
+ * topics enabled — daemon pushes go into this topic. Ignored for a
108
+ * session-owned topic when `perSessionTopics` is true. */
101
109
  topicId: z.number().optional(),
110
+ /** Auto-create and manage ONE forum topic per interactive session
111
+ * (gjc per-session-thread parity), instead of the single flat/global
112
+ * `topicId` above. Requires the paired chat to be a supergroup with
113
+ * Topics enabled AND private (fail-closed — see `TopicRegistry`).
114
+ * Off by default; existing flat-topic/no-topic setups are unaffected. */
115
+ perSessionTopics: z.boolean().optional(),
102
116
  })
103
117
  .optional(),
104
118
  })
@@ -1,19 +1,33 @@
1
1
  <!-- Parent: ../AGENTS.md -->
2
- <!-- Generated: 2026-07-05 -->
2
+ <!-- Generated: 2026-07-06 -->
3
3
 
4
4
  # notify
5
5
 
6
6
  ## Purpose
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).
7
+ Remote subagent visibility/control AND main-session mirroring over Telegram (gjc parity).
8
+ Tier 1 (shipped v0.7.43–v0.7.48): inline images, a static forum topic, inline keyboards
9
+ (per-subagent ⏹ Cancel buttons + `callback_query` handling), image attachments (`sendPhoto`,
10
+ relayed from a session `{type:"photo"}` frame). Tier 2 (this pass): a dynamic, PER-SESSION
11
+ forum topic (opt-in via `notifications.telegram.perSessionTopics`) that mirrors that session's
12
+ OWN activity (`identity_header`/`context_update`/`turn_stream` frames), accepts free-text
13
+ replies that steer that session directly (`user_message`, routed through `handleRemoteUserMessage`
14
+ in `../../commands/launch.ts`), in-thread `/verbose`/`/lean`/`/redact` config commands scoped to
15
+ that one session, and a shared `RateLimitPool` (burst/steady-rate protection across every
16
+ session sharing one bot token). The flat/global `topicId` path is UNCHANGED when
17
+ `perSessionTopics` is off — zero regression risk for existing setups.
8
18
 
9
19
  ## Key Files
10
20
  | File | Description |
11
21
  |------|-------------|
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`/`sendPhoto`/`answerCallbackQuery`/`getUpdates`) + `maskToken`; `sendMessage`/`sendPhoto` accept `messageThreadId` (forum topics) and `replyMarkup` (inline keyboards) |
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` |
22
+ | `paths.ts` | Path helpers under `<jeoHome>/notifications` (daemon lock/log, per-session discovery files, `notifyTopicsPath()` for the per-session topic map) |
23
+ | `telegram-api.ts` | Minimal, injectable-`fetch` Telegram Bot API client: `getMe`/`sendMessage`(+`parseMode`)/`sendPhoto`/`answerCallbackQuery`/`getUpdates` (Tier 1) plus `createForumTopic`/`editForumTopic`/`getFile`/`downloadFile`(path-traversal-guarded)/`getChat`/`setMessageReaction` (Tier 2) + `maskToken` |
24
+ | `telegram-html.ts` | Bounded markdown→Telegram-HTML converter (`markdownToTelegramHtml`) + safe truncate/split (`truncateTelegramHtml`/`splitTelegramHtml`, never break a tag/entity) — renders `turn_stream` frames |
25
+ | `topic-registry.ts` | Pure, injectable-create `TopicRegistry`: one forum-topic record per session (numeric `topicId`, rename-dedup via `applyName`), concurrency-guarded `getOrCreateTopic`, serializable state |
26
+ | `rate-limit-pool.ts` | Pure, injectable-clock `RateLimitPool`: token-bucket + 4 priority lanes (`ask`>`finalized`>`live`>`idle`, only `finalized` has a jeo caller today) + per-session round-robin fairness + coalescing |
27
+ | `config-commands.ts` | Pure `parseInThreadConfigCommand`: `/verbose`, `/lean`, `/verbosity lean\|verbose`, `/redact on\|off` — session-local, never persisted |
28
+ | `session-endpoint.ts` | SESSION-scoped (not per-turn) loopback WebSocket server: one per interactive REPL for its whole lifetime, `attachRegistry`/`detachRegistry` swap the live per-turn `SubagentRegistry` in/out at turn boundaries, `sendIdentity`/`sendContextUpdate`/`sendTurnStream` push main-session frames, `onUserMessage`/`onConfigCommand` receive callbacks. `startSessionNotifyEndpoint` (eager, session-scoped) is the primary entrypoint; `ensureSessionNotifyEndpoint`/`stopSessionNotifyEndpoint` (lazy, per-registry) remain as a FALLBACK for a detached subagent launched outside any interactive session |
15
29
  | `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 (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` |
30
+ | `telegram-daemon.ts` | The managed daemon (`TelegramDaemon` class + `runNotifyDaemonForeground` CLI entrypoint): scans session discovery files, connects a WS per session, routes ALL outbound sends through the shared `RateLimitPool`, resolves each push's topic via `TopicRegistry` when `perSessionTopics` is on (else the flat `topicId`, Tier 1 behavior), renames a topic once `identity_header` arrives, downloads inbound photo/image-document attachments for relay, reacts 👀 to an accepted inbound message, and dispatches global `/subagents`/`/steer`/`/cancel`/`/help` PLUS per-session-topic inbound (config command or free-text `user_message`) |
17
31
 
18
32
  ## Subdirectories
19
33
  *(None)*
@@ -24,24 +38,30 @@ Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, sc
24
38
  - The session-side WebSocket server MUST stay loopback-only (`127.0.0.1`); the daemon-to-Telegram hop is the only internet-facing part of this feature.
25
39
  - Every entrypoint here must stay a no-op (or refuse cleanly with a clear message) unless `notifications.enabled` + a stored bot token + chat id are all present — never assume the feature is configured.
26
40
  - `daemon-control.ts`'s singleton lock exists because Telegram allows only ONE `getUpdates` long-poll owner per bot token; never bypass it.
41
+ - Telegram has NO live message editing anywhere in this feature — every delivered frame is a fresh `sendMessage`/`sendPhoto` call. `RateLimitPool` coalescing only ever drops a STALE, UNSENT queued item; it never edits an already-sent message.
42
+ - jeo's forum topics are NEVER deleted (unlike gjc) — `sessionId` persists across `--resume`, so a topic, once created, is meant to be reused indefinitely across relaunches of the same session.
43
+ - `session-endpoint.ts`'s constructor is `(cwd, sessionId?)`, NOT `(registry, cwd)` — the registry attaches/detaches after construction via `attachRegistry`/`detachRegistry`, since one endpoint now outlives many per-turn registries.
27
44
 
28
45
  ### Testing Requirements
29
- - Prefer real `Bun.serve`/`WebSocket` for `session-endpoint.ts` tests (fast, no mocking needed) and injected fakes (`WebSocketImpl`, `TelegramApi`, `readdir`/`readFile`/`unlink`/`isPidAlive`) for `telegram-daemon.ts`/`daemon-control.ts`.
46
+ - Prefer real `Bun.serve`/`WebSocket` for `session-endpoint.ts` tests (fast, no mocking needed) and injected fakes (`WebSocketImpl`, `TelegramApi`, `readdir`/`readFile`/`unlink`/`isPidAlive`, `loadTopicState`/`saveTopicState`, `writeTempFile`, `now`) for `telegram-daemon.ts`/`daemon-control.ts`.
30
47
  - Any test touching `readGlobalConfig`/`saveConfigPatch` must isolate `JEO_CONFIG_DIR` to a temp directory (see `test/notify-daemon-control.test.ts`).
48
+ - `topic-registry.ts`/`rate-limit-pool.ts`/`config-commands.ts`/`telegram-html.ts` are pure — no network/fs/real-timer dependency, test them directly with plain inputs/outputs (inject `now`/`create` callbacks where the API takes them).
31
49
 
32
50
  ### Common Patterns
33
51
  - Dependency injection for network/fs/time so daemon logic is unit-testable without a real bot token or real child processes.
34
52
  - Edge-triggered (not level-triggered) status reporting: a message is sent on a TRANSITION, never on every poll tick.
53
+ - Fail-closed, not fail-open, on a per-session topic-creation failure (e.g. Threaded Mode off): the session's frames fall back to the flat/global `topicId` (or drop, if that's also unset) rather than retrying every frame or blocking delivery.
35
54
 
36
55
  ## Dependencies
37
56
 
38
57
  ### Internal
39
58
  - `../subagent-registry.ts` (`SubagentRegistry`, `SubagentRecord`).
40
- - `../state.ts` (`readGlobalConfig`/`saveConfigPatch` for `notifications` settings).
41
- - Consumed by `../task-tool.ts` (`ensureSessionNotifyEndpoint`), `../../commands/launch.ts` (`stopSessionNotifyEndpoint`), `../../commands/notify.ts`, `../../commands/daemon.ts`, and the hidden `notify-daemon-run` CLI entrypoint (`src/cli/runner.ts`).
59
+ - `../state.ts` (`readGlobalConfig`/`saveConfigPatch` for `notifications` settings, incl. `verbosity`/`redact`/`telegram.perSessionTopics`).
60
+ - `../../util/file-attachment.ts` (`attachImagePaths`) — the ONE image-ingestion path a `user_message` frame's `imagePaths` (daemon-downloaded temp files) flows through on the session side, same as local drag-drop.
61
+ - Consumed by `../task-tool.ts` (`ensureSessionNotifyEndpoint`, now also threading an optional `sessionEndpoint` to attach to), `../../commands/launch.ts` (`startSessionNotifyEndpoint` at session start, `stopSessionNotifyEndpoint` fallback, `attachRegistry`/`detachRegistry` per turn, `sendIdentity`/`sendContextUpdate`/`sendTurnStream`, `onUserMessage`/`onConfigCommand` handlers), `../../commands/notify.ts`, `../../commands/daemon.ts`, and the hidden `notify-daemon-run` CLI entrypoint (`src/cli/runner.ts`).
42
62
 
43
63
  ### External
44
- - Telegram Bot HTTP API (`https://api.telegram.org`) — the only outbound network call in this feature.
64
+ - Telegram Bot HTTP API (`https://api.telegram.org` for calls, `https://api.telegram.org/file/...` for file downloads — a DIFFERENT base path) — the only outbound network call in this feature.
45
65
  - Bun's native `Bun.serve` (WebSocket server) and global `WebSocket` (client) — zero added dependencies.
46
66
 
47
67
  <!-- MANUAL: Any manually added notes below this line are preserved on regeneration -->
@@ -0,0 +1,50 @@
1
+ /**
2
+ * In-thread configuration slash commands for the per-session Telegram surface
3
+ * (gjc `notifications/config-commands.ts` parity). A user can adjust how their
4
+ * OWN session's activity mirrors into its topic without touching global config:
5
+ *
6
+ * - `/verbose` switch the mirror to verbose (full tool output + reasoning)
7
+ * - `/lean` switch back to lean (assistant text + tool names only)
8
+ * - `/verbosity lean|verbose`
9
+ * - `/redact on|off` toggle redaction of mirrored turn/context text
10
+ *
11
+ * Pure and unit-testable; the daemon maps a returned change onto a
12
+ * `config_command` frame sent to the owning session's WebSocket. Only reachable
13
+ * when `notifications.telegram.perSessionTopics` is enabled — the flat/global
14
+ * `topicId` surface has no per-session settings to change.
15
+ */
16
+
17
+ /** A parsed in-thread configuration change. */
18
+ export interface ConfigCommandChange {
19
+ verbosity?: "lean" | "verbose";
20
+ redact?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Parse an in-thread config command. Returns the requested change, or
25
+ * `undefined` when the text is not a recognised config command (the daemon
26
+ * then falls through to treating it as a free-text `user_message`).
27
+ */
28
+ export function parseInThreadConfigCommand(text: string): ConfigCommandChange | undefined {
29
+ const trimmed = text.trim();
30
+ if (!trimmed.startsWith("/")) return undefined;
31
+ const [rawCommand, ...rest] = trimmed.slice(1).split(/\s+/);
32
+ const command = rawCommand?.toLowerCase();
33
+ const arg = rest[0]?.toLowerCase();
34
+
35
+ switch (command) {
36
+ case "verbose":
37
+ return { verbosity: "verbose" };
38
+ case "lean":
39
+ return { verbosity: "lean" };
40
+ case "verbosity":
41
+ if (arg === "lean" || arg === "verbose") return { verbosity: arg };
42
+ return undefined;
43
+ case "redact":
44
+ if (arg === "on" || arg === "true" || arg === "1") return { redact: true };
45
+ if (arg === "off" || arg === "false" || arg === "0") return { redact: false };
46
+ return undefined;
47
+ default:
48
+ return undefined;
49
+ }
50
+ }
@@ -30,6 +30,13 @@ export function notifySessionEndpointPath(sessionId: string): string {
30
30
  return path.join(notifySessionsDir(), `${sessionId}.json`);
31
31
  }
32
32
 
33
+ /** Persisted per-session forum-topic map (`sessionId -> {topicId, name, createdAt}`),
34
+ * survives daemon restarts (gjc `telegram-topics.json` parity). Only written/read
35
+ * when `notifications.telegram.perSessionTopics` is enabled. */
36
+ export function notifyTopicsPath(): string {
37
+ return path.join(notifyDir(), "telegram-topics.json");
38
+ }
39
+
33
40
  /** Singleton lock for the daemon process (Telegram allows only one `getUpdates`
34
41
  * long-poll owner per bot token — gjc's rationale for a daemon lock/state file). */
35
42
  export function notifyDaemonLockPath(): string {
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Host-wide shared Telegram rate-limit pool for the threaded session surface
3
+ * (gjc `RateLimitPool` parity).
4
+ *
5
+ * Multiple jeo sessions on one host share a single bot token and paired chat.
6
+ * Telegram enforces per-bot/per-chat limits (~1 message/sec, bursts up to ~20),
7
+ * so the singleton notifications daemon owns ONE pool that all per-session
8
+ * threads draw from. The pool provides:
9
+ *
10
+ * - a token bucket (burst capacity + steady refill) modelling the chat limit;
11
+ * - priority lanes (`ask` > `finalized` > `live` > `idle`) so urgent frames
12
+ * win scarce tokens;
13
+ * - per-session round-robin fairness within a lane so one session's live-edit
14
+ * stream cannot starve other sessions;
15
+ * - coalescing of live edits that share a `coalesceKey` (the latest rendered
16
+ * text replaces the queued one) so throttled edit storms collapse.
17
+ *
18
+ * The core is a pull-based scheduler with an injectable clock so fairness,
19
+ * starvation, and burst behaviour are deterministically unit-testable without
20
+ * real time or a live Bot API.
21
+ *
22
+ * Current jeo callers only actually submit to the `"finalized"` lane in
23
+ * practice (subagent status pushes + main-session `turn_stream`/
24
+ * `context_update` mirroring). The `"ask"` and `"live"` lanes exist in the
25
+ * type for architectural completeness/future use — jeo has no `ask` tool
26
+ * (Tier 3, out of scope) and no incremental token-level stream to mirror yet
27
+ * (see the Tier 2 contract) — and `"idle"` is likewise unused today. All four
28
+ * lanes are kept exactly as gjc has them; this is a note about CURRENT
29
+ * callers, not a scope reduction of the module itself.
30
+ */
31
+
32
+ /** Delivery lanes in descending priority. */
33
+ export type RateLimitLane = "ask" | "finalized" | "live" | "idle";
34
+
35
+ /** Lanes ordered from highest to lowest priority. */
36
+ export const LANE_PRIORITY: readonly RateLimitLane[] = ["ask", "finalized", "live", "idle"];
37
+
38
+ /** A unit of work competing for a send slot. */
39
+ export interface RateLimitItem<T = unknown> {
40
+ /** Owning session id (used for per-session fairness). */
41
+ sessionId: string;
42
+ /** Priority lane. */
43
+ lane: RateLimitLane;
44
+ /**
45
+ * Optional coalesce key. Submitting another item with the same
46
+ * `(sessionId, lane, coalesceKey)` replaces the queued payload with the
47
+ * newer one instead of enqueuing a duplicate (used for live edits).
48
+ */
49
+ coalesceKey?: string;
50
+ /** Opaque payload the caller maps to an actual Telegram send. */
51
+ payload: T;
52
+ }
53
+
54
+ /** Options for {@link RateLimitPool}. */
55
+ export interface RateLimitPoolOptions {
56
+ /** Burst capacity (max tokens). Default 20 (Telegram per-chat burst). */
57
+ capacity?: number;
58
+ /** Steady refill rate in tokens per second. Default 1 (~1 msg/sec/chat). */
59
+ refillPerSec?: number;
60
+ /** Injectable clock in ms. Default `Date.now`. */
61
+ now?: () => number;
62
+ }
63
+
64
+ interface QueuedItem<T> {
65
+ item: RateLimitItem<T>;
66
+ seq: number;
67
+ }
68
+
69
+ /** Build a fixed lookup table with one entry per priority lane (lanes are a
70
+ * small, static, never-added-to-at-runtime key set — a plain `Record` avoids
71
+ * the `Map` overhead/API surface this scheduler never needs). */
72
+ function createLaneRecord<V>(factory: () => V): Record<RateLimitLane, V> {
73
+ const record = {} as Record<RateLimitLane, V>;
74
+ for (const lane of LANE_PRIORITY) record[lane] = factory();
75
+ return record;
76
+ }
77
+
78
+ /**
79
+ * A deterministic, pull-based shared rate-limit scheduler.
80
+ *
81
+ * Callers {@link RateLimitPool.submit} work and periodically
82
+ * {@link RateLimitPool.drain} (e.g. on a timer or after each submit); `drain`
83
+ * returns the items granted a send slot, in the order they should be sent.
84
+ */
85
+ export class RateLimitPool<T = unknown> {
86
+ private readonly capacity: number;
87
+ private readonly refillPerSec: number;
88
+ private readonly now: () => number;
89
+
90
+ /** Per-lane FIFO queues; each lane holds items across sessions. */
91
+ private readonly lanes: Record<RateLimitLane, QueuedItem<T>[]>;
92
+ /** Rotating session cursor per lane for round-robin fairness. */
93
+ private readonly laneCursor: Record<RateLimitLane, number> = createLaneRecord(() => 0);
94
+
95
+ private tokens: number;
96
+ private lastRefill: number;
97
+ private seqCounter = 0;
98
+
99
+ constructor(options: RateLimitPoolOptions = {}) {
100
+ this.capacity = Math.max(1, options.capacity ?? 20);
101
+ this.refillPerSec = Math.max(0, options.refillPerSec ?? 1);
102
+ this.now = options.now ?? Date.now;
103
+ this.tokens = this.capacity;
104
+ this.lastRefill = this.now();
105
+ this.lanes = createLaneRecord<QueuedItem<T>[]>(() => []);
106
+ }
107
+
108
+ /** Number of items currently queued across all lanes. */
109
+ get pending(): number {
110
+ let total = 0;
111
+ for (const lane of LANE_PRIORITY) total += this.lanes[lane].length;
112
+ return total;
113
+ }
114
+
115
+ /** Current available token count (after refill at `now`). */
116
+ availableTokens(nowMs: number = this.now()): number {
117
+ this.refill(nowMs);
118
+ return this.tokens;
119
+ }
120
+
121
+ /**
122
+ * Submit an item. If it carries a `coalesceKey` matching a queued item in
123
+ * the same `(sessionId, lane)`, the queued payload is replaced (latest
124
+ * wins) and FIFO position is preserved; otherwise it is appended.
125
+ */
126
+ submit(item: RateLimitItem<T>): void {
127
+ const queue = this.lanes[item.lane];
128
+ if (!queue) throw new Error(`unknown rate-limit lane: ${item.lane}`);
129
+ if (item.coalesceKey !== undefined) {
130
+ const existing = queue.find(
131
+ q => q.item.sessionId === item.sessionId && q.item.coalesceKey === item.coalesceKey,
132
+ );
133
+ if (existing) {
134
+ existing.item = item;
135
+ return;
136
+ }
137
+ }
138
+ queue.push({ item, seq: this.seqCounter++ });
139
+ }
140
+
141
+ /**
142
+ * Grant as many queued items as tokens allow at `nowMs`. Items are selected
143
+ * by lane priority, then round-robin across sessions within a lane (so no
144
+ * single session monopolises a lane), consuming one token each.
145
+ */
146
+ drain(nowMs: number = this.now()): RateLimitItem<T>[] {
147
+ this.refill(nowMs);
148
+ const granted: RateLimitItem<T>[] = [];
149
+ while (this.tokens >= 1) {
150
+ const next = this.takeNext();
151
+ if (!next) break;
152
+ this.tokens -= 1;
153
+ granted.push(next);
154
+ }
155
+ return granted;
156
+ }
157
+
158
+ /** Remove queued items matching `predicate` without consuming tokens. Returns removed items in lane/FIFO order. */
159
+ removeWhere(predicate: (item: RateLimitItem<T>) => boolean): RateLimitItem<T>[] {
160
+ const removed: RateLimitItem<T>[] = [];
161
+ for (const lane of LANE_PRIORITY) {
162
+ const queue = this.lanes[lane];
163
+ let write = 0;
164
+ for (let read = 0; read < queue.length; read++) {
165
+ const queued = queue[read]!;
166
+ if (predicate(queued.item)) {
167
+ removed.push(queued.item);
168
+ } else {
169
+ queue[write++] = queued;
170
+ }
171
+ }
172
+ queue.length = write;
173
+ }
174
+ return removed;
175
+ }
176
+
177
+ private refill(nowMs: number): void {
178
+ if (nowMs <= this.lastRefill) return;
179
+ const elapsedSec = (nowMs - this.lastRefill) / 1000;
180
+ this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillPerSec);
181
+ this.lastRefill = nowMs;
182
+ }
183
+
184
+ /** Pop the next item by lane priority + per-session round-robin fairness. */
185
+ private takeNext(): RateLimitItem<T> | undefined {
186
+ for (const lane of LANE_PRIORITY) {
187
+ const queue = this.lanes[lane];
188
+ if (queue.length === 0) continue;
189
+ const picked = this.pickFairIndex(lane, queue);
190
+ const [removed] = queue.splice(picked, 1);
191
+ return removed?.item;
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ /**
197
+ * Choose the index to serve from a lane queue using round-robin over the
198
+ * distinct session ids present, starting just after the last-served
199
+ * session. Falls back to FIFO (index 0) when only one session is queued.
200
+ */
201
+ private pickFairIndex(lane: RateLimitLane, queue: QueuedItem<T>[]): number {
202
+ const sessions: string[] = [];
203
+ for (const q of queue) if (!sessions.includes(q.item.sessionId)) sessions.push(q.item.sessionId);
204
+ if (sessions.length <= 1) return 0;
205
+ const cursor = this.laneCursor[lane];
206
+ // Choose the earliest-queued item whose session is the next in rotation.
207
+ for (let offset = 0; offset < sessions.length; offset++) {
208
+ const candidate = sessions[(cursor + offset) % sessions.length]!;
209
+ const idx = queue.findIndex(q => q.item.sessionId === candidate);
210
+ if (idx >= 0) {
211
+ this.laneCursor[lane] = (cursor + offset + 1) % sessions.length;
212
+ return idx;
213
+ }
214
+ }
215
+ return 0;
216
+ }
217
+ }