jeo-code 0.7.47 → 0.7.49
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 +34 -0
- package/README.ja.md +2 -2
- package/README.ko.md +5 -4
- package/README.md +5 -4
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/config-schema.ts +17 -0
- package/src/agent/notify/AGENTS.md +30 -10
- package/src/agent/notify/config-commands.ts +50 -0
- package/src/agent/notify/paths.ts +7 -0
- package/src/agent/notify/rate-limit-pool.ts +217 -0
- package/src/agent/notify/session-endpoint.ts +167 -29
- package/src/agent/notify/telegram-api.ts +170 -10
- package/src/agent/notify/telegram-daemon.ts +490 -21
- package/src/agent/notify/telegram-html.ts +414 -0
- package/src/agent/notify/topic-registry.ts +136 -0
- package/src/agent/state.ts +9 -0
- package/src/agent/task-tool.ts +6 -2
- package/src/commands/launch.ts +141 -4
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,40 @@ 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.49] - 2026-07-06
|
|
10
|
+
_gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **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.
|
|
14
|
+
- **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).
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
- **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).
|
|
18
|
+
- **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.
|
|
19
|
+
- **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).
|
|
20
|
+
- **`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).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
25
|
+
### Verified
|
|
26
|
+
- `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).
|
|
27
|
+
- 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).
|
|
28
|
+
- 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).
|
|
29
|
+
|
|
30
|
+
## [0.7.48] - 2026-07-06
|
|
31
|
+
_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._
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- **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.
|
|
35
|
+
- **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`.
|
|
36
|
+
- **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.
|
|
37
|
+
|
|
38
|
+
### Verified
|
|
39
|
+
- `bun run typecheck` clean; `bun test` 2498 pass / 0 fail.
|
|
40
|
+
- `test/notify-telegram-api.test.ts` (+56 lines): `sendPhoto`/`answerCallbackQuery` request shape, `messageThreadId`/`replyMarkup` forwarding.
|
|
41
|
+
- `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`.
|
|
42
|
+
|
|
9
43
|
## [0.7.47] - 2026-07-06
|
|
10
44
|
_PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern._
|
|
11
45
|
|
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.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
|
+
- **[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.
|
|
262
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.
|
|
263
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.
|
|
264
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.
|
|
265
|
-
- **[0.7.44]** (2026-07-06) — Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call.
|
|
266
|
-
- **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
|
|
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.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
|
|
264
|
+
- **[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.
|
|
262
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.
|
|
263
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.
|
|
264
267
|
- **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
|
|
265
|
-
- **[0.7.44]** (2026-07-06) — Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call.
|
|
266
|
-
- **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
|
|
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.49]** (2026-07-06) — gjc Telegram-daemon parity, Tier 2 (follow-up to 0.7.37/0.7.38/0.7.48's subagent-visibility-only daemon): adds a dynamic, PER-SESSION forum topic that mirrors that session's OWN activity — not just its subagents — and lets a Telegram reply steer it directly. Opt-in via `notifications.telegram.perSessionTopics`; the flat/global `topicId` path from 0.7.48 is UNCHANGED when it's off, so existing setups see zero regression.
|
|
266
|
+
- **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
|
|
264
267
|
- **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
|
|
265
268
|
- **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
|
|
266
269
|
- **[0.7.45]** (2026-07-06) — gjc parity: jeo's subagent `task {tasks:[...]}` fan-out batches now visibly run as PARALLEL processes the way gjc's own task tool does, instead of quietly forcing the mutating executor role to serialize. Two compounding bugs made a batch of independent subagent tasks look and behave sequential even though the read-only roles were already technically concurrent: (1) the executor role's fan-out was hard-coded to concurrency 1 regardless of batch size, and (2) the TUI's live status line tracked ONE shared string clobbered by whichever worker's event landed last — worse, ANY single worker reaching "done" cleared the whole `(sub)` marker even while its siblings were still actively running.
|
|
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
|
-
- **[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.
|
|
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.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
|
+
- **[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.
|
|
262
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.
|
|
263
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.
|
|
264
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.
|
|
265
|
-
- **[0.7.44]** (2026-07-06) — Root-caused a real production hang reported from `jeo`'s OpenAI Codex OAuth subagent path: after roughly 20-30 minutes of active streamed traffic, `chatgpt.com`'s backend severs the live SSE connection mid-response (an infra connection-duration cap, not a broken network) and Bun's fetch/undici surfaces it as `Error: The socket connection was closed unexpectedly …`. `retryableStream` (model-manager.ts) only auto-retries losing the FIRST streamed chunk — once any chunk had reached the caller it deliberately stopped retrying (a full re-call would replay already-emitted content) — so this class of drop propagated straight out of the engine as a raw, unretried turn-ending error every time, even though nothing had been committed to history yet and a plain resend is exactly as safe as a fresh call.
|
|
266
|
-
- **[0.7.43]** (2026-07-06) — TUI inline image DISPLAY (gjc TUI-image parity): jeo could already ATTACH an image to a turn (clipboard paste, drag-drop, `@path`) but never rendered it back — the transcript only ever showed a `⧉ N image(s) attached` count. On a terminal that speaks the kitty graphics protocol (kitty/ghostty/wezterm) or iTerm2's OSC 1337 inline-image protocol, a submitted image now renders as an actual picture directly under the `user` card; every other terminal (Terminal.app, plain xterm, CI/non-TTY) keeps today's text-only behavior unchanged. No native/binary dependency added — Sixel was deliberately left out because producing a sixel stream from arbitrary PNG/JPEG bytes needs a pixel quantizer jeo doesn't have; both implemented protocols decode compressed image bytes themselves.
|
|
267
267
|
|
|
268
268
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
269
269
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -92,10 +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(),
|
|
106
|
+
/** Forum-topic thread id (message_thread_id) for supergroups with
|
|
107
|
+
* topics enabled — daemon pushes go into this topic. Ignored for a
|
|
108
|
+
* session-owned topic when `perSessionTopics` is true. */
|
|
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(),
|
|
99
116
|
})
|
|
100
117
|
.optional(),
|
|
101
118
|
})
|
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
<!-- Parent: ../AGENTS.md -->
|
|
2
|
-
<!-- Generated: 2026-07-
|
|
2
|
+
<!-- Generated: 2026-07-06 -->
|
|
3
3
|
|
|
4
4
|
# notify
|
|
5
5
|
|
|
6
6
|
## Purpose
|
|
7
|
-
Remote subagent visibility/control over Telegram (gjc
|
|
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
|
|
14
|
-
| `
|
|
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,
|
|
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, de
|
|
|
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
|
-
-
|
|
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
|
+
}
|