jeo-code 0.7.47 → 0.7.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.ja.md +1 -1
- package/README.ko.md +4 -3
- package/README.md +4 -3
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/config-schema.ts +3 -0
- package/src/agent/notify/AGENTS.md +3 -3
- package/src/agent/notify/telegram-api.ts +99 -10
- package/src/agent/notify/telegram-daemon.ts +151 -15
- package/src/agent/state.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [0.7.48] - 2026-07-06
|
|
10
|
+
_gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Forum-topic routing** (`topicId` in `notifications.telegram` config, `config-schema.ts`/`state.ts`) — every daemon push carries the configured `message_thread_id` so a supergroup with Topics enabled routes jeo's notifications into one dedicated topic instead of the general channel; inbound `/subagents`/`/steer`/`/cancel`/`/help` commands are topic-filtered to match.
|
|
14
|
+
- **Inline-keyboard Cancel buttons + callback taps** (`src/agent/notify/telegram-api.ts`: `answerCallbackQuery`, `replyMarkup` on `sendMessage`/`sendPhoto`; `src/agent/notify/telegram-daemon.ts`) — a subagent's `started` push and `/subagents` listing now attach a per-subagent inline ⏹ Cancel button; tapping it round-trips through the same cancel path as the existing `/cancel <sessionId> <subagentId>` text command, then acknowledges the tap via `answerCallbackQuery` so Telegram clears its loading spinner. `TelegramUpdate` extended with `callback_query`/`message_thread_id`.
|
|
15
|
+
- **Image attachments** (`TelegramApi.sendPhoto`, `TelegramDaemon#notifyPhoto`) — a session `{type:"photo"}` frame is now relayed to Telegram via `sendPhoto` (caption + topic + inline-keyboard `replyMarkup` all supported), closing the last gap from 0.7.37's "no image attachments" scope note.
|
|
16
|
+
|
|
17
|
+
### Verified
|
|
18
|
+
- `bun run typecheck` clean; `bun test` 2498 pass / 0 fail.
|
|
19
|
+
- `test/notify-telegram-api.test.ts` (+56 lines): `sendPhoto`/`answerCallbackQuery` request shape, `messageThreadId`/`replyMarkup` forwarding.
|
|
20
|
+
- `test/notify-telegram-daemon.test.ts` (+184 lines across two commits): topic-scoped push/inbound-filter round-trip, inline Cancel button attached to a `started` push and to `/subagents` output, a `callback_query` tap correctly cancels the matching subagent and acknowledges, and a session photo frame relayed via `sendPhoto` with caption + topic + `replyMarkup`.
|
|
21
|
+
|
|
9
22
|
## [0.7.47] - 2026-07-06
|
|
10
23
|
_PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern._
|
|
11
24
|
|
package/README.ja.md
CHANGED
|
@@ -259,11 +259,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
259
259
|
## 変更履歴 (Changelog)
|
|
260
260
|
|
|
261
261
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
262
|
+
- **[0.7.48]** (2026-07-06) — gjc Telegram-daemon parity, phase 2 (follow-up to 0.7.37/0.7.38's baseline subagent-visibility daemon, which deliberately scoped OUT forum topics, inline keyboards, and image attachments — see that entry): the daemon now speaks gjc's richer notification surface instead of the plain-text-only baseline.
|
|
262
263
|
- **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
|
|
263
264
|
- **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
|
|
264
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.
|
|
265
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.
|
|
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.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
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.
|
|
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.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
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.
|
|
267
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.
|
|
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.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
263
|
- **[0.7.47]** (2026-07-06) — PromptRouter (gjc-inspired, jeo-native design — NOT a port of katanemo/plano, whose always-on proxy-orchestrator architecture doesn't fit an interactive CLI's per-turn latency budget): jeo already had static, role-based model mapping (`resolveSubagentModel`/`resolveRoleModel`) but zero logic that varied the model by what THIS turn's prompt actually asks for. Adds an opt-in (default OFF), heuristic-first, fail-open per-turn router: a bilingual regex classifier scores a prompt into trivial/standard/complex, escalating to one cheap LLM call ONLY when the heuristic is genuinely ambiguous (confidence below a configurable threshold — most turns never escalate), and an explicit `/model` pin always wins over routing. No new plumbing: reuses `resolveRoleModel`, `callLlm`, `jsonMode`, `catalogMetadata`, `tryExtractJsonObject`, and the existing `onNotice` transparency pattern.
|
|
263
264
|
- **[0.7.46]** (2026-07-06) — Registry-only correction: `npm publish` packs the working-tree filesystem, not the git commit — a concurrent, unrelated, uncommitted feature-in-progress from another session sharing this checkout (`src/agent/prompt-router.ts`, `src/commands/launch/route-slash.ts`, and edits to `config-schema.ts`/`state.ts`/`launch.ts`/`slash.ts`) was physically present on disk during the 0.7.45 `npm publish` and got bundled into that tarball even though it was never committed to git and is absent from the `0.7.45` git tag/branch. Unpublished `jeo-code@0.7.45` from the registry within minutes (npm then permanently blocks republishing that exact version number, hence the bump to 0.7.46) and republished from a verified-clean working tree (`git stash` of the foreign files, `npm pack --dry-run` confirmed their absence, then restored the stash afterward so the other session's in-progress work was never touched or lost). No functional change versus the intended 0.7.45 content — see that entry below.
|
|
264
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.
|
|
265
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.
|
|
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
|
@@ -96,6 +96,9 @@ export const ConfigSchema = z
|
|
|
96
96
|
.object({
|
|
97
97
|
botToken: z.string().optional(),
|
|
98
98
|
chatId: z.string().optional(),
|
|
99
|
+
/** Forum-topic thread id (message_thread_id) for supergroups with
|
|
100
|
+
* topics enabled — daemon pushes go into this topic. */
|
|
101
|
+
topicId: z.number().optional(),
|
|
99
102
|
})
|
|
100
103
|
.optional(),
|
|
101
104
|
})
|
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
# notify
|
|
5
5
|
|
|
6
6
|
## Purpose
|
|
7
|
-
Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity,
|
|
7
|
+
Remote subagent visibility/control over Telegram (gjc Telegram-daemon parity, scoped to jeo's subagent surface). Supports gjc's richer notification surface: forum topics (`message_thread_id`), inline keyboards (per-subagent ⏹ Cancel buttons + `callback_query` handling), and image attachments (`sendPhoto`, relayed from a session `{type:"photo"}` frame).
|
|
8
8
|
|
|
9
9
|
## Key Files
|
|
10
10
|
| File | Description |
|
|
11
11
|
|------|-------------|
|
|
12
12
|
| `paths.ts` | Path helpers under `<jeoHome>/notifications` (daemon lock/log, per-session discovery files) |
|
|
13
|
-
| `telegram-api.ts` | Minimal, injectable-`fetch` Telegram Bot API client (`getMe`/`sendMessage`/`getUpdates`) + `maskToken` |
|
|
13
|
+
| `telegram-api.ts` | Minimal, injectable-`fetch` Telegram Bot API client (`getMe`/`sendMessage`/`sendPhoto`/`answerCallbackQuery`/`getUpdates`) + `maskToken`; `sendMessage`/`sendPhoto` accept `messageThreadId` (forum topics) and `replyMarkup` (inline keyboards) |
|
|
14
14
|
| `session-endpoint.ts` | Per-turn, loopback-only WebSocket server exposing ONE `SubagentRegistry` (discovery file + `snapshot`/`steer`/`cancel`/`list` protocol); lazy `ensureSessionNotifyEndpoint`/`stopSessionNotifyEndpoint` wiring, no-op unless `notifications.enabled` |
|
|
15
15
|
| `daemon-control.ts` | Daemon lifecycle: pid+startedAt lock singleton, `daemonInvocation` self-spawn argv (mirrors `memory.ts`'s `distillInvocation`), `startDaemon`/`stopDaemon`/`reloadDaemon`/`daemonStatus` |
|
|
16
|
-
| `telegram-daemon.ts` | The managed daemon (`TelegramDaemon` class + `runNotifyDaemonForeground` CLI entrypoint): scans session discovery files, connects a WS per session, pushes Telegram messages on subagent status EDGES only, and dispatches inbound `/subagents`/`/steer`/`/cancel`/`/help` commands back over the matching session's WebSocket |
|
|
16
|
+
| `telegram-daemon.ts` | The managed daemon (`TelegramDaemon` class + `runNotifyDaemonForeground` CLI entrypoint): scans session discovery files, connects a WS per session, pushes Telegram messages on subagent status EDGES only (with an inline ⏹ Cancel button), and dispatches inbound `/subagents`/`/steer`/`/cancel`/`/help` commands AND inline-button `callback_query` taps back over the matching session's WebSocket. All pushes carry the configured forum `topicId`, and inbound is topic-filtered; a session `{type:"photo"}` frame is relayed via `sendPhoto` |
|
|
17
17
|
|
|
18
18
|
## Subdirectories
|
|
19
19
|
*(None)*
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal Telegram Bot HTTP API client (gjc `telegram-reference.ts`/`telegram-cli.ts`
|
|
3
|
-
* parity
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* parity). Scoped to the calls the jeo daemon needs — identity verification,
|
|
4
|
+
* outbound push, inbound long-poll — plus gjc's richer surface: forum topics
|
|
5
|
+
* (`message_thread_id`), inline keyboards (`reply_markup` + `answerCallbackQuery`),
|
|
6
|
+
* and image attachments (`sendPhoto`, photo given as a URL or `file_id`).
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
export interface TelegramChat {
|
|
@@ -12,6 +13,31 @@ export interface TelegramChat {
|
|
|
12
13
|
username?: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** Inline keyboard primitives (gjc parity). `callback_data` is capped by Telegram
|
|
17
|
+
* at 64 bytes; the daemon keeps its payloads short (`cancel:<shortId>:<id>`). */
|
|
18
|
+
export interface InlineKeyboardButton {
|
|
19
|
+
text: string;
|
|
20
|
+
callback_data?: string;
|
|
21
|
+
url?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface InlineKeyboardMarkup {
|
|
25
|
+
inline_keyboard: InlineKeyboardButton[][];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Button-press callback delivered via `getUpdates` when a user taps an inline
|
|
29
|
+
* keyboard button. `message` carries the originating chat/topic. */
|
|
30
|
+
export interface TelegramCallbackQuery {
|
|
31
|
+
id: string;
|
|
32
|
+
from: { id: number; username?: string; is_bot: boolean };
|
|
33
|
+
message?: {
|
|
34
|
+
message_id: number;
|
|
35
|
+
chat: TelegramChat;
|
|
36
|
+
message_thread_id?: number;
|
|
37
|
+
};
|
|
38
|
+
data?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
15
41
|
export interface TelegramUpdate {
|
|
16
42
|
update_id: number;
|
|
17
43
|
message?: {
|
|
@@ -20,7 +46,11 @@ export interface TelegramUpdate {
|
|
|
20
46
|
chat: TelegramChat;
|
|
21
47
|
text?: string;
|
|
22
48
|
from?: { id: number; username?: string; is_bot: boolean };
|
|
49
|
+
/** Forum-topic thread this message belongs to (supergroups with topics on). */
|
|
50
|
+
message_thread_id?: number;
|
|
51
|
+
is_topic_message?: boolean;
|
|
23
52
|
};
|
|
53
|
+
callback_query?: TelegramCallbackQuery;
|
|
24
54
|
}
|
|
25
55
|
|
|
26
56
|
export interface TelegramGetMeResult {
|
|
@@ -40,6 +70,24 @@ export interface TelegramGetUpdatesResult {
|
|
|
40
70
|
description?: string;
|
|
41
71
|
}
|
|
42
72
|
|
|
73
|
+
/** Shared outbound options: target a forum topic and/or attach an inline keyboard. */
|
|
74
|
+
export interface SendMessageOptions {
|
|
75
|
+
messageThreadId?: number;
|
|
76
|
+
replyMarkup?: InlineKeyboardMarkup;
|
|
77
|
+
disableWebPagePreview?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SendPhotoOptions {
|
|
81
|
+
caption?: string;
|
|
82
|
+
messageThreadId?: number;
|
|
83
|
+
replyMarkup?: InlineKeyboardMarkup;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AnswerCallbackQueryOptions {
|
|
87
|
+
text?: string;
|
|
88
|
+
showAlert?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
43
91
|
export type FetchLike = typeof fetch;
|
|
44
92
|
|
|
45
93
|
export class TelegramApi {
|
|
@@ -52,18 +100,59 @@ export class TelegramApi {
|
|
|
52
100
|
return `https://api.telegram.org/bot${this.token}/${method}`;
|
|
53
101
|
}
|
|
54
102
|
|
|
103
|
+
private async postJson<T>(method: string, body: Record<string, unknown>): Promise<T> {
|
|
104
|
+
const res = await this.fetchImpl(this.url(method), {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "content-type": "application/json" },
|
|
107
|
+
body: JSON.stringify(body),
|
|
108
|
+
});
|
|
109
|
+
return (await res.json()) as T;
|
|
110
|
+
}
|
|
111
|
+
|
|
55
112
|
async getMe(): Promise<TelegramGetMeResult> {
|
|
56
113
|
const res = await this.fetchImpl(this.url("getMe"));
|
|
57
114
|
return (await res.json()) as TelegramGetMeResult;
|
|
58
115
|
}
|
|
59
116
|
|
|
60
|
-
async sendMessage(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
117
|
+
async sendMessage(
|
|
118
|
+
chatId: string | number,
|
|
119
|
+
text: string,
|
|
120
|
+
options: SendMessageOptions = {},
|
|
121
|
+
): Promise<TelegramSendResult> {
|
|
122
|
+
const body: Record<string, unknown> = {
|
|
123
|
+
chat_id: chatId,
|
|
124
|
+
text,
|
|
125
|
+
disable_web_page_preview: options.disableWebPagePreview ?? true,
|
|
126
|
+
};
|
|
127
|
+
if (options.messageThreadId !== undefined) body.message_thread_id = options.messageThreadId;
|
|
128
|
+
if (options.replyMarkup) body.reply_markup = options.replyMarkup;
|
|
129
|
+
return this.postJson<TelegramSendResult>("sendMessage", body);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Send a photo. `photo` is a public URL or a Telegram `file_id` (no local
|
|
133
|
+
* multipart upload — jeo pushes URLs/ids, matching gjc's URL-attachment path). */
|
|
134
|
+
async sendPhoto(
|
|
135
|
+
chatId: string | number,
|
|
136
|
+
photo: string,
|
|
137
|
+
options: SendPhotoOptions = {},
|
|
138
|
+
): Promise<TelegramSendResult> {
|
|
139
|
+
const body: Record<string, unknown> = { chat_id: chatId, photo };
|
|
140
|
+
if (options.caption !== undefined) body.caption = options.caption;
|
|
141
|
+
if (options.messageThreadId !== undefined) body.message_thread_id = options.messageThreadId;
|
|
142
|
+
if (options.replyMarkup) body.reply_markup = options.replyMarkup;
|
|
143
|
+
return this.postJson<TelegramSendResult>("sendPhoto", body);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Acknowledge an inline-button tap (clears the client's loading spinner and
|
|
147
|
+
* optionally shows a toast/alert). Telegram requires this within ~15s. */
|
|
148
|
+
async answerCallbackQuery(
|
|
149
|
+
callbackQueryId: string,
|
|
150
|
+
options: AnswerCallbackQueryOptions = {},
|
|
151
|
+
): Promise<TelegramSendResult> {
|
|
152
|
+
const body: Record<string, unknown> = { callback_query_id: callbackQueryId };
|
|
153
|
+
if (options.text !== undefined) body.text = options.text;
|
|
154
|
+
if (options.showAlert !== undefined) body.show_alert = options.showAlert;
|
|
155
|
+
return this.postJson<TelegramSendResult>("answerCallbackQuery", body);
|
|
67
156
|
}
|
|
68
157
|
|
|
69
158
|
/** Long-poll for new updates. `offset` should be `last_update_id + 1`. */
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The managed Telegram daemon (gjc `notifications/telegram-daemon.ts` parity
|
|
3
|
-
* scoped down to jeo's subagent-remote-control surface — no forum topics, no
|
|
4
|
-
* inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo
|
|
5
|
-
* intentionally does not replicate from gjc's full notification stack).
|
|
6
|
-
*
|
|
2
|
+
* The managed Telegram daemon (gjc `notifications/telegram-daemon.ts` parity).
|
|
7
3
|
* Runs as ONE long-lived background process (singleton, enforced by
|
|
8
4
|
* `daemon-control.ts`'s lock file — Telegram allows only one `getUpdates`
|
|
9
5
|
* long-poll owner per bot token). It:
|
|
@@ -12,19 +8,28 @@
|
|
|
12
8
|
* discovery files and connects a loopback WebSocket to each
|
|
13
9
|
* (`SessionNotifyEndpoint` on the other end);
|
|
14
10
|
* - sends a Telegram message on each subagent state EDGE (started → a
|
|
15
|
-
* terminal state), not on every poll tick
|
|
11
|
+
* terminal state), not on every poll tick, attaching an inline "Cancel"
|
|
12
|
+
* keyboard button for the running subagent;
|
|
16
13
|
* - long-polls Telegram `getUpdates` and dispatches `/subagents`,
|
|
17
14
|
* `/steer <sessionId> <subagentId> <message>`, `/cancel <sessionId>
|
|
18
|
-
* <subagentId>`, `/help`
|
|
15
|
+
* <subagentId>`, `/help` — plus inline-keyboard button taps
|
|
16
|
+
* (`callback_query`) — back into the matching session's WebSocket.
|
|
17
|
+
*
|
|
18
|
+
* gjc-parity surface added on top of the plain-text core: forum topics (all
|
|
19
|
+
* pushes carry the configured `message_thread_id`, inbound is topic-filtered),
|
|
20
|
+
* inline keyboards (cancel buttons + `callback_query` handling), and image
|
|
21
|
+
* attachments (a session may push a `{type:"photo"}` frame relayed via
|
|
22
|
+
* `sendPhoto`).
|
|
19
23
|
*/
|
|
20
24
|
import * as fs from "node:fs/promises";
|
|
21
25
|
import * as path from "node:path";
|
|
22
26
|
import { readGlobalConfig } from "../state";
|
|
23
27
|
import { notifySessionsDir } from "./paths";
|
|
24
28
|
import { acquireDaemonLock, isPidAlive } from "./daemon-control";
|
|
25
|
-
import { TelegramApi, type TelegramUpdate } from "./telegram-api";
|
|
29
|
+
import { TelegramApi, type TelegramUpdate, type TelegramCallbackQuery, type InlineKeyboardMarkup, type InlineKeyboardButton } from "./telegram-api";
|
|
26
30
|
import type { SubagentRecord } from "../subagent-registry";
|
|
27
31
|
|
|
32
|
+
|
|
28
33
|
function sleep(ms: number): Promise<void> {
|
|
29
34
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
30
35
|
setTimeout(resolve, ms);
|
|
@@ -109,7 +114,47 @@ export const HELP_TEXT =
|
|
|
109
114
|
"/subagents — list running/recent subagents across every connected session\n" +
|
|
110
115
|
"/steer <sessionId> <subagentId> <message> — send a live message into a running subagent\n" +
|
|
111
116
|
"/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
|
|
112
|
-
"/
|
|
117
|
+
"/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
|
|
118
|
+
"/help — show this message\n" +
|
|
119
|
+
"(running subagents also carry an inline ⏹ Cancel button — tap it instead of typing /cancel)";
|
|
120
|
+
|
|
121
|
+
// ── Inline keyboards / callback data (gjc parity) ───────────────────────────────
|
|
122
|
+
|
|
123
|
+
/** Encode an inline-button cancel target. Kept short to stay under Telegram's
|
|
124
|
+
* 64-byte `callback_data` cap. */
|
|
125
|
+
export function cancelCallbackData(shortId: string, subagentId: string): string {
|
|
126
|
+
return `cancel:${shortId}:${subagentId}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Parse a `callback_data` payload produced by `cancelCallbackData`. */
|
|
130
|
+
export function parseCallbackData(
|
|
131
|
+
data: string,
|
|
132
|
+
): { action: "cancel"; shortId: string; subagentId: string } | undefined {
|
|
133
|
+
const m = /^cancel:([^:]+):([\s\S]+)$/.exec(data);
|
|
134
|
+
if (!m) return undefined;
|
|
135
|
+
return { action: "cancel", shortId: m[1]!, subagentId: m[2]! };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One ⏹ Cancel button (its own row) targeting a single running subagent. */
|
|
139
|
+
function cancelButton(shortId: string, rec: SubagentRecord): InlineKeyboardButton {
|
|
140
|
+
return { text: `⏹ Cancel ${rec.role} '${rec.id}'`, callback_data: cancelCallbackData(shortId, rec.id) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Inline keyboard with one ⏹ Cancel button per RUNNING subagent across every
|
|
144
|
+
* connected session; returns undefined when nothing is running (no keyboard to
|
|
145
|
+
* attach). */
|
|
146
|
+
export function buildSubagentsKeyboard(
|
|
147
|
+
sessions: Array<{ sessionId: string; records: SubagentRecord[] }>,
|
|
148
|
+
): InlineKeyboardMarkup | undefined {
|
|
149
|
+
const rows: InlineKeyboardButton[][] = [];
|
|
150
|
+
for (const s of sessions) {
|
|
151
|
+
for (const r of s.records) {
|
|
152
|
+
if (r.status === "running") rows.push([cancelButton(shortSessionId(s.sessionId), r)]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return rows.length ? { inline_keyboard: rows } : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
113
158
|
|
|
114
159
|
// ── Stateful daemon (network/ws — DI'd for tests) ───────────────────────────────
|
|
115
160
|
|
|
@@ -131,6 +176,9 @@ export interface TelegramDaemonOptions {
|
|
|
131
176
|
isPidAlive?: (pid: number) => boolean;
|
|
132
177
|
scanIntervalMs?: number;
|
|
133
178
|
ackTimeoutMs?: number;
|
|
179
|
+
/** Forum-topic thread id (message_thread_id). When set, every push is sent
|
|
180
|
+
* into this topic and inbound messages/taps from other topics are ignored. */
|
|
181
|
+
topicId?: number;
|
|
134
182
|
}
|
|
135
183
|
|
|
136
184
|
export class TelegramDaemon {
|
|
@@ -145,14 +193,37 @@ export class TelegramDaemon {
|
|
|
145
193
|
this.WS = opts.WebSocketImpl ?? WebSocket;
|
|
146
194
|
}
|
|
147
195
|
|
|
148
|
-
private async notify(text: string): Promise<void> {
|
|
196
|
+
private async notify(text: string, replyMarkup?: InlineKeyboardMarkup): Promise<void> {
|
|
149
197
|
try {
|
|
150
|
-
await this.opts.telegram.sendMessage(this.opts.chatId, text
|
|
198
|
+
await this.opts.telegram.sendMessage(this.opts.chatId, text, {
|
|
199
|
+
messageThreadId: this.opts.topicId,
|
|
200
|
+
replyMarkup,
|
|
201
|
+
});
|
|
151
202
|
} catch {
|
|
152
203
|
// best-effort — a failed push must never crash the daemon loop.
|
|
153
204
|
}
|
|
154
205
|
}
|
|
155
206
|
|
|
207
|
+
private async notifyPhoto(photo: string, caption?: string, replyMarkup?: InlineKeyboardMarkup): Promise<void> {
|
|
208
|
+
try {
|
|
209
|
+
await this.opts.telegram.sendPhoto(this.opts.chatId, photo, {
|
|
210
|
+
messageThreadId: this.opts.topicId,
|
|
211
|
+
caption,
|
|
212
|
+
replyMarkup,
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
// best-effort — a failed photo push must never crash the daemon loop.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async answerCallback(id: string, text?: string): Promise<void> {
|
|
220
|
+
try {
|
|
221
|
+
await this.opts.telegram.answerCallbackQuery(id, text !== undefined ? { text } : {});
|
|
222
|
+
} catch {
|
|
223
|
+
// best-effort — acknowledging the tap is nice-to-have, not load-bearing.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
156
227
|
async scanSessions(): Promise<void> {
|
|
157
228
|
const readdir = this.opts.readdir ?? (d => fs.readdir(d));
|
|
158
229
|
let files: string[];
|
|
@@ -205,11 +276,28 @@ export class TelegramDaemon {
|
|
|
205
276
|
const next = msg.subagents as SubagentRecord[];
|
|
206
277
|
const events = diffSubagentTransitions(conn.lastRecords, next);
|
|
207
278
|
conn.lastRecords = next;
|
|
279
|
+
const shortId = shortSessionId(conn.sessionId);
|
|
208
280
|
for (const ev of events) {
|
|
209
|
-
|
|
281
|
+
const keyboard =
|
|
282
|
+
ev.kind === "started" ? { inline_keyboard: [[cancelButton(shortId, ev.record)]] } : undefined;
|
|
283
|
+
await this.notify(formatNotifyEvent(shortId, conn.cwd, ev), keyboard);
|
|
210
284
|
}
|
|
211
285
|
return;
|
|
212
286
|
}
|
|
287
|
+
if (msg.type === "photo" && typeof msg.url === "string") {
|
|
288
|
+
const replyMarkup =
|
|
289
|
+
msg.replyMarkup &&
|
|
290
|
+
typeof msg.replyMarkup === "object" &&
|
|
291
|
+
"inline_keyboard" in (msg.replyMarkup as object)
|
|
292
|
+
? (msg.replyMarkup as InlineKeyboardMarkup)
|
|
293
|
+
: undefined;
|
|
294
|
+
await this.notifyPhoto(
|
|
295
|
+
msg.url,
|
|
296
|
+
typeof msg.caption === "string" ? msg.caption : undefined,
|
|
297
|
+
replyMarkup,
|
|
298
|
+
);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
213
301
|
if (msg.type === "ack" && typeof msg.reqId === "string") {
|
|
214
302
|
const resolve = this.pendingAcks.get(msg.reqId);
|
|
215
303
|
if (resolve) {
|
|
@@ -250,7 +338,7 @@ export class TelegramDaemon {
|
|
|
250
338
|
const trimmed = text.trim();
|
|
251
339
|
if (trimmed === "/subagents") {
|
|
252
340
|
const sessions = [...this.sessions.values()].map(c => ({ sessionId: c.sessionId, cwd: c.cwd, records: c.lastRecords }));
|
|
253
|
-
await this.notify(formatSubagentsList(sessions));
|
|
341
|
+
await this.notify(formatSubagentsList(sessions), buildSubagentsKeyboard(sessions));
|
|
254
342
|
return;
|
|
255
343
|
}
|
|
256
344
|
if (trimmed === "/help" || trimmed === "/start") {
|
|
@@ -285,14 +373,61 @@ export class TelegramDaemon {
|
|
|
285
373
|
/** Trust boundary: a bot's username is publicly discoverable, so ANY Telegram
|
|
286
374
|
* user can message it. Only the paired chat (the one that ran
|
|
287
375
|
* `jeo notify setup`) may steer/cancel subagents; everything else is dropped
|
|
288
|
-
* without a reply (replying would leak that the bot is live).
|
|
376
|
+
* without a reply (replying would leak that the bot is live). When a forum
|
|
377
|
+
* topic is configured, messages/taps from other topics are also ignored. */
|
|
289
378
|
async handleUpdate(update: TelegramUpdate): Promise<void> {
|
|
379
|
+
if (update.callback_query) {
|
|
380
|
+
await this.handleCallbackQuery(update.callback_query);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
290
383
|
const msg = update.message;
|
|
291
384
|
if (!msg?.text) return;
|
|
292
385
|
if (String(msg.chat.id) !== this.opts.chatId) return;
|
|
386
|
+
if (
|
|
387
|
+
this.opts.topicId !== undefined &&
|
|
388
|
+
msg.message_thread_id !== undefined &&
|
|
389
|
+
msg.message_thread_id !== this.opts.topicId
|
|
390
|
+
) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
293
393
|
await this.handleInboundText(msg.text);
|
|
294
394
|
}
|
|
295
395
|
|
|
396
|
+
/** Handle an inline-button tap. Same chat/topic trust boundary as text, and the
|
|
397
|
+
* tap is always acknowledged (Telegram shows a spinner until `answerCallbackQuery`). */
|
|
398
|
+
async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<void> {
|
|
399
|
+
const chatId = cb.message?.chat.id;
|
|
400
|
+
if (chatId !== undefined && String(chatId) !== this.opts.chatId) {
|
|
401
|
+
await this.answerCallback(cb.id);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (
|
|
405
|
+
this.opts.topicId !== undefined &&
|
|
406
|
+
cb.message?.message_thread_id !== undefined &&
|
|
407
|
+
cb.message.message_thread_id !== this.opts.topicId
|
|
408
|
+
) {
|
|
409
|
+
await this.answerCallback(cb.id);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const parsed = cb.data ? parseCallbackData(cb.data) : undefined;
|
|
413
|
+
if (!parsed) {
|
|
414
|
+
await this.answerCallback(cb.id);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const conn = this.findSession(parsed.shortId);
|
|
418
|
+
if (!conn) {
|
|
419
|
+
await this.answerCallback(cb.id, `No connected session matches '${parsed.shortId}'.`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const ok = await this.sendRequest(conn, { type: "cancel", ids: [parsed.subagentId] });
|
|
423
|
+
await this.answerCallback(cb.id, ok ? `Cancelled '${parsed.subagentId}'.` : "Cancel failed.");
|
|
424
|
+
await this.notify(
|
|
425
|
+
ok
|
|
426
|
+
? `⏹ Cancelled '${parsed.subagentId}' via button.`
|
|
427
|
+
: `Cancel failed — '${parsed.subagentId}' is unknown or already finished.`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
296
431
|
private async pollTelegramLoop(): Promise<void> {
|
|
297
432
|
while (!this.stopped) {
|
|
298
433
|
let res;
|
|
@@ -346,11 +481,12 @@ export async function runNotifyDaemonForeground(): Promise<void> {
|
|
|
346
481
|
const config = await readGlobalConfig();
|
|
347
482
|
const botToken = config.notifications?.telegram?.botToken;
|
|
348
483
|
const chatId = config.notifications?.telegram?.chatId;
|
|
484
|
+
const topicId = config.notifications?.telegram?.topicId;
|
|
349
485
|
if (!config.notifications?.enabled || !botToken || !chatId) {
|
|
350
486
|
process.stderr.write("[jeo notify-daemon] notifications not configured — run `jeo notify setup` first.\n");
|
|
351
487
|
return;
|
|
352
488
|
}
|
|
353
|
-
const daemon = new TelegramDaemon({ chatId, telegram: new TelegramApi(botToken) });
|
|
489
|
+
const daemon = new TelegramDaemon({ chatId, topicId, telegram: new TelegramApi(botToken) });
|
|
354
490
|
let shuttingDown = false;
|
|
355
491
|
const shutdown = () => {
|
|
356
492
|
if (shuttingDown) return;
|