jeo-code 0.12.10 → 0.12.11

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.
@@ -87,6 +87,7 @@ export class SessionNotifyEndpoint {
87
87
  private lastSnapshotJson = "";
88
88
  /** `undefined` between turns — subagent requests report an empty registry. */
89
89
  private registry: SubagentRegistry | undefined;
90
+ private redact = false;
90
91
  readonly sessionId: string;
91
92
  private readonly token: string;
92
93
  private identityFrame: string | undefined;
@@ -103,10 +104,20 @@ export class SessionNotifyEndpoint {
103
104
  * session reconnects to the SAME discovery file / Telegram topic);
104
105
  * falls back to a random id for session-less contexts. */
105
106
  sessionId: string = crypto.randomUUID(),
107
+ redact = false,
106
108
  ) {
107
109
  if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) throw new Error("Invalid notification session ID");
108
110
  this.sessionId = sessionId;
109
111
  this.token = crypto.randomUUID();
112
+ this.redact = redact;
113
+ }
114
+
115
+ /** Toggle redaction for outbound subagent snapshots in this session. */
116
+ setRedact(enabled: boolean): void {
117
+ if (this.redact === enabled) return;
118
+ this.redact = enabled;
119
+ this.lastSnapshotJson = "";
120
+ this.broadcastIfChanged();
110
121
  }
111
122
 
112
123
  /** Attach the registry backing THIS turn's subagents; `list`/`snapshot`
@@ -126,7 +137,15 @@ export class SessionNotifyEndpoint {
126
137
  }
127
138
 
128
139
  private snapshot(): { type: "snapshot"; sessionId: string; cwd: string; pid: number; subagents: SubagentRecord[] } {
129
- return { type: "snapshot", sessionId: this.sessionId, cwd: this.cwd, pid: process.pid, subagents: this.registry?.list() ?? [] };
140
+ const records = this.registry?.list() ?? [];
141
+ const subagents: SubagentRecord[] = this.redact
142
+ ? records.map(record => ({
143
+ ...record,
144
+ task: "[redacted]",
145
+ ...(record.result === undefined ? {} : { result: "[redacted]" }),
146
+ }))
147
+ : records;
148
+ return { type: "snapshot", sessionId: this.sessionId, cwd: this.cwd, pid: process.pid, subagents };
130
149
  }
131
150
 
132
151
  private broadcastIfChanged(): void {
@@ -301,7 +320,7 @@ export class SessionNotifyEndpoint {
301
320
  if (msg.type === "user_message") {
302
321
  let ok = false;
303
322
  const validPaths = msg.imagePaths === undefined || (Array.isArray(msg.imagePaths) && msg.imagePaths.every(x => typeof x === "string"));
304
- if (typeof msg.text === "string" && !msg.text.trimStart().startsWith("/") && validPaths &&
323
+ if (typeof msg.text === "string" && !msg.text.trimStart().startsWith("/") && !msg.text.trimStart().startsWith("!") && validPaths &&
305
324
  (msg.text.trim() || (msg.imagePaths as string[] | undefined)?.length) && this.onUserMessage) {
306
325
  try {
307
326
  ok = (await this.onUserMessage({ text: msg.text, imagePaths: msg.imagePaths as string[] | undefined })) !== false;
@@ -426,7 +445,7 @@ export async function startSessionNotifyEndpoint(cwd: string, sessionId: string
426
445
  try {
427
446
  const config = await readGlobalConfig();
428
447
  if (!config.notifications?.enabled) return undefined;
429
- const endpoint = new SessionNotifyEndpoint(cwd, sessionId);
448
+ const endpoint = new SessionNotifyEndpoint(cwd, sessionId, config.notifications?.redact ?? false);
430
449
  await endpoint.start();
431
450
  return endpoint;
432
451
  } catch {
@@ -456,7 +475,7 @@ export function ensureSessionNotifyEndpoint(registry: SubagentRegistry, cwd: str
456
475
  try {
457
476
  const config = await readGlobalConfig();
458
477
  if (!config.notifications?.enabled) return;
459
- const endpoint = new SessionNotifyEndpoint(cwd);
478
+ const endpoint = new SessionNotifyEndpoint(cwd, undefined, config.notifications?.redact ?? false);
460
479
  await endpoint.start();
461
480
  endpoint.attachRegistry(registry);
462
481
  endpointsByRegistry.set(registry, endpoint);
@@ -339,7 +339,7 @@ export class SlackDaemon {
339
339
  const body = command[3]!.trim();
340
340
  let local: Record<string, unknown>;
341
341
  if (command[1] === "send") {
342
- if (body.startsWith("/")) { this.emit("Remote slash commands are disabled; send ordinary prompt text.", undefined, threadTs); return; }
342
+ if (body.startsWith("/") || body.startsWith("!")) { this.emit("Remote slash and shell commands are disabled; send ordinary prompt text.", undefined, threadTs); return; }
343
343
  local = { type: "user_message", text: body };
344
344
  } else {
345
345
  const args = command[1] === "steer" ? /^(\S+)\s+([\s\S]+)$/.exec(body) : /^(\S+)$/.exec(body);
@@ -350,7 +350,7 @@ export class SlackDaemon {
350
350
  this.emit(ok ? "Session acknowledged command (accepted, not completed)." : "Command not acknowledged (unavailable, rejected or timed out).", session.sessionId, threadTs);
351
351
  return;
352
352
  }
353
- if (text.startsWith("/")) { this.emit(HELP, undefined, threadTs); return; }
353
+ if (text.startsWith("/") || text.startsWith("!")) { this.emit(HELP, undefined, threadTs); return; }
354
354
  const reply = threadTs ? this.replies.get(threadTs) : undefined;
355
355
  if (!addressed && !reply) return;
356
356
  const session = reply && (this.deps.now ?? Date.now)() - reply.at < 86_400_000 ? this.findSession(reply.sessionId) : undefined;
@@ -33,14 +33,16 @@
33
33
  import * as fs from "node:fs/promises";
34
34
  import * as os from "node:os";
35
35
  import * as path from "node:path";
36
+ import * as crypto from "node:crypto";
36
37
  import { readGlobalConfig } from "../state";
37
- import { notifySessionsDir, notifyTopicsPath } from "./paths";
38
+ import { notifySessionsDir, notifyTelegramUpdateOffsetPath, notifyTopicsPath } from "./paths";
38
39
  import { acquireDaemonLock, isPidAlive } from "./daemon-control";
39
40
  import { TelegramApi, type TelegramUpdate, type TelegramCallbackQuery, type InlineKeyboardMarkup, type InlineKeyboardButton } from "./telegram-api";
40
41
  import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
41
42
  import { RateLimitPool } from "./rate-limit-pool";
42
43
  import { parseInThreadConfigCommand } from "./config-commands";
43
44
  import { markdownToTelegramHtml, splitTelegramHtml } from "./telegram-html";
45
+ import { attachmentFromImageBytes } from "../../util/file-attachment";
44
46
  import type { SubagentRecord } from "../subagent-registry";
45
47
  import { parseSessionEndpoint } from "./session-discovery";
46
48
 
@@ -55,6 +57,10 @@ const POLL_ERROR_BACKOFF_MIN_MS = 1_000;
55
57
  * `getUpdates` long-poll owner) or transient network errors must never
56
58
  * wait longer than this between retries. */
57
59
  const POLL_ERROR_BACKOFF_MAX_MS = 10_000;
60
+ /** Retry failed outbound Bot API requests at most three times after the first
61
+ * attempt; the daemon's shared pool timer drives retries without blocking an
62
+ * accepted inbound action or replaying its side effects. */
63
+ const MAX_OUTBOUND_RETRIES = 3;
58
64
 
59
65
 
60
66
  // ── Pure helpers (unit-testable without network/ws) ────────────────────────────
@@ -86,7 +92,7 @@ export function diffSubagentTransitions(prev: SubagentRecord[], next: SubagentRe
86
92
  }
87
93
 
88
94
  /** One icon per subagent status; a "started" event renders as the running icon. */
89
- const STATUS_ICON: Record<SubagentRecord["status"], string> = { running: "▶", completed: "✅", failed: "❌", cancelled: "⏹" };
95
+ const STATUS_ICON: Record<SubagentRecord["status"], string> = { running: "▶", paused: "⏸", completed: "✅", failed: "❌", cancelled: "⏹" };
90
96
 
91
97
  function projectName(cwd: string): string {
92
98
  return cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd;
@@ -200,7 +206,7 @@ interface SessionConnection {
200
206
 
201
207
  export interface TelegramDaemonOptions {
202
208
  chatId: string;
203
- /** Verified non-secret Telegram bot ID; required for default disk topic persistence. */
209
+ /** Verified non-secret Telegram bot ID; required for default disk persistence. */
204
210
  botId?: string;
205
211
  allowedUserIds?: string[];
206
212
  telegram: TelegramApi;
@@ -222,6 +228,9 @@ export interface TelegramDaemonOptions {
222
228
  /** Injectable destination-scoped persistence for the per-session topic map —
223
229
  * default disk persistence checks both `botId` and `chatId`. */
224
230
  loadTopicState?: () => Promise<TopicRegistryState>;
231
+ /** Durable Telegram update cursor hooks, primarily for deterministic tests. */
232
+ loadUpdateOffset?: () => Promise<number | undefined>;
233
+ saveUpdateOffset?: (nextOffset: number) => Promise<void>;
225
234
  saveTopicState?: (state: TopicRegistryState) => Promise<void>;
226
235
  /** Injectable clock for `RateLimitPool` determinism in tests. */
227
236
  now?: () => number;
@@ -241,14 +250,16 @@ export interface TelegramDaemonOptions {
241
250
  /** Outbound payload the shared `RateLimitPool` schedules; `flushPool` maps each
242
251
  * variant onto the matching Bot API call. */
243
252
  type OutboundPayload =
244
- | { kind: "text"; chatId: string; text: string; topicId?: number; replyMarkup?: InlineKeyboardMarkup; html?: boolean }
245
- | { kind: "photo"; chatId: string; photo: string; topicId?: number; caption?: string; replyMarkup?: InlineKeyboardMarkup };
253
+ | { kind: "text"; chatId: string; text: string; topicId?: number; replyMarkup?: InlineKeyboardMarkup; html?: boolean; retryChunks?: string[]; retryCount?: number; onDelivered?: (delivered: boolean) => void }
254
+ | { kind: "photo"; chatId: string; photo: string; topicId?: number; caption?: string; replyMarkup?: InlineKeyboardMarkup; retryCount?: number; onDelivered?: (delivered: boolean) => void };
246
255
 
247
256
  export class TelegramDaemon {
248
257
  private readonly WS: typeof WebSocket;
249
258
  readonly sessions = new Map<string, SessionConnection>();
250
259
  private readonly pendingAcks = new Map<string, { conn: SessionConnection; resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
260
+ private readonly pendingOutbound = new Set<(delivered: boolean) => void>();
251
261
  private updateOffset: number | undefined;
262
+ private daemonStartEpochSeconds = 0;
252
263
  /** Current error-poll retry delay; starts at `POLL_ERROR_BACKOFF_MIN_MS`,
253
264
  * doubles (capped at `POLL_ERROR_BACKOFF_MAX_MS`) on each consecutive
254
265
  * non-ok/failed `getUpdates`, and resets on the next success. */
@@ -261,6 +272,8 @@ export class TelegramDaemon {
261
272
  * path never touches this. */
262
273
  private readonly topics: TopicRegistry | undefined;
263
274
  private readonly pool: RateLimitPool<OutboundPayload>;
275
+ /** Paths written by this daemon for inbound media; only these are eligible for cleanup. */
276
+ private readonly ownedAttachments = new Set<string>();
264
277
 
265
278
  constructor(private readonly opts: TelegramDaemonOptions) {
266
279
  this.WS = opts.WebSocketImpl ?? WebSocket;
@@ -271,14 +284,33 @@ export class TelegramDaemon {
271
284
  private async notify(
272
285
  text: string,
273
286
  replyMarkup?: InlineKeyboardMarkup,
274
- opts?: { topicId?: number; sessionId?: string; html?: boolean },
275
- ): Promise<void> {
287
+ opts?: { topicId?: number; sessionId?: string; html?: boolean; awaitDelivery?: boolean },
288
+ ): Promise<boolean> {
289
+ let delivery: Promise<boolean> | undefined;
290
+ let onDelivered: ((delivered: boolean) => void) | undefined;
291
+ if (opts?.awaitDelivery) {
292
+ const { promise, resolve } = Promise.withResolvers<boolean>();
293
+ let settled = false;
294
+ onDelivered = delivered => {
295
+ if (settled) return;
296
+ settled = true;
297
+ this.pendingOutbound.delete(onDelivered!);
298
+ resolve(delivered);
299
+ };
300
+ this.pendingOutbound.add(onDelivered);
301
+ delivery = promise;
302
+ }
276
303
  this.pool.submit({
277
304
  sessionId: opts?.sessionId ?? "__global__",
278
305
  lane: "finalized",
279
- payload: { kind: "text", chatId: this.opts.chatId, text, topicId: opts?.topicId ?? this.opts.topicId, replyMarkup, html: opts?.html },
306
+ payload: { kind: "text", chatId: this.opts.chatId, text, topicId: opts?.topicId ?? this.opts.topicId, replyMarkup, html: opts?.html, onDelivered },
280
307
  });
281
308
  await this.flushPool();
309
+ return delivery ? await delivery : true;
310
+ }
311
+
312
+ private async notifyReply(text: string, replyMarkup?: InlineKeyboardMarkup): Promise<boolean> {
313
+ return await this.notify(text, replyMarkup, { awaitDelivery: true });
282
314
  }
283
315
 
284
316
  private async notifyPhoto(
@@ -295,11 +327,12 @@ export class TelegramDaemon {
295
327
  await this.flushPool();
296
328
  }
297
329
 
298
- private async answerCallback(id: string, text?: string): Promise<void> {
330
+ private async answerCallback(id: string, text?: string): Promise<boolean> {
299
331
  try {
300
332
  await this.opts.telegram.answerCallbackQuery(id, text !== undefined ? { text } : {});
333
+ return true;
301
334
  } catch {
302
- // best-effort — acknowledging the tap is nice-to-have, not load-bearing.
335
+ return false;
303
336
  }
304
337
  }
305
338
 
@@ -316,26 +349,43 @@ export class TelegramDaemon {
316
349
  const granted = this.pool.drain();
317
350
  for (const item of granted) {
318
351
  const p = item.payload;
319
- try {
320
- if (p.kind === "text") {
321
- const chunks = splitTelegramHtml(p.text);
322
- for (let i = 0; i < chunks.length; i++) {
323
- await this.opts.telegram.sendMessage(p.chatId, chunks[i]!, {
352
+ let delivered = true;
353
+ let retryChunks: string[] | undefined;
354
+ if (p.kind === "text") {
355
+ const chunks = p.retryChunks ?? splitTelegramHtml(p.text);
356
+ for (let i = 0; i < chunks.length; i++) {
357
+ try {
358
+ const result = await this.opts.telegram.sendMessage(p.chatId, chunks[i]!, {
324
359
  messageThreadId: p.topicId,
325
360
  // A reply keyboard belongs on the LAST fragment a split reply ends on.
326
361
  replyMarkup: i === chunks.length - 1 ? p.replyMarkup : undefined,
327
362
  parseMode: p.html ? "HTML" : undefined,
328
363
  });
364
+ if (!result || result.ok !== true) throw new Error("Telegram sendMessage returned ok:false");
365
+ } catch {
366
+ delivered = false;
367
+ retryChunks = chunks.slice(i);
368
+ break;
329
369
  }
330
- } else {
331
- await this.opts.telegram.sendPhoto(p.chatId, p.photo, { messageThreadId: p.topicId, caption: p.caption, replyMarkup: p.replyMarkup });
332
370
  }
333
- } catch {
334
- // best-effort — a failed push must never crash the daemon loop.
371
+ } else {
372
+ try {
373
+ const result = await this.opts.telegram.sendPhoto(p.chatId, p.photo, { messageThreadId: p.topicId, caption: p.caption, replyMarkup: p.replyMarkup });
374
+ if (!result || result.ok !== true) throw new Error("Telegram sendPhoto returned ok:false");
375
+ } catch {
376
+ delivered = false;
377
+ }
378
+ }
379
+ if (!delivered && !this.stopped && (p.retryCount ?? 0) < MAX_OUTBOUND_RETRIES) {
380
+ this.pool.submit({
381
+ ...item,
382
+ payload: { ...p, retryCount: (p.retryCount ?? 0) + 1, ...(retryChunks ? { retryChunks } : {}) },
383
+ });
384
+ continue;
335
385
  }
386
+ p.onDelivered?.(delivered);
336
387
  }
337
388
  }
338
-
339
389
  async scanSessions(): Promise<void> {
340
390
  if (this.stopped) return;
341
391
  const readdir = this.opts.readdir ?? (d => fs.readdir(d));
@@ -461,12 +511,22 @@ export class TelegramDaemon {
461
511
  if (!filePath) return undefined;
462
512
  const bytes = await this.opts.telegram.downloadFile(filePath, this.opts.maxAttachmentBytes);
463
513
  if (!bytes) return undefined;
514
+ if (!attachmentFromImageBytes(bytes)) return undefined;
464
515
  const write = this.opts.writeTempFile ?? defaultWriteTempFile;
465
- return await write(bytes, suggestedName);
516
+ const writtenPath = await write(bytes, suggestedName);
517
+ this.ownedAttachments.add(writtenPath);
518
+ return writtenPath;
466
519
  } catch {
467
520
  return undefined;
468
521
  }
469
522
  }
523
+ private async cleanupAttachments(paths: readonly string[]): Promise<void> {
524
+ const unlink = this.opts.unlink ?? (p => fs.unlink(p));
525
+ for (const filePath of paths) {
526
+ if (!this.ownedAttachments.delete(filePath)) continue;
527
+ await unlink(filePath).catch(() => {});
528
+ }
529
+ }
470
530
 
471
531
  private async reactToMessage(messageId: number, emoji: string): Promise<void> {
472
532
  try {
@@ -621,40 +681,35 @@ export class TelegramDaemon {
621
681
  return promise;
622
682
  }
623
683
 
624
- async handleInboundText(text: string): Promise<void> {
684
+ private async handleInboundText(text: string): Promise<boolean> {
625
685
  const trimmed = text.trim();
626
686
  if (trimmed === "/subagents") {
627
687
  const sessions = [...this.sessions.values()].map(c => ({ sessionId: c.sessionId, cwd: c.cwd, records: c.lastRecords }));
628
- await this.notify(formatSubagentsList(sessions), buildSubagentsKeyboard(sessions));
629
- return;
630
- }
631
- if (trimmed === "/help" || trimmed === "/start") {
632
- await this.notify(HELP_TEXT);
633
- return;
688
+ return await this.notify(formatSubagentsList(sessions), buildSubagentsKeyboard(sessions), { awaitDelivery: true });
634
689
  }
690
+ if (trimmed === "/help" || trimmed === "/start") return await this.notifyReply(HELP_TEXT);
635
691
  const steer = parseSteerCommand(trimmed);
636
692
  if (steer) {
637
693
  const conn = this.findSession(steer.shortId);
638
694
  if (!conn) {
639
- await this.notify(`No connected session matches '${steer.shortId}'. Send /subagents to see live sessions.`);
640
- return;
695
+ return await this.notifyReply(`No connected session matches '${steer.shortId}'. Send /subagents to see live sessions.`);
641
696
  }
642
697
  const ok = await this.sendRequest(conn, { type: "steer", id: steer.subagentId, message: steer.message });
643
- await this.notify(ok ? `Steered '${steer.subagentId}'.` : `Steer failed — '${steer.subagentId}' is unknown or not running.`);
644
- return;
698
+ const delivered = await this.notifyReply(ok ? `Steered '${steer.subagentId}'.` : `Steer failed — '${steer.subagentId}' is unknown or not running.`);
699
+ return ok || delivered;
645
700
  }
646
701
  const cancel = parseCancelCommand(trimmed);
647
702
  if (cancel) {
648
703
  const conn = this.findSession(cancel.shortId);
649
704
  if (!conn) {
650
- await this.notify(`No connected session matches '${cancel.shortId}'. Send /subagents to see live sessions.`);
651
- return;
705
+ return await this.notifyReply(`No connected session matches '${cancel.shortId}'. Send /subagents to see live sessions.`);
652
706
  }
653
707
  const ok = await this.sendRequest(conn, { type: "cancel", ids: [cancel.subagentId] });
654
- await this.notify(ok ? `Cancelled '${cancel.subagentId}'.` : `Cancel failed — '${cancel.subagentId}' is unknown or already finished.`);
655
- return;
708
+ const delivered = await this.notifyReply(ok ? `Cancelled '${cancel.subagentId}'.` : `Cancel failed — '${cancel.subagentId}' is unknown or already finished.`);
709
+ return ok || delivered;
656
710
  }
657
- if (trimmed.startsWith("/")) await this.notify(`Unrecognized command. ${HELP_TEXT}`);
711
+ if (trimmed.startsWith("/") || trimmed.startsWith("!")) return await this.notifyReply(`Unrecognized command. ${HELP_TEXT}`);
712
+ return true;
658
713
  }
659
714
 
660
715
  /** Inbound routed to a KNOWN session's OWN forum topic (per-session-topics
@@ -665,17 +720,23 @@ export class TelegramDaemon {
665
720
  * caption) is forwarded as free-text steering, with any attached photo/
666
721
  * image-document downloaded first. A reaction confirms endpoint acceptance;
667
722
  * remote slash commands other than the presentation settings are rejected. */
668
- private async handleSessionTopicInbound(sessionId: string, msg: NonNullable<TelegramUpdate["message"]>): Promise<void> {
723
+ private async handleSessionTopicInbound(sessionId: string, msg: NonNullable<TelegramUpdate["message"]>): Promise<boolean> {
669
724
  const conn = this.sessions.get(sessionId);
670
- if (!conn?.verified) return; // disconnected or not yet authenticated by its snapshot
725
+ if (!conn?.verified) return true; // disconnected or not yet authenticated by its snapshot
671
726
  const text = msg.text ?? msg.caption ?? "";
672
727
  const hasMedia = !!(msg.photo?.length || msg.document);
673
728
  const cfg = !hasMedia ? parseInThreadConfigCommand(text) : undefined;
674
729
  if (cfg) {
675
- conn.ws.send(JSON.stringify({ type: "config_command", sessionId, ...cfg }));
676
- return;
730
+ try {
731
+ const readyState = (conn.ws as WebSocket & { readyState?: number }).readyState;
732
+ if (typeof readyState === "number" && readyState !== 1) return true;
733
+ conn.ws.send(JSON.stringify({ type: "config_command", sessionId, ...cfg }));
734
+ } catch {
735
+ // The session may disconnect between the readiness check and send; checkpoint this update and continue polling.
736
+ }
737
+ return true;
677
738
  }
678
- if (typeof text !== "string" || text.trimStart().startsWith("/")) return;
739
+ if (typeof text !== "string" || text.trimStart().startsWith("/") || text.trimStart().startsWith("!")) return true;
679
740
  const imagePaths: string[] = [];
680
741
  if (msg.photo?.length) {
681
742
  const largest = msg.photo[msg.photo.length - 1]!;
@@ -685,9 +746,15 @@ export class TelegramDaemon {
685
746
  const p = await this.downloadAttachment(msg.document.file_id, msg.document.file_name ?? `document-${msg.document.file_id}`);
686
747
  if (p) imagePaths.push(p);
687
748
  }
688
- if (!text && imagePaths.length === 0) return; // a non-image document with no caption — nothing usable to forward
689
- const ok = await this.sendRequest(conn, { type: "user_message", sessionId, text, imagePaths: imagePaths.length ? imagePaths : undefined });
749
+ if (!text && imagePaths.length === 0) return true; // a non-image document with no caption — nothing usable to forward
750
+ let ok = false;
751
+ try {
752
+ ok = await this.sendRequest(conn, { type: "user_message", sessionId, text, imagePaths: imagePaths.length ? imagePaths : undefined });
753
+ } finally {
754
+ await this.cleanupAttachments(imagePaths);
755
+ }
690
756
  if (ok) await this.reactToMessage(msg.message_id, "👀");
757
+ return ok;
691
758
  }
692
759
 
693
760
  private authorized(chat: NonNullable<TelegramUpdate["message"]>["chat"], from: TelegramCallbackQuery["from"] | undefined): boolean {
@@ -699,34 +766,25 @@ export class TelegramDaemon {
699
766
  /** Only an authorized human in the paired chat can control sessions. Groups
700
767
  * require an explicit allowlist; legacy private chats require owner identity.
701
768
  * Session topics route solely to their owner; all other input must match the
702
- * configured flat topic, including when the inbound topic is absent. */
703
- async handleUpdate(update: TelegramUpdate): Promise<void> {
704
- if (!update || typeof update !== "object") return;
705
- if (update.callback_query) {
706
- await this.handleCallbackQuery(update.callback_query);
707
- return;
708
- }
769
+ * configured flat topic, including when inbound topic is absent. */
770
+ async handleUpdate(update: TelegramUpdate): Promise<boolean> {
771
+ if (!update || typeof update !== "object") return true;
772
+ if (update.callback_query) return await this.handleCallbackQuery(update.callback_query);
709
773
  const msg = update.message;
710
- if (!msg) return;
711
- if (!this.authorized(msg.chat, msg.from)) return;
774
+ if (!msg) return true;
775
+ if (!this.authorized(msg.chat, msg.from)) return true;
712
776
 
713
777
  const ownerSessionId = this.topics && msg.message_thread_id !== undefined
714
778
  ? this.topics.sessionForTopic(msg.message_thread_id)
715
779
  : undefined;
716
780
  if (ownerSessionId && await this.pairedChatIsPrivate()) {
717
- await this.handleSessionTopicInbound(ownerSessionId, msg);
718
- return;
781
+ return await this.handleSessionTopicInbound(ownerSessionId, msg);
719
782
  }
720
783
 
721
- if (typeof msg.text !== "string" || !msg.text) return;
722
- if (this.topics && msg.message_thread_id !== undefined && msg.message_thread_id !== this.opts.topicId) return;
723
- if (
724
- this.opts.topicId !== undefined &&
725
- msg.message_thread_id !== this.opts.topicId
726
- ) {
727
- return;
728
- }
729
- await this.handleInboundText(msg.text);
784
+ if (typeof msg.text !== "string" || !msg.text) return true;
785
+ if (this.topics && msg.message_thread_id !== undefined && msg.message_thread_id !== this.opts.topicId) return true;
786
+ if (this.opts.topicId !== undefined && msg.message_thread_id !== this.opts.topicId) return true;
787
+ return await this.handleInboundText(msg.text);
730
788
  }
731
789
 
732
790
  /** Handle an inline-button tap. Same chat trust boundary as text; the topic
@@ -735,10 +793,10 @@ export class TelegramDaemon {
735
793
  * regardless of the flat `topicId`), not just the configured flat topic.
736
794
  * The tap is always acknowledged (Telegram shows a spinner until
737
795
  * `answerCallbackQuery`). */
738
- async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<void> {
796
+ async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<boolean> {
739
797
  if (!cb.message || !Number.isSafeInteger(cb.message.message_id) || !this.authorized(cb.message.chat, cb.from)) {
740
798
  await this.answerCallback(cb.id);
741
- return;
799
+ return true;
742
800
  }
743
801
  const threadId = cb.message.message_thread_id;
744
802
  const ownerSessionId = this.topics && threadId !== undefined && await this.pairedChatIsPrivate()
@@ -746,29 +804,29 @@ export class TelegramDaemon {
746
804
  if (!ownerSessionId && ((this.opts.topicId !== undefined && threadId !== this.opts.topicId) ||
747
805
  (this.topics && threadId !== undefined && threadId !== this.opts.topicId))) {
748
806
  await this.answerCallback(cb.id);
749
- return;
807
+ return true;
750
808
  }
751
809
  const parsed = typeof cb.data === "string" ? parseCallbackData(cb.data) : undefined;
752
810
  if (!parsed) {
753
811
  await this.answerCallback(cb.id);
754
- return;
812
+ return true;
755
813
  }
756
814
  const conn = this.findSession(parsed.shortId);
757
815
  if (!conn) {
758
816
  await this.answerCallback(cb.id, `No connected session matches '${parsed.shortId}'.`);
759
- return;
817
+ return true;
760
818
  }
761
819
  if (ownerSessionId && ownerSessionId !== conn.sessionId) {
762
820
  await this.answerCallback(cb.id);
763
- return;
821
+ return true;
764
822
  }
765
823
  const ok = await this.sendRequest(conn, { type: "cancel", ids: [parsed.subagentId] });
766
824
  await this.answerCallback(cb.id, ok ? `Cancelled '${parsed.subagentId}'.` : "Cancel failed.");
767
- await this.notify(
768
- ok
769
- ? `⏹ Cancelled '${parsed.subagentId}' via button.`
770
- : `Cancel failed — '${parsed.subagentId}' is unknown or already finished.`,
771
- );
825
+ const notificationDelivered = await this.notify(ok
826
+ ? `⏹ Cancelled '${parsed.subagentId}' via button.`
827
+ : `Cancel failed — '${parsed.subagentId}' is unknown or already finished.`, undefined, { awaitDelivery: true });
828
+ // Do not replay a cancel that already took effect; otherwise persist it only after terminal feedback.
829
+ return ok || notificationDelivered;
772
830
  }
773
831
 
774
832
  /** Sleeps for the current backoff delay, then grows it (capped) for the
@@ -793,6 +851,7 @@ export class TelegramDaemon {
793
851
  }
794
852
  }
795
853
 
854
+ private wasCursorLoadedWithoutOffset = false;
796
855
  private async pollTelegramLoop(): Promise<void> {
797
856
  while (!this.stopped) {
798
857
  let res;
@@ -804,30 +863,56 @@ export class TelegramDaemon {
804
863
  }
805
864
  if (this.stopped) break;
806
865
  if (!res.ok) {
807
- // e.g. 409 Conflict (another getUpdates owner) or 401 (revoked token) —
808
- // without a pause this would hot-loop against the API.
809
866
  await this.pollErrorBackoff();
810
867
  continue;
811
868
  }
812
- // A successful round-trip clears any accumulated backoff — the next
813
- // failure (if any) starts fresh from POLL_ERROR_BACKOFF_MIN_MS rather
814
- // than staying parked at whatever ceiling a prior outage reached.
815
869
  this.pollErrorBackoffMs = POLL_ERROR_BACKOFF_MIN_MS;
870
+ // Keep filtering pre-start ordinary messages across poll batches: an earlier dispatch failure can leave old queued updates behind.
871
+ // Callback queries are exempt because their attached message date is not the callback tap time.
872
+ const checkFreshness = this.wasCursorLoadedWithoutOffset;
873
+ let dispatchFailed = false;
816
874
  for (const update of res.result) {
817
875
  if (this.stopped) break;
818
- this.updateOffset = update.update_id + 1;
819
- await this.handleUpdate(update);
876
+ if (!Number.isSafeInteger(update?.update_id) || update.update_id < 0 || update.update_id >= Number.MAX_SAFE_INTEGER) {
877
+ throw new Error("Telegram update has an invalid update_id");
878
+ }
879
+ if (this.updateOffset !== undefined && update.update_id < this.updateOffset) continue;
880
+
881
+ const nextOffset = update.update_id + 1;
882
+ // A callback is a user action; its attached message date is not the tap time.
883
+ if (checkFreshness && update.message &&
884
+ (!Number.isSafeInteger(update.message.date) || update.message.date < this.daemonStartEpochSeconds)) {
885
+ const save = this.opts.saveUpdateOffset ?? (() => defaultSaveUpdateOffset(nextOffset, this.opts.botId, this.opts.chatId));
886
+ await save(nextOffset);
887
+ this.updateOffset = nextOffset;
888
+ continue;
889
+ }
890
+ if (!await this.handleUpdate(update)) {
891
+ dispatchFailed = true;
892
+ break;
893
+ }
894
+ const save = this.opts.saveUpdateOffset ?? (() => defaultSaveUpdateOffset(nextOffset, this.opts.botId, this.opts.chatId));
895
+ await save(nextOffset);
896
+ this.updateOffset = nextOffset;
820
897
  }
898
+ if (dispatchFailed) await this.pollErrorBackoff();
821
899
  }
822
900
  }
823
901
 
824
902
  /** Blocks (long-poll loop) until `stop()` is called. */
825
903
  async start(): Promise<void> {
826
904
  if (this.stopped) return;
905
+ this.daemonStartEpochSeconds = Math.floor(Date.now() / 1000);
906
+ const load = this.opts.loadUpdateOffset ?? (() => defaultLoadUpdateOffset(this.opts.botId, this.opts.chatId));
907
+ this.updateOffset = await load();
908
+ if (this.updateOffset !== undefined && (!Number.isSafeInteger(this.updateOffset) || this.updateOffset < 0)) {
909
+ throw new Error("Stored Telegram update offset is invalid");
910
+ }
911
+ this.wasCursorLoadedWithoutOffset = this.updateOffset === undefined;
827
912
  if (this.topics) {
828
- const load = this.opts.loadTopicState ?? (() => defaultLoadTopicState(this.opts.botId, this.opts.chatId));
913
+ const loadTopics = this.opts.loadTopicState ?? (() => defaultLoadTopicState(this.opts.botId, this.opts.chatId));
829
914
  try {
830
- this.topics.load(await load());
915
+ this.topics.load(await loadTopics());
831
916
  } catch {
832
917
  // best-effort — a missing/corrupt topic-state file just starts empty
833
918
  // (every session's first frame re-creates its topic from scratch).
@@ -837,24 +922,28 @@ export class TelegramDaemon {
837
922
  await this.scanSessions();
838
923
  if (this.stopped) return;
839
924
  this.scanTimer = setInterval(() => void this.scanSessions(), this.opts.scanIntervalMs ?? 3_000);
840
- // Catches anything left queued after a submit-time drain because the
841
- // burst capacity was briefly exhausted (rare at jeo's realistic message
842
- // volume, but a large simultaneous fan-out can hit it) — without this,
843
- // a queued-but-never-retried item would silently never send.
844
925
  this.poolTimer = setInterval(() => void this.flushPool(), 250);
845
- await this.pollTelegramLoop();
926
+ try {
927
+ await this.pollTelegramLoop();
928
+ } catch (error) {
929
+ this.stop();
930
+ throw error;
931
+ }
846
932
  }
847
933
 
848
934
  stop(): void {
849
935
  this.stopped = true;
850
936
  this.cancelBackoff?.();
851
937
  clearInterval(this.scanTimer);
938
+ for (const resolve of this.pendingOutbound) resolve(false);
939
+ this.pendingOutbound.clear();
852
940
  clearInterval(this.poolTimer);
853
941
  for (const pending of this.pendingAcks.values()) {
854
942
  clearTimeout(pending.timer);
855
943
  pending.resolve(false);
856
944
  }
857
945
  this.pendingAcks.clear();
946
+ void this.cleanupAttachments([...this.ownedAttachments]);
858
947
  for (const conn of this.sessions.values()) {
859
948
  try {
860
949
  conn.ws.close();
@@ -864,6 +953,51 @@ export class TelegramDaemon {
864
953
  }
865
954
  }
866
955
 
956
+ async function defaultLoadUpdateOffset(botId: string | undefined, chatId: string): Promise<number | undefined> {
957
+ if (!botId) return undefined;
958
+ if (!/^[1-9]\d*$/.test(botId) || !chatId) return undefined;
959
+ const target = notifyTelegramUpdateOffsetPath(botId, chatId);
960
+ try {
961
+ const raw = await fs.readFile(target, "utf-8");
962
+ const state = JSON.parse(raw) as { botId?: unknown; chatId?: unknown; nextOffset?: unknown };
963
+ if (state.botId !== botId || state.chatId !== chatId || !Number.isSafeInteger(state.nextOffset) ||
964
+ (state.nextOffset as number) < 0) throw new Error("Stored Telegram update cursor is corrupt or has the wrong destination");
965
+ return state.nextOffset as number;
966
+ } catch (error) {
967
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
968
+ throw error;
969
+ }
970
+ }
971
+
972
+ async function defaultSaveUpdateOffset(nextOffset: number, botId: string | undefined, chatId: string): Promise<void> {
973
+ if (!botId) return;
974
+ if (!/^[1-9]\d*$/.test(botId) || !chatId) return;
975
+ if (!Number.isSafeInteger(nextOffset) || nextOffset < 0) throw new Error("Telegram update offset is invalid");
976
+ const target = notifyTelegramUpdateOffsetPath(botId, chatId);
977
+ await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
978
+ await fs.chmod(path.dirname(target), 0o700);
979
+ const tmp = `${target}.${crypto.randomUUID()}.tmp`;
980
+ let file: fs.FileHandle | undefined;
981
+ try {
982
+ file = await fs.open(tmp, "wx", 0o600);
983
+ await file.writeFile(JSON.stringify({ botId, chatId, nextOffset }), "utf-8");
984
+ await file.sync();
985
+ await file.close();
986
+ file = undefined;
987
+ await fs.rename(tmp, target);
988
+ const directory = await fs.open(path.dirname(target), "r");
989
+ try {
990
+ await directory.sync();
991
+ } finally {
992
+ await directory.close();
993
+ }
994
+ } catch (error) {
995
+ await file?.close().catch(() => {});
996
+ await fs.unlink(tmp).catch(() => {});
997
+ throw error;
998
+ }
999
+ }
1000
+
867
1001
  /** Default inbound-attachment temp-file writer: a private (0700-parent,
868
1002
  * 0600-mode) file under the OS temp dir, named to survive filesystem
869
1003
  * weirdness (sanitized, length-capped) while staying traceable to its