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.
@@ -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,42 @@
12
8
  * discovery files and connects a loopback WebSocket to each
13
9
  * (`SessionNotifyEndpoint` on the other end);
14
10
  * - sends a Telegram message on each subagent state EDGE (started → a
15
- * terminal state), not on every poll tick;
11
+ * terminal state), not on every poll tick, attaching an inline "Cancel"
12
+ * keyboard button for the running subagent;
16
13
  * - long-polls Telegram `getUpdates` and dispatches `/subagents`,
17
14
  * `/steer <sessionId> <subagentId> <message>`, `/cancel <sessionId>
18
- * <subagentId>`, `/help` back into the matching session's WebSocket.
15
+ * <subagentId>`, `/help` — plus inline-keyboard button taps
16
+ * (`callback_query`) — back into the matching session's WebSocket.
17
+ *
18
+ * gjc-parity surface added on top of the plain-text core: forum topics (all
19
+ * pushes carry the configured `message_thread_id`, inbound is topic-filtered),
20
+ * inline keyboards (cancel buttons + `callback_query` handling), image
21
+ * attachments (a session may push a `{type:"photo"}` frame relayed via
22
+ * `sendPhoto`), a shared `RateLimitPool` (burst/steady-rate protection across
23
+ * every session sharing this one bot token), and — when
24
+ * `notifications.telegram.perSessionTopics` is enabled — a dynamic,
25
+ * PER-SESSION forum topic (via `TopicRegistry`) that mirrors that session's
26
+ * OWN activity (`identity_header`/`context_update`/`turn_stream` frames) and
27
+ * accepts free-text replies (routed back as `user_message`) and
28
+ * `/verbose`/`/lean`/`/redact` config commands, ALL scoped to that one
29
+ * session's topic. The flat/global `topicId` behavior below is UNCHANGED when
30
+ * `perSessionTopics` is off (or unset) — zero regression risk for existing
31
+ * setups.
19
32
  */
20
33
  import * as fs from "node:fs/promises";
34
+ import * as os from "node:os";
21
35
  import * as path from "node:path";
22
36
  import { readGlobalConfig } from "../state";
23
- import { notifySessionsDir } from "./paths";
37
+ import { notifySessionsDir, notifyTopicsPath } from "./paths";
24
38
  import { acquireDaemonLock, isPidAlive } from "./daemon-control";
25
- import { TelegramApi, type TelegramUpdate } from "./telegram-api";
39
+ import { TelegramApi, type TelegramUpdate, type TelegramCallbackQuery, type InlineKeyboardMarkup, type InlineKeyboardButton } from "./telegram-api";
40
+ import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
41
+ import { RateLimitPool } from "./rate-limit-pool";
42
+ import { parseInThreadConfigCommand } from "./config-commands";
43
+ import { markdownToTelegramHtml, splitTelegramHtml } from "./telegram-html";
26
44
  import type { SubagentRecord } from "../subagent-registry";
27
45
 
46
+
28
47
  function sleep(ms: number): Promise<void> {
29
48
  const { promise, resolve } = Promise.withResolvers<void>();
30
49
  setTimeout(resolve, ms);
@@ -109,7 +128,50 @@ export const HELP_TEXT =
109
128
  "/subagents — list running/recent subagents across every connected session\n" +
110
129
  "/steer <sessionId> <subagentId> <message> — send a live message into a running subagent\n" +
111
130
  "/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
112
- "/help — show this message";
131
+ "/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
132
+ "/help — show this message\n" +
133
+ "(running subagents also carry an inline ⏹ Cancel button — tap it instead of typing /cancel)\n\n" +
134
+ "Inside a session's OWN topic (when per-session topics are enabled): reply with any " +
135
+ "text to steer that session directly, or use /verbose, /lean, /verbosity lean|verbose, " +
136
+ "/redact on|off to adjust how that session mirrors its activity here.";
137
+
138
+ // ── Inline keyboards / callback data (gjc parity) ───────────────────────────────
139
+
140
+ /** Encode an inline-button cancel target. Kept short to stay under Telegram's
141
+ * 64-byte `callback_data` cap. */
142
+ export function cancelCallbackData(shortId: string, subagentId: string): string {
143
+ return `cancel:${shortId}:${subagentId}`;
144
+ }
145
+
146
+ /** Parse a `callback_data` payload produced by `cancelCallbackData`. */
147
+ export function parseCallbackData(
148
+ data: string,
149
+ ): { action: "cancel"; shortId: string; subagentId: string } | undefined {
150
+ const m = /^cancel:([^:]+):([\s\S]+)$/.exec(data);
151
+ if (!m) return undefined;
152
+ return { action: "cancel", shortId: m[1]!, subagentId: m[2]! };
153
+ }
154
+
155
+ /** One ⏹ Cancel button (its own row) targeting a single running subagent. */
156
+ function cancelButton(shortId: string, rec: SubagentRecord): InlineKeyboardButton {
157
+ return { text: `⏹ Cancel ${rec.role} '${rec.id}'`, callback_data: cancelCallbackData(shortId, rec.id) };
158
+ }
159
+
160
+ /** Inline keyboard with one ⏹ Cancel button per RUNNING subagent across every
161
+ * connected session; returns undefined when nothing is running (no keyboard to
162
+ * attach). */
163
+ export function buildSubagentsKeyboard(
164
+ sessions: Array<{ sessionId: string; records: SubagentRecord[] }>,
165
+ ): InlineKeyboardMarkup | undefined {
166
+ const rows: InlineKeyboardButton[][] = [];
167
+ for (const s of sessions) {
168
+ for (const r of s.records) {
169
+ if (r.status === "running") rows.push([cancelButton(shortSessionId(s.sessionId), r)]);
170
+ }
171
+ }
172
+ return rows.length ? { inline_keyboard: rows } : undefined;
173
+ }
174
+
113
175
 
114
176
  // ── Stateful daemon (network/ws — DI'd for tests) ───────────────────────────────
115
177
 
@@ -119,6 +181,14 @@ interface SessionConnection {
119
181
  pid: number;
120
182
  ws: WebSocket;
121
183
  lastRecords: SubagentRecord[];
184
+ /** Latest identity, once an `identity_header` frame arrives — used to name/
185
+ * rename the session's forum topic. `undefined` until then (topic still
186
+ * gets created eagerly with a provisional short-id name, gjc parity). */
187
+ identity?: { repo: string; branch?: string; cwd: string };
188
+ /** True once this session's per-session topic creation has been attempted
189
+ * and failed (e.g. Threaded Mode off) — stops a retry storm; falls back to
190
+ * no per-session mirroring for the rest of this connection's lifetime. */
191
+ topicFailed?: boolean;
122
192
  }
123
193
 
124
194
  export interface TelegramDaemonOptions {
@@ -131,8 +201,31 @@ export interface TelegramDaemonOptions {
131
201
  isPidAlive?: (pid: number) => boolean;
132
202
  scanIntervalMs?: number;
133
203
  ackTimeoutMs?: number;
204
+ /** Forum-topic thread id (message_thread_id). When set, every push is sent
205
+ * into this topic and inbound messages/taps from other topics are ignored.
206
+ * Ignored for a session with its OWN per-session topic when
207
+ * `perSessionTopics` is true. */
208
+ topicId?: number;
209
+ /** Auto-create/manage one forum topic PER SESSION instead of the single flat
210
+ * `topicId` above (see the file header doc). Off by default. */
211
+ perSessionTopics?: boolean;
212
+ /** Injectable persistence for the per-session topic map — defaults to
213
+ * reading/writing `notifyTopicsPath()` via `fs`. */
214
+ loadTopicState?: () => Promise<TopicRegistryState>;
215
+ saveTopicState?: (state: TopicRegistryState) => Promise<void>;
216
+ /** Injectable clock for `RateLimitPool` determinism in tests. */
217
+ now?: () => number;
218
+ /** Injectable temp-file writer for downloaded inbound attachments — defaults
219
+ * to a real `os.tmpdir()` write. Returns the written file's absolute path. */
220
+ writeTempFile?: (bytes: Uint8Array, suggestedName: string) => Promise<string>;
134
221
  }
135
222
 
223
+ /** Outbound payload the shared `RateLimitPool` schedules; `flushPool` maps each
224
+ * variant onto the matching Bot API call. */
225
+ type OutboundPayload =
226
+ | { kind: "text"; chatId: string; text: string; topicId?: number; replyMarkup?: InlineKeyboardMarkup; html?: boolean }
227
+ | { kind: "photo"; chatId: string; photo: string; topicId?: number; caption?: string; replyMarkup?: InlineKeyboardMarkup };
228
+
136
229
  export class TelegramDaemon {
137
230
  private readonly WS: typeof WebSocket;
138
231
  readonly sessions = new Map<string, SessionConnection>();
@@ -140,16 +233,83 @@ export class TelegramDaemon {
140
233
  private updateOffset: number | undefined;
141
234
  private stopped = false;
142
235
  private scanTimer: ReturnType<typeof setInterval> | undefined;
236
+ private poolTimer: ReturnType<typeof setInterval> | undefined;
237
+ /** `undefined` when `perSessionTopics` is off — the flat/global `topicId`
238
+ * path never touches this. */
239
+ private readonly topics: TopicRegistry | undefined;
240
+ private readonly pool: RateLimitPool<OutboundPayload>;
143
241
 
144
242
  constructor(private readonly opts: TelegramDaemonOptions) {
145
243
  this.WS = opts.WebSocketImpl ?? WebSocket;
244
+ this.topics = opts.perSessionTopics ? new TopicRegistry() : undefined;
245
+ this.pool = new RateLimitPool<OutboundPayload>({ now: opts.now });
146
246
  }
147
247
 
148
- private async notify(text: string): Promise<void> {
248
+ private async notify(
249
+ text: string,
250
+ replyMarkup?: InlineKeyboardMarkup,
251
+ opts?: { topicId?: number; sessionId?: string; html?: boolean },
252
+ ): Promise<void> {
253
+ this.pool.submit({
254
+ sessionId: opts?.sessionId ?? "__global__",
255
+ lane: "finalized",
256
+ payload: { kind: "text", chatId: this.opts.chatId, text, topicId: opts?.topicId ?? this.opts.topicId, replyMarkup, html: opts?.html },
257
+ });
258
+ await this.flushPool();
259
+ }
260
+
261
+ private async notifyPhoto(
262
+ photo: string,
263
+ caption?: string,
264
+ replyMarkup?: InlineKeyboardMarkup,
265
+ opts?: { topicId?: number; sessionId?: string },
266
+ ): Promise<void> {
267
+ this.pool.submit({
268
+ sessionId: opts?.sessionId ?? "__global__",
269
+ lane: "finalized",
270
+ payload: { kind: "photo", chatId: this.opts.chatId, photo, topicId: opts?.topicId ?? this.opts.topicId, caption, replyMarkup },
271
+ });
272
+ await this.flushPool();
273
+ }
274
+
275
+ private async answerCallback(id: string, text?: string): Promise<void> {
149
276
  try {
150
- await this.opts.telegram.sendMessage(this.opts.chatId, text);
277
+ await this.opts.telegram.answerCallbackQuery(id, text !== undefined ? { text } : {});
151
278
  } catch {
152
- // best-effort — a failed push must never crash the daemon loop.
279
+ // best-effort — acknowledging the tap is nice-to-have, not load-bearing.
280
+ }
281
+ }
282
+
283
+ /** Grant queued pool items tokens and actually dispatch them via the Bot API.
284
+ * Called after every `submit()` (drains whatever tokens are free RIGHT NOW —
285
+ * the common case, near-synchronous like the pre-pool behavior) AND by a
286
+ * 250ms timer (catches anything that queued because the burst capacity was
287
+ * briefly exhausted — e.g. a large fan-out's subagents all finishing near-
288
+ * simultaneously). `splitTelegramHtml` is applied to EVERY text send (not
289
+ * just HTML ones — it degrades to plain char-boundary splitting when no
290
+ * tags are present), so nothing this daemon sends can exceed Telegram's
291
+ * 4096-char hard limit regardless of source. */
292
+ private async flushPool(): Promise<void> {
293
+ const granted = this.pool.drain();
294
+ for (const item of granted) {
295
+ const p = item.payload;
296
+ try {
297
+ if (p.kind === "text") {
298
+ const chunks = splitTelegramHtml(p.text);
299
+ for (let i = 0; i < chunks.length; i++) {
300
+ await this.opts.telegram.sendMessage(p.chatId, chunks[i]!, {
301
+ messageThreadId: p.topicId,
302
+ // A reply keyboard belongs on the LAST fragment a split reply ends on.
303
+ replyMarkup: i === chunks.length - 1 ? p.replyMarkup : undefined,
304
+ parseMode: p.html ? "HTML" : undefined,
305
+ });
306
+ }
307
+ } else {
308
+ await this.opts.telegram.sendPhoto(p.chatId, p.photo, { messageThreadId: p.topicId, caption: p.caption, replyMarkup: p.replyMarkup });
309
+ }
310
+ } catch {
311
+ // best-effort — a failed push must never crash the daemon loop.
312
+ }
153
313
  }
154
314
  }
155
315
 
@@ -194,6 +354,97 @@ export class TelegramDaemon {
194
354
  });
195
355
  }
196
356
 
357
+ /** Resolve the topic to push INTO for `conn`: its own per-session topic
358
+ * (created on first use, cached thereafter) when `perSessionTopics` is on
359
+ * and creation hasn't already failed for this connection; the flat/global
360
+ * `topicId` otherwise (unchanged pre-Tier-2 behavior). Never throws — a
361
+ * creation failure (e.g. Threaded Mode off in @BotFather) is remembered on
362
+ * `conn.topicFailed` so it is not retried every single frame. */
363
+ private async resolveTopicId(conn: SessionConnection): Promise<number | undefined> {
364
+ if (!this.opts.perSessionTopics || !this.topics || conn.topicFailed) return this.opts.topicId;
365
+ // Fail-closed privacy gate (contract requirement): per-session topics carry
366
+ // that session's OWN activity mirroring + accept free-text steering — both
367
+ // are only safe in a confirmed 1:1 private DM. A group/channel `chatId`
368
+ // (e.g. misconfigured `jeo notify setup`) must NEVER get per-session
369
+ // topics/routing; it silently falls back to the flat/global `topicId`
370
+ // instead (still fully functional for subagent-status pushes, just without
371
+ // the per-session surface). Checked once per daemon lifetime and cached —
372
+ // chat type cannot change without re-running `jeo notify setup` anyway.
373
+ if (!(await this.pairedChatIsPrivate())) return this.opts.topicId;
374
+ const provisional = conn.identity
375
+ ? `${conn.identity.repo}${conn.identity.branch ? `@${conn.identity.branch}` : ""}`
376
+ : `session ${shortSessionId(conn.sessionId)}`;
377
+ try {
378
+ const record = await this.topics.getOrCreateTopic(
379
+ conn.sessionId,
380
+ async () => {
381
+ const res = await this.opts.telegram.createForumTopic(this.opts.chatId, provisional);
382
+ const tid = res.result?.message_thread_id;
383
+ if (tid === undefined) throw new Error("createForumTopic: no message_thread_id");
384
+ return tid;
385
+ },
386
+ undefined,
387
+ provisional,
388
+ );
389
+ void this.persistTopics();
390
+ return record.topicId;
391
+ } catch {
392
+ conn.topicFailed = true;
393
+ return this.opts.topicId;
394
+ }
395
+ }
396
+
397
+ /** Cached (undefined = not yet checked) result of `getChat`'s `type ===
398
+ * "private"` — a `getChat` failure fails CLOSED (treated as not-private,
399
+ * never retried, matching `resolveTopicId`'s own no-retry-storm posture). */
400
+ private pairedChatPrivate: boolean | undefined;
401
+ private async pairedChatIsPrivate(): Promise<boolean> {
402
+ if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
403
+ try {
404
+ const res = await this.opts.telegram.getChat(this.opts.chatId);
405
+ this.pairedChatPrivate = res.result?.type === "private";
406
+ } catch {
407
+ this.pairedChatPrivate = false;
408
+ }
409
+ return this.pairedChatPrivate;
410
+ }
411
+
412
+ private async persistTopics(): Promise<void> {
413
+ if (!this.topics) return;
414
+ const save = this.opts.saveTopicState ?? defaultSaveTopicState;
415
+ try {
416
+ await save(this.topics.serialize());
417
+ } catch {
418
+ // best-effort — a failed persist just re-creates topics after a restart.
419
+ }
420
+ }
421
+
422
+ /** Download a Telegram-hosted file into a local temp file for relay into a
423
+ * session's `[image #N]` attachment pipeline (`attachImagePaths`, see
424
+ * `src/util/file-attachment.ts`). Returns `undefined` on any failure — the
425
+ * message still delivers as text-only rather than being dropped entirely. */
426
+ private async downloadAttachment(fileId: string, suggestedName: string): Promise<string | undefined> {
427
+ try {
428
+ const got = await this.opts.telegram.getFile(fileId);
429
+ const filePath = got.result?.file_path;
430
+ if (!filePath) return undefined;
431
+ const bytes = await this.opts.telegram.downloadFile(filePath);
432
+ if (!bytes) return undefined;
433
+ const write = this.opts.writeTempFile ?? defaultWriteTempFile;
434
+ return await write(bytes, suggestedName);
435
+ } catch {
436
+ return undefined;
437
+ }
438
+ }
439
+
440
+ private async reactToMessage(messageId: number, emoji: string): Promise<void> {
441
+ try {
442
+ await this.opts.telegram.setMessageReaction(this.opts.chatId, messageId, emoji);
443
+ } catch {
444
+ // best-effort — a visible delivery confirmation, not load-bearing.
445
+ }
446
+ }
447
+
197
448
  async handleSessionMessage(conn: SessionConnection, raw: string): Promise<void> {
198
449
  let msg: Record<string, unknown>;
199
450
  try {
@@ -205,17 +456,82 @@ export class TelegramDaemon {
205
456
  const next = msg.subagents as SubagentRecord[];
206
457
  const events = diffSubagentTransitions(conn.lastRecords, next);
207
458
  conn.lastRecords = next;
208
- for (const ev of events) {
209
- await this.notify(formatNotifyEvent(shortSessionId(conn.sessionId), conn.cwd, ev));
459
+ const shortId = shortSessionId(conn.sessionId);
460
+ if (events.length > 0) {
461
+ const topicId = await this.resolveTopicId(conn);
462
+ for (const ev of events) {
463
+ const keyboard =
464
+ ev.kind === "started" ? { inline_keyboard: [[cancelButton(shortId, ev.record)]] } : undefined;
465
+ await this.notify(formatNotifyEvent(shortId, conn.cwd, ev), keyboard, { topicId, sessionId: conn.sessionId });
466
+ }
210
467
  }
211
468
  return;
212
469
  }
470
+ if (msg.type === "photo" && typeof msg.url === "string") {
471
+ const replyMarkup =
472
+ msg.replyMarkup &&
473
+ typeof msg.replyMarkup === "object" &&
474
+ "inline_keyboard" in (msg.replyMarkup as object)
475
+ ? (msg.replyMarkup as InlineKeyboardMarkup)
476
+ : undefined;
477
+ const topicId = await this.resolveTopicId(conn);
478
+ await this.notifyPhoto(
479
+ msg.url,
480
+ typeof msg.caption === "string" ? msg.caption : undefined,
481
+ replyMarkup,
482
+ { topicId, sessionId: conn.sessionId },
483
+ );
484
+ return;
485
+ }
213
486
  if (msg.type === "ack" && typeof msg.reqId === "string") {
214
487
  const resolve = this.pendingAcks.get(msg.reqId);
215
488
  if (resolve) {
216
489
  resolve(Boolean(msg.ok));
217
490
  this.pendingAcks.delete(msg.reqId);
218
491
  }
492
+ return;
493
+ }
494
+ // ── Tier 2: main-session mirroring frames (only meaningful/acted-on when
495
+ // `perSessionTopics` is enabled — `resolveTopicId` degrades to the
496
+ // flat/global topicId otherwise, so these still deliver, just without
497
+ // per-session grouping). ──────────────────────────────────────────────
498
+ if (msg.type === "identity_header") {
499
+ conn.identity = {
500
+ repo: typeof msg.repo === "string" && msg.repo ? msg.repo : projectName(conn.cwd),
501
+ branch: typeof msg.branch === "string" ? msg.branch : undefined,
502
+ cwd: typeof msg.cwd === "string" ? msg.cwd : conn.cwd,
503
+ };
504
+ const topicId = await this.resolveTopicId(conn);
505
+ if (topicId !== undefined && this.topics) {
506
+ const name = `${conn.identity.repo}${conn.identity.branch ? `@${conn.identity.branch}` : ""}`;
507
+ // `getOrCreateTopic` only names a topic on FIRST creation — a later,
508
+ // more-informative identity (the provisional short-id name gets
509
+ // superseded once the real repo/branch is known) needs an explicit
510
+ // rename, applied only when the name actually changed (dedup).
511
+ if (this.topics.applyName(conn.sessionId, name)) {
512
+ try {
513
+ await this.opts.telegram.editForumTopic(this.opts.chatId, topicId, name);
514
+ void this.persistTopics();
515
+ } catch {
516
+ // best-effort — a failed rename just leaves the provisional name.
517
+ }
518
+ }
519
+ }
520
+ return;
521
+ }
522
+ if (msg.type === "context_update" && typeof msg.phase === "string" && typeof msg.summary === "string") {
523
+ const topicId = await this.resolveTopicId(conn);
524
+ const icon = msg.phase === "turn_start" ? "▶" : "■";
525
+ const label = msg.phase === "turn_start" ? "Turn started" : "Turn finished";
526
+ const modelSuffix = typeof msg.model === "string" && msg.model ? ` (${msg.model})` : "";
527
+ const usageLine = typeof msg.usage === "string" && msg.usage ? `\n${msg.usage}` : "";
528
+ await this.notify(`${icon} ${label}${modelSuffix}\n${msg.summary}${usageLine}`, undefined, { topicId, sessionId: conn.sessionId });
529
+ return;
530
+ }
531
+ if (msg.type === "turn_stream" && typeof msg.text === "string") {
532
+ const topicId = await this.resolveTopicId(conn);
533
+ await this.notify(markdownToTelegramHtml(msg.text), undefined, { topicId, sessionId: conn.sessionId, html: true });
534
+ return;
219
535
  }
220
536
  }
221
537
 
@@ -250,7 +566,7 @@ export class TelegramDaemon {
250
566
  const trimmed = text.trim();
251
567
  if (trimmed === "/subagents") {
252
568
  const sessions = [...this.sessions.values()].map(c => ({ sessionId: c.sessionId, cwd: c.cwd, records: c.lastRecords }));
253
- await this.notify(formatSubagentsList(sessions));
569
+ await this.notify(formatSubagentsList(sessions), buildSubagentsKeyboard(sessions));
254
570
  return;
255
571
  }
256
572
  if (trimmed === "/help" || trimmed === "/start") {
@@ -282,17 +598,118 @@ export class TelegramDaemon {
282
598
  if (trimmed.startsWith("/")) await this.notify(`Unrecognized command. ${HELP_TEXT}`);
283
599
  }
284
600
 
601
+ /** Inbound routed to a KNOWN session's OWN forum topic (per-session-topics
602
+ * mode only — `handleUpdate` is the only caller, and only once
603
+ * `this.topics.sessionForTopic(...)` has already confirmed ownership).
604
+ * A config command (`/verbose`, `/lean`, `/redact on|off`) is applied to
605
+ * THAT session only; anything else (including a media-only message with no
606
+ * caption) is forwarded as free-text steering, with any attached photo/
607
+ * image-document downloaded first. A 👀 reaction confirms the daemon
608
+ * actually routed the message (simplified single-stage delivery
609
+ * confirmation — gjc's own two-stage queued→consumed reaction protocol
610
+ * requires a round-trip ack frame this version does not add). */
611
+ private async handleSessionTopicInbound(sessionId: string, msg: NonNullable<TelegramUpdate["message"]>): Promise<void> {
612
+ const conn = this.sessions.get(sessionId);
613
+ if (!conn) return; // the owning session has disconnected; the topic record just lingers
614
+ const text = msg.text ?? msg.caption ?? "";
615
+ const hasMedia = !!(msg.photo?.length || msg.document);
616
+ const cfg = !hasMedia ? parseInThreadConfigCommand(text) : undefined;
617
+ if (cfg) {
618
+ conn.ws.send(JSON.stringify({ type: "config_command", sessionId, ...cfg }));
619
+ return;
620
+ }
621
+ const imagePaths: string[] = [];
622
+ if (msg.photo?.length) {
623
+ const largest = msg.photo[msg.photo.length - 1]!;
624
+ const p = await this.downloadAttachment(largest.file_id, `photo-${largest.file_id}.jpg`);
625
+ if (p) imagePaths.push(p);
626
+ } else if (msg.document && (msg.document.mime_type ?? "").startsWith("image/")) {
627
+ const p = await this.downloadAttachment(msg.document.file_id, msg.document.file_name ?? `document-${msg.document.file_id}`);
628
+ if (p) imagePaths.push(p);
629
+ }
630
+ if (!text && imagePaths.length === 0) return; // a non-image document with no caption — nothing usable to forward
631
+ conn.ws.send(JSON.stringify({ type: "user_message", sessionId, text, imagePaths: imagePaths.length ? imagePaths : undefined }));
632
+ await this.reactToMessage(msg.message_id, "👀");
633
+ }
634
+
285
635
  /** Trust boundary: a bot's username is publicly discoverable, so ANY Telegram
286
636
  * user can message it. Only the paired chat (the one that ran
287
- * `jeo notify setup`) may steer/cancel subagents; everything else is dropped
288
- * without a reply (replying would leak that the bot is live). */
637
+ * `jeo notify setup`) may steer/cancel subagents or steer a session;
638
+ * everything else is dropped without a reply (replying would leak that the
639
+ * bot is live). A message in a KNOWN session's own topic (per-session-
640
+ * topics mode) routes directly to that session — bypassing the flat
641
+ * `topicId` gate, which still governs everything else (global commands in
642
+ * the General topic or the configured flat topic; other topics ignored). */
289
643
  async handleUpdate(update: TelegramUpdate): Promise<void> {
644
+ if (update.callback_query) {
645
+ await this.handleCallbackQuery(update.callback_query);
646
+ return;
647
+ }
290
648
  const msg = update.message;
291
- if (!msg?.text) return;
649
+ if (!msg) return;
292
650
  if (String(msg.chat.id) !== this.opts.chatId) return;
651
+
652
+ const ownerSessionId = this.topics && msg.message_thread_id !== undefined
653
+ ? this.topics.sessionForTopic(msg.message_thread_id)
654
+ : undefined;
655
+ if (ownerSessionId) {
656
+ await this.handleSessionTopicInbound(ownerSessionId, msg);
657
+ return;
658
+ }
659
+
660
+ if (!msg.text) return;
661
+ if (
662
+ this.opts.topicId !== undefined &&
663
+ msg.message_thread_id !== undefined &&
664
+ msg.message_thread_id !== this.opts.topicId
665
+ ) {
666
+ return;
667
+ }
293
668
  await this.handleInboundText(msg.text);
294
669
  }
295
670
 
671
+ /** Handle an inline-button tap. Same chat trust boundary as text; the topic
672
+ * gate additionally allows a tap from within any KNOWN session's own topic
673
+ * (its cancel buttons target that session's own subagents — legitimate
674
+ * regardless of the flat `topicId`), not just the configured flat topic.
675
+ * The tap is always acknowledged (Telegram shows a spinner until
676
+ * `answerCallbackQuery`). */
677
+ async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<void> {
678
+ const chatId = cb.message?.chat.id;
679
+ if (chatId !== undefined && String(chatId) !== this.opts.chatId) {
680
+ await this.answerCallback(cb.id);
681
+ return;
682
+ }
683
+ const threadId = cb.message?.message_thread_id;
684
+ const inKnownSessionTopic = !!this.topics && threadId !== undefined && this.topics.sessionForTopic(threadId) !== undefined;
685
+ if (
686
+ this.opts.topicId !== undefined &&
687
+ threadId !== undefined &&
688
+ threadId !== this.opts.topicId &&
689
+ !inKnownSessionTopic
690
+ ) {
691
+ await this.answerCallback(cb.id);
692
+ return;
693
+ }
694
+ const parsed = cb.data ? parseCallbackData(cb.data) : undefined;
695
+ if (!parsed) {
696
+ await this.answerCallback(cb.id);
697
+ return;
698
+ }
699
+ const conn = this.findSession(parsed.shortId);
700
+ if (!conn) {
701
+ await this.answerCallback(cb.id, `No connected session matches '${parsed.shortId}'.`);
702
+ return;
703
+ }
704
+ const ok = await this.sendRequest(conn, { type: "cancel", ids: [parsed.subagentId] });
705
+ await this.answerCallback(cb.id, ok ? `Cancelled '${parsed.subagentId}'.` : "Cancel failed.");
706
+ await this.notify(
707
+ ok
708
+ ? `⏹ Cancelled '${parsed.subagentId}' via button.`
709
+ : `Cancel failed — '${parsed.subagentId}' is unknown or already finished.`,
710
+ );
711
+ }
712
+
296
713
  private async pollTelegramLoop(): Promise<void> {
297
714
  while (!this.stopped) {
298
715
  let res;
@@ -317,14 +734,29 @@ export class TelegramDaemon {
317
734
 
318
735
  /** Blocks (long-poll loop) until `stop()` is called. */
319
736
  async start(): Promise<void> {
737
+ if (this.topics) {
738
+ const load = this.opts.loadTopicState ?? defaultLoadTopicState;
739
+ try {
740
+ this.topics.load(await load());
741
+ } catch {
742
+ // best-effort — a missing/corrupt topic-state file just starts empty
743
+ // (every session's first frame re-creates its topic from scratch).
744
+ }
745
+ }
320
746
  await this.scanSessions();
321
747
  this.scanTimer = setInterval(() => void this.scanSessions(), this.opts.scanIntervalMs ?? 3_000);
748
+ // Catches anything left queued after a submit-time drain because the
749
+ // burst capacity was briefly exhausted (rare at jeo's realistic message
750
+ // volume, but a large simultaneous fan-out can hit it) — without this,
751
+ // a queued-but-never-retried item would silently never send.
752
+ this.poolTimer = setInterval(() => void this.flushPool(), 250);
322
753
  await this.pollTelegramLoop();
323
754
  }
324
755
 
325
756
  stop(): void {
326
757
  this.stopped = true;
327
- if (this.scanTimer) clearInterval(this.scanTimer);
758
+ clearInterval(this.scanTimer);
759
+ clearInterval(this.poolTimer);
328
760
  for (const conn of this.sessions.values()) {
329
761
  try {
330
762
  conn.ws.close();
@@ -334,6 +766,41 @@ export class TelegramDaemon {
334
766
  }
335
767
  }
336
768
 
769
+ /** Default inbound-attachment temp-file writer: a private (0700-parent,
770
+ * default-mode) file under the OS temp dir, named to survive filesystem
771
+ * weirdness (sanitized, length-capped) while staying traceable to its
772
+ * Telegram origin for debugging. */
773
+ async function defaultWriteTempFile(bytes: Uint8Array, suggestedName: string): Promise<string> {
774
+ const safe = (suggestedName.replace(/[^\w.-]+/g, "_").slice(-128) || "file");
775
+ const target = path.join(os.tmpdir(), `jeo-telegram-${Date.now()}-${crypto.randomUUID().slice(0, 8)}-${safe}`);
776
+ await fs.writeFile(target, bytes);
777
+ return target;
778
+ }
779
+
780
+ async function defaultLoadTopicState(): Promise<TopicRegistryState> {
781
+ try {
782
+ const raw = await fs.readFile(notifyTopicsPath(), "utf-8");
783
+ return JSON.parse(raw) as TopicRegistryState;
784
+ } catch {
785
+ return { topics: {} };
786
+ }
787
+ }
788
+
789
+ /** Atomic temp+rename write (same convention as `state.ts`'s config/workflow
790
+ * persistence) — a torn write must never corrupt the topic map. */
791
+ async function defaultSaveTopicState(state: TopicRegistryState): Promise<void> {
792
+ const target = notifyTopicsPath();
793
+ await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
794
+ const tmp = `${target}.${Math.random().toString(36).slice(2)}.tmp`;
795
+ try {
796
+ await fs.writeFile(tmp, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 0o600 });
797
+ await fs.rename(tmp, target);
798
+ } catch (err) {
799
+ await fs.unlink(tmp).catch(() => {});
800
+ throw err;
801
+ }
802
+ }
803
+
337
804
  // ── CLI entrypoint (`jeo notify-daemon-run`, internal) ──────────────────────────
338
805
 
339
806
  export async function runNotifyDaemonForeground(): Promise<void> {
@@ -346,11 +813,13 @@ export async function runNotifyDaemonForeground(): Promise<void> {
346
813
  const config = await readGlobalConfig();
347
814
  const botToken = config.notifications?.telegram?.botToken;
348
815
  const chatId = config.notifications?.telegram?.chatId;
816
+ const topicId = config.notifications?.telegram?.topicId;
817
+ const perSessionTopics = config.notifications?.telegram?.perSessionTopics;
349
818
  if (!config.notifications?.enabled || !botToken || !chatId) {
350
819
  process.stderr.write("[jeo notify-daemon] notifications not configured — run `jeo notify setup` first.\n");
351
820
  return;
352
821
  }
353
- const daemon = new TelegramDaemon({ chatId, telegram: new TelegramApi(botToken) });
822
+ const daemon = new TelegramDaemon({ chatId, topicId, perSessionTopics, telegram: new TelegramApi(botToken) });
354
823
  let shuttingDown = false;
355
824
  const shutdown = () => {
356
825
  if (shuttingDown) return;