jeo-code 0.11.2 → 0.11.3

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.
@@ -42,6 +42,7 @@ import { RateLimitPool } from "./rate-limit-pool";
42
42
  import { parseInThreadConfigCommand } from "./config-commands";
43
43
  import { markdownToTelegramHtml, splitTelegramHtml } from "./telegram-html";
44
44
  import type { SubagentRecord } from "../subagent-registry";
45
+ import { parseSessionEndpoint } from "./session-discovery";
45
46
 
46
47
 
47
48
  /** Initial (and post-success-reset) delay before retrying a failed/non-ok
@@ -55,11 +56,6 @@ const POLL_ERROR_BACKOFF_MIN_MS = 1_000;
55
56
  * wait longer than this between retries. */
56
57
  const POLL_ERROR_BACKOFF_MAX_MS = 10_000;
57
58
 
58
- function sleep(ms: number): Promise<void> {
59
- const { promise, resolve } = Promise.withResolvers<void>();
60
- setTimeout(resolve, ms);
61
- return promise;
62
- }
63
59
 
64
60
  // ── Pure helpers (unit-testable without network/ws) ────────────────────────────
65
61
 
@@ -139,7 +135,6 @@ export const HELP_TEXT =
139
135
  "/subagents — list running/recent subagents across every connected session\n" +
140
136
  "/steer <sessionId> <subagentId> <message> — send a live message into a running subagent\n" +
141
137
  "/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
142
- "/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
143
138
  "/help — show this message\n" +
144
139
  "(running subagents also carry an inline ⏹ Cancel button — tap it instead of typing /cancel)\n\n" +
145
140
  "Inside a session's OWN topic (when per-session topics are enabled): reply with any " +
@@ -192,6 +187,7 @@ interface SessionConnection {
192
187
  pid: number;
193
188
  ws: WebSocket;
194
189
  lastRecords: SubagentRecord[];
190
+ verified?: boolean;
195
191
  /** Latest identity, once an `identity_header` frame arrives — used to name/
196
192
  * rename the session's forum topic. `undefined` until then (topic still
197
193
  * gets created eagerly with a provisional short-id name, gjc parity). */
@@ -204,6 +200,9 @@ interface SessionConnection {
204
200
 
205
201
  export interface TelegramDaemonOptions {
206
202
  chatId: string;
203
+ /** Verified non-secret Telegram bot ID; required for default disk topic persistence. */
204
+ botId?: string;
205
+ allowedUserIds?: string[];
207
206
  telegram: TelegramApi;
208
207
  WebSocketImpl?: typeof WebSocket;
209
208
  readdir?: (dir: string) => Promise<string[]>;
@@ -220,8 +219,8 @@ export interface TelegramDaemonOptions {
220
219
  /** Auto-create/manage one forum topic PER SESSION instead of the single flat
221
220
  * `topicId` above (see the file header doc). Off by default. */
222
221
  perSessionTopics?: boolean;
223
- /** Injectable persistence for the per-session topic map — defaults to
224
- * reading/writing `notifyTopicsPath()` via `fs`. */
222
+ /** Injectable destination-scoped persistence for the per-session topic map —
223
+ * default disk persistence checks both `botId` and `chatId`. */
225
224
  loadTopicState?: () => Promise<TopicRegistryState>;
226
225
  saveTopicState?: (state: TopicRegistryState) => Promise<void>;
227
226
  /** Injectable clock for `RateLimitPool` determinism in tests. */
@@ -248,13 +247,14 @@ type OutboundPayload =
248
247
  export class TelegramDaemon {
249
248
  private readonly WS: typeof WebSocket;
250
249
  readonly sessions = new Map<string, SessionConnection>();
251
- private readonly pendingAcks = new Map<string, (ok: boolean) => void>();
250
+ private readonly pendingAcks = new Map<string, { conn: SessionConnection; resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
252
251
  private updateOffset: number | undefined;
253
252
  /** Current error-poll retry delay; starts at `POLL_ERROR_BACKOFF_MIN_MS`,
254
253
  * doubles (capped at `POLL_ERROR_BACKOFF_MAX_MS`) on each consecutive
255
254
  * non-ok/failed `getUpdates`, and resets on the next success. */
256
255
  private pollErrorBackoffMs = POLL_ERROR_BACKOFF_MIN_MS;
257
256
  private stopped = false;
257
+ private cancelBackoff: (() => void) | undefined;
258
258
  private scanTimer: ReturnType<typeof setInterval> | undefined;
259
259
  private poolTimer: ReturnType<typeof setInterval> | undefined;
260
260
  /** `undefined` when `perSessionTopics` is off — the flat/global `topicId`
@@ -337,6 +337,7 @@ export class TelegramDaemon {
337
337
  }
338
338
 
339
339
  async scanSessions(): Promise<void> {
340
+ if (this.stopped) return;
340
341
  const readdir = this.opts.readdir ?? (d => fs.readdir(d));
341
342
  let files: string[];
342
343
  try {
@@ -347,13 +348,16 @@ export class TelegramDaemon {
347
348
  const readFile = this.opts.readFile ?? (p => fs.readFile(p, "utf-8"));
348
349
  const unlink = this.opts.unlink ?? (p => fs.unlink(p));
349
350
  const pidAlive = this.opts.isPidAlive ?? isPidAlive;
350
- for (const file of files.filter(f => f.endsWith(".json"))) {
351
+ for (const file of files.filter(f => /^[A-Za-z0-9_-]+\.json$/.test(f))) {
352
+ if (this.stopped) return;
351
353
  const sessionId = path.basename(file, ".json");
352
354
  if (this.sessions.has(sessionId)) continue;
353
355
  const filePath = path.join(notifySessionsDir(), file);
354
356
  try {
355
357
  const raw = await readFile(filePath);
356
- const endpoint = JSON.parse(raw) as { url: string; token: string; pid: number; cwd: string };
358
+ if (this.stopped) return;
359
+ const endpoint = parseSessionEndpoint(raw);
360
+ if (!endpoint) continue;
357
361
  if (!pidAlive(endpoint.pid)) {
358
362
  await unlink(filePath).catch(() => {});
359
363
  continue;
@@ -393,7 +397,7 @@ export class TelegramDaemon {
393
397
  // instead (still fully functional for subagent-status pushes, just without
394
398
  // the per-session surface). Checked once per daemon lifetime and cached —
395
399
  // chat type cannot change without re-running `jeo notify setup` anyway.
396
- if (!(await this.pairedChatIsPrivate())) return this.opts.topicId;
400
+ if (!(await this.pairedChatIsPrivate()) || this.stopped) return this.opts.topicId;
397
401
  const provisional = conn.identity
398
402
  ? `${conn.identity.repo}${conn.identity.branch ? `@${conn.identity.branch}` : ""}`
399
403
  : `session ${shortSessionId(conn.sessionId)}`;
@@ -434,7 +438,7 @@ export class TelegramDaemon {
434
438
 
435
439
  private async persistTopics(): Promise<void> {
436
440
  if (!this.topics) return;
437
- const save = this.opts.saveTopicState ?? defaultSaveTopicState;
441
+ const save = this.opts.saveTopicState ?? (state => defaultSaveTopicState(state, this.opts.botId, this.opts.chatId));
438
442
  try {
439
443
  await save(this.topics.serialize());
440
444
  } catch {
@@ -473,12 +477,24 @@ export class TelegramDaemon {
473
477
  }
474
478
 
475
479
  async handleSessionMessage(conn: SessionConnection, raw: string): Promise<void> {
480
+ if (this.stopped) return;
481
+ if (Buffer.byteLength(raw) > 1024 * 1024) return;
476
482
  let msg: Record<string, unknown>;
477
483
  try {
478
484
  msg = JSON.parse(raw);
479
485
  } catch {
480
486
  return;
481
487
  }
488
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
489
+ if (msg.type === "snapshot") {
490
+ if (msg.sessionId !== conn.sessionId || msg.pid !== conn.pid || !Array.isArray(msg.subagents)) {
491
+ conn.verified = false;
492
+ conn.ws.close();
493
+ return;
494
+ }
495
+ conn.verified = true;
496
+ }
497
+ if (!conn.verified || (msg.sessionId !== undefined && msg.sessionId !== conn.sessionId)) return;
482
498
  if (msg.type === "snapshot" && Array.isArray(msg.subagents)) {
483
499
  const next = msg.subagents as SubagentRecord[];
484
500
  const events = diffSubagentTransitions(conn.lastRecords, next);
@@ -511,9 +527,10 @@ export class TelegramDaemon {
511
527
  return;
512
528
  }
513
529
  if (msg.type === "ack" && typeof msg.reqId === "string") {
514
- const resolve = this.pendingAcks.get(msg.reqId);
515
- if (resolve) {
516
- resolve(Boolean(msg.ok));
530
+ const pending = this.pendingAcks.get(msg.reqId);
531
+ if (pending?.conn === conn) {
532
+ clearTimeout(pending.timer);
533
+ pending.resolve(msg.ok === true);
517
534
  this.pendingAcks.delete(msg.reqId);
518
535
  }
519
536
  return;
@@ -572,25 +589,32 @@ export class TelegramDaemon {
572
589
 
573
590
  private findSession(shortId: string): SessionConnection | undefined {
574
591
  const needle = shortId.toLowerCase();
592
+ if (!needle) return undefined;
593
+ let match: SessionConnection | undefined;
575
594
  for (const conn of this.sessions.values()) {
576
- if (shortSessionId(conn.sessionId) === needle || conn.sessionId.toLowerCase().startsWith(needle)) return conn;
595
+ if (shortSessionId(conn.sessionId).toLowerCase() === needle || conn.sessionId.toLowerCase().startsWith(needle)) {
596
+ if (match) return undefined;
597
+ match = conn;
598
+ }
577
599
  }
578
- return undefined;
600
+ return match;
579
601
  }
580
602
 
581
603
  private sendRequest(conn: SessionConnection, frame: Record<string, unknown>): Promise<boolean> {
604
+ if (this.stopped || !conn.verified) return Promise.resolve(false);
582
605
  const reqId = crypto.randomUUID();
583
606
  const { promise, resolve } = Promise.withResolvers<boolean>();
584
- this.pendingAcks.set(reqId, resolve);
585
- setTimeout(() => {
607
+ const timer = setTimeout(() => {
586
608
  if (this.pendingAcks.has(reqId)) {
587
609
  this.pendingAcks.delete(reqId);
588
610
  resolve(false);
589
611
  }
590
612
  }, this.opts.ackTimeoutMs ?? 3_000);
613
+ this.pendingAcks.set(reqId, { conn, resolve, timer });
591
614
  try {
592
615
  conn.ws.send(JSON.stringify({ ...frame, reqId }));
593
616
  } catch {
617
+ clearTimeout(timer);
594
618
  this.pendingAcks.delete(reqId);
595
619
  resolve(false);
596
620
  }
@@ -639,13 +663,11 @@ export class TelegramDaemon {
639
663
  * A config command (`/verbose`, `/lean`, `/redact on|off`) is applied to
640
664
  * THAT session only; anything else (including a media-only message with no
641
665
  * caption) is forwarded as free-text steering, with any attached photo/
642
- * image-document downloaded first. A 👀 reaction confirms the daemon
643
- * actually routed the message (simplified single-stage delivery
644
- * confirmation — gjc's own two-stage queued→consumed reaction protocol
645
- * requires a round-trip ack frame this version does not add). */
666
+ * image-document downloaded first. A reaction confirms endpoint acceptance;
667
+ * remote slash commands other than the presentation settings are rejected. */
646
668
  private async handleSessionTopicInbound(sessionId: string, msg: NonNullable<TelegramUpdate["message"]>): Promise<void> {
647
669
  const conn = this.sessions.get(sessionId);
648
- if (!conn) return; // the owning session has disconnected; the topic record just lingers
670
+ if (!conn?.verified) return; // disconnected or not yet authenticated by its snapshot
649
671
  const text = msg.text ?? msg.caption ?? "";
650
672
  const hasMedia = !!(msg.photo?.length || msg.document);
651
673
  const cfg = !hasMedia ? parseInThreadConfigCommand(text) : undefined;
@@ -653,6 +675,7 @@ export class TelegramDaemon {
653
675
  conn.ws.send(JSON.stringify({ type: "config_command", sessionId, ...cfg }));
654
676
  return;
655
677
  }
678
+ if (typeof text !== "string" || text.trimStart().startsWith("/")) return;
656
679
  const imagePaths: string[] = [];
657
680
  if (msg.photo?.length) {
658
681
  const largest = msg.photo[msg.photo.length - 1]!;
@@ -663,39 +686,42 @@ export class TelegramDaemon {
663
686
  if (p) imagePaths.push(p);
664
687
  }
665
688
  if (!text && imagePaths.length === 0) return; // a non-image document with no caption — nothing usable to forward
666
- conn.ws.send(JSON.stringify({ type: "user_message", sessionId, text, imagePaths: imagePaths.length ? imagePaths : undefined }));
667
- await this.reactToMessage(msg.message_id, "👀");
689
+ const ok = await this.sendRequest(conn, { type: "user_message", sessionId, text, imagePaths: imagePaths.length ? imagePaths : undefined });
690
+ if (ok) await this.reactToMessage(msg.message_id, "👀");
668
691
  }
669
692
 
670
- /** Trust boundary: a bot's username is publicly discoverable, so ANY Telegram
671
- * user can message it. Only the paired chat (the one that ran
672
- * `jeo notify setup`) may steer/cancel subagents or steer a session;
673
- * everything else is dropped without a reply (replying would leak that the
674
- * bot is live). A message in a KNOWN session's own topic (per-session-
675
- * topics mode) routes directly to that session — bypassing the flat
676
- * `topicId` gate, which still governs everything else (global commands in
677
- * the General topic or the configured flat topic; other topics ignored). */
693
+ private authorized(chat: NonNullable<TelegramUpdate["message"]>["chat"], from: TelegramCallbackQuery["from"] | undefined): boolean {
694
+ if (!chat || String(chat.id) !== this.opts.chatId || !from || from.is_bot !== false || !Number.isSafeInteger(from.id)) return false;
695
+ if (this.opts.allowedUserIds !== undefined) return this.opts.allowedUserIds.includes(String(from.id));
696
+ return chat.type === "private" && String(from.id) === this.opts.chatId;
697
+ }
698
+
699
+ /** Only an authorized human in the paired chat can control sessions. Groups
700
+ * require an explicit allowlist; legacy private chats require owner identity.
701
+ * Session topics route solely to their owner; all other input must match the
702
+ * configured flat topic, including when the inbound topic is absent. */
678
703
  async handleUpdate(update: TelegramUpdate): Promise<void> {
704
+ if (!update || typeof update !== "object") return;
679
705
  if (update.callback_query) {
680
706
  await this.handleCallbackQuery(update.callback_query);
681
707
  return;
682
708
  }
683
709
  const msg = update.message;
684
710
  if (!msg) return;
685
- if (String(msg.chat.id) !== this.opts.chatId) return;
711
+ if (!this.authorized(msg.chat, msg.from)) return;
686
712
 
687
713
  const ownerSessionId = this.topics && msg.message_thread_id !== undefined
688
714
  ? this.topics.sessionForTopic(msg.message_thread_id)
689
715
  : undefined;
690
- if (ownerSessionId) {
716
+ if (ownerSessionId && await this.pairedChatIsPrivate()) {
691
717
  await this.handleSessionTopicInbound(ownerSessionId, msg);
692
718
  return;
693
719
  }
694
720
 
695
- if (!msg.text) return;
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;
696
723
  if (
697
724
  this.opts.topicId !== undefined &&
698
- msg.message_thread_id !== undefined &&
699
725
  msg.message_thread_id !== this.opts.topicId
700
726
  ) {
701
727
  return;
@@ -710,23 +736,19 @@ export class TelegramDaemon {
710
736
  * The tap is always acknowledged (Telegram shows a spinner until
711
737
  * `answerCallbackQuery`). */
712
738
  async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<void> {
713
- const chatId = cb.message?.chat.id;
714
- if (chatId !== undefined && String(chatId) !== this.opts.chatId) {
739
+ if (!cb.message || !Number.isSafeInteger(cb.message.message_id) || !this.authorized(cb.message.chat, cb.from)) {
715
740
  await this.answerCallback(cb.id);
716
741
  return;
717
742
  }
718
- const threadId = cb.message?.message_thread_id;
719
- const inKnownSessionTopic = !!this.topics && threadId !== undefined && this.topics.sessionForTopic(threadId) !== undefined;
720
- if (
721
- this.opts.topicId !== undefined &&
722
- threadId !== undefined &&
723
- threadId !== this.opts.topicId &&
724
- !inKnownSessionTopic
725
- ) {
743
+ const threadId = cb.message.message_thread_id;
744
+ const ownerSessionId = this.topics && threadId !== undefined && await this.pairedChatIsPrivate()
745
+ ? this.topics.sessionForTopic(threadId) : undefined;
746
+ if (!ownerSessionId && ((this.opts.topicId !== undefined && threadId !== this.opts.topicId) ||
747
+ (this.topics && threadId !== undefined && threadId !== this.opts.topicId))) {
726
748
  await this.answerCallback(cb.id);
727
749
  return;
728
750
  }
729
- const parsed = cb.data ? parseCallbackData(cb.data) : undefined;
751
+ const parsed = typeof cb.data === "string" ? parseCallbackData(cb.data) : undefined;
730
752
  if (!parsed) {
731
753
  await this.answerCallback(cb.id);
732
754
  return;
@@ -736,6 +758,10 @@ export class TelegramDaemon {
736
758
  await this.answerCallback(cb.id, `No connected session matches '${parsed.shortId}'.`);
737
759
  return;
738
760
  }
761
+ if (ownerSessionId && ownerSessionId !== conn.sessionId) {
762
+ await this.answerCallback(cb.id);
763
+ return;
764
+ }
739
765
  const ok = await this.sendRequest(conn, { type: "cancel", ids: [parsed.subagentId] });
740
766
  await this.answerCallback(cb.id, ok ? `Cancelled '${parsed.subagentId}'.` : "Cancel failed.");
741
767
  await this.notify(
@@ -751,9 +777,20 @@ export class TelegramDaemon {
751
777
  * hot-loop against the API; growing it also avoids sitting at a single
752
778
  * fixed multi-second delay for the entire outage. */
753
779
  private async pollErrorBackoff(): Promise<void> {
754
- const sleepFn = this.opts.sleep ?? sleep;
755
- await sleepFn(this.pollErrorBackoffMs);
756
- this.pollErrorBackoffMs = Math.min(this.pollErrorBackoffMs * 2, POLL_ERROR_BACKOFF_MAX_MS);
780
+ if (this.stopped) return;
781
+ const cancelled = Promise.withResolvers<void>();
782
+ this.cancelBackoff = cancelled.resolve;
783
+ let timer: Timer | undefined;
784
+ try {
785
+ const delay = this.opts.sleep
786
+ ? this.opts.sleep(this.pollErrorBackoffMs)
787
+ : new Promise<void>(resolve => { timer = setTimeout(resolve, this.pollErrorBackoffMs); });
788
+ await Promise.race([delay, cancelled.promise]);
789
+ if (!this.stopped) this.pollErrorBackoffMs = Math.min(this.pollErrorBackoffMs * 2, POLL_ERROR_BACKOFF_MAX_MS);
790
+ } finally {
791
+ clearTimeout(timer);
792
+ this.cancelBackoff = undefined;
793
+ }
757
794
  }
758
795
 
759
796
  private async pollTelegramLoop(): Promise<void> {
@@ -765,6 +802,7 @@ export class TelegramDaemon {
765
802
  await this.pollErrorBackoff();
766
803
  continue;
767
804
  }
805
+ if (this.stopped) break;
768
806
  if (!res.ok) {
769
807
  // e.g. 409 Conflict (another getUpdates owner) or 401 (revoked token) —
770
808
  // without a pause this would hot-loop against the API.
@@ -776,6 +814,7 @@ export class TelegramDaemon {
776
814
  // than staying parked at whatever ceiling a prior outage reached.
777
815
  this.pollErrorBackoffMs = POLL_ERROR_BACKOFF_MIN_MS;
778
816
  for (const update of res.result) {
817
+ if (this.stopped) break;
779
818
  this.updateOffset = update.update_id + 1;
780
819
  await this.handleUpdate(update);
781
820
  }
@@ -784,8 +823,9 @@ export class TelegramDaemon {
784
823
 
785
824
  /** Blocks (long-poll loop) until `stop()` is called. */
786
825
  async start(): Promise<void> {
826
+ if (this.stopped) return;
787
827
  if (this.topics) {
788
- const load = this.opts.loadTopicState ?? defaultLoadTopicState;
828
+ const load = this.opts.loadTopicState ?? (() => defaultLoadTopicState(this.opts.botId, this.opts.chatId));
789
829
  try {
790
830
  this.topics.load(await load());
791
831
  } catch {
@@ -793,7 +833,9 @@ export class TelegramDaemon {
793
833
  // (every session's first frame re-creates its topic from scratch).
794
834
  }
795
835
  }
836
+ if (this.stopped) return;
796
837
  await this.scanSessions();
838
+ if (this.stopped) return;
797
839
  this.scanTimer = setInterval(() => void this.scanSessions(), this.opts.scanIntervalMs ?? 3_000);
798
840
  // Catches anything left queued after a submit-time drain because the
799
841
  // burst capacity was briefly exhausted (rare at jeo's realistic message
@@ -805,8 +847,14 @@ export class TelegramDaemon {
805
847
 
806
848
  stop(): void {
807
849
  this.stopped = true;
850
+ this.cancelBackoff?.();
808
851
  clearInterval(this.scanTimer);
809
852
  clearInterval(this.poolTimer);
853
+ for (const pending of this.pendingAcks.values()) {
854
+ clearTimeout(pending.timer);
855
+ pending.resolve(false);
856
+ }
857
+ this.pendingAcks.clear();
810
858
  for (const conn of this.sessions.values()) {
811
859
  try {
812
860
  conn.ws.close();
@@ -817,20 +865,24 @@ export class TelegramDaemon {
817
865
  }
818
866
 
819
867
  /** Default inbound-attachment temp-file writer: a private (0700-parent,
820
- * default-mode) file under the OS temp dir, named to survive filesystem
868
+ * 0600-mode) file under the OS temp dir, named to survive filesystem
821
869
  * weirdness (sanitized, length-capped) while staying traceable to its
822
870
  * Telegram origin for debugging. */
823
871
  async function defaultWriteTempFile(bytes: Uint8Array, suggestedName: string): Promise<string> {
824
872
  const safe = (suggestedName.replace(/[^\w.-]+/g, "_").slice(-128) || "file");
825
- const target = path.join(os.tmpdir(), `jeo-telegram-${Date.now()}-${crypto.randomUUID().slice(0, 8)}-${safe}`);
826
- await fs.writeFile(target, bytes);
873
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "jeo-telegram-"));
874
+ await fs.chmod(directory, 0o700);
875
+ const target = path.join(directory, safe === "." || safe === ".." ? "file" : safe);
876
+ await fs.writeFile(target, bytes, { mode: 0o600, flag: "wx" });
827
877
  return target;
828
878
  }
829
879
 
830
- async function defaultLoadTopicState(): Promise<TopicRegistryState> {
880
+ async function defaultLoadTopicState(botId: string | undefined, chatId: string): Promise<TopicRegistryState> {
881
+ if (!botId || !/^[1-9]\d*$/.test(botId)) return { topics: {} };
831
882
  try {
832
883
  const raw = await fs.readFile(notifyTopicsPath(), "utf-8");
833
- return JSON.parse(raw) as TopicRegistryState;
884
+ const state = JSON.parse(raw) as TopicRegistryState & { botId?: string; chatId?: string };
885
+ return state.botId === botId && state.chatId === chatId ? state : { topics: {} };
834
886
  } catch {
835
887
  return { topics: {} };
836
888
  }
@@ -838,12 +890,13 @@ async function defaultLoadTopicState(): Promise<TopicRegistryState> {
838
890
 
839
891
  /** Atomic temp+rename write (same convention as `state.ts`'s config/workflow
840
892
  * persistence) — a torn write must never corrupt the topic map. */
841
- async function defaultSaveTopicState(state: TopicRegistryState): Promise<void> {
893
+ async function defaultSaveTopicState(state: TopicRegistryState, botId: string | undefined, chatId: string): Promise<void> {
894
+ if (!botId || !/^[1-9]\d*$/.test(botId)) return;
842
895
  const target = notifyTopicsPath();
843
896
  await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
844
897
  const tmp = `${target}.${Math.random().toString(36).slice(2)}.tmp`;
845
898
  try {
846
- await fs.writeFile(tmp, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 0o600 });
899
+ await fs.writeFile(tmp, JSON.stringify({ ...state, botId, chatId }, null, 2), { encoding: "utf-8", mode: 0o600 });
847
900
  await fs.rename(tmp, target);
848
901
  } catch (err) {
849
902
  await fs.unlink(tmp).catch(() => {});
@@ -853,34 +906,113 @@ async function defaultSaveTopicState(state: TopicRegistryState): Promise<void> {
853
906
 
854
907
  // ── CLI entrypoint (`jeo notify-daemon-run`, internal) ──────────────────────────
855
908
 
856
- export async function runNotifyDaemonForeground(): Promise<void> {
909
+ import { DiscordDaemon } from "./discord-daemon";
910
+ import type { DiscordDaemonConfig, DiscordDaemonDependencies } from "./discord-daemon";
911
+ import { DiscordApiError } from "./discord-api";
912
+ import { SlackDaemon } from "./slack-daemon";
913
+ import type { SlackDaemonConfig, SlackDaemonDependencies } from "./slack-daemon";
914
+ import { SlackApi, SlackApiError } from "./slack-api";
915
+
916
+ interface ForegroundTransport {
917
+ start(): Promise<void>;
918
+ stop(): void;
919
+ }
920
+
921
+ export interface NotifyForegroundOptions {
922
+ fetchImpl?: typeof fetch;
923
+ signals?: {
924
+ on(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown;
925
+ off(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown;
926
+ };
927
+ createTelegram?: (options: TelegramDaemonOptions) => ForegroundTransport;
928
+ createDiscord?: (config: DiscordDaemonConfig, dependencies: DiscordDaemonDependencies) => ForegroundTransport;
929
+ createSlack?: (config: SlackDaemonConfig, dependencies: SlackDaemonDependencies) => ForegroundTransport;
930
+ }
931
+
932
+ export async function runNotifyDaemonForeground(options: NotifyForegroundOptions = {}): Promise<void> {
857
933
  const lock = await acquireDaemonLock();
858
934
  if (!lock) {
859
- process.stderr.write("[jeo notify-daemon] another daemon instance already holds the lock; exiting.\n");
935
+ process.stderr.write("[jeo notify-daemon] another daemon or pairing process holds the lock; exiting.\n");
936
+ process.exitCode = 1;
860
937
  return;
861
938
  }
939
+ const transports: { stop(): void }[] = [];
940
+ const polling = new AbortController();
941
+ const shutdown = Promise.withResolvers<void>();
942
+ let stopping = false;
943
+ let telegramRun: Promise<void> | undefined;
944
+ const stop = () => {
945
+ if (stopping) return;
946
+ stopping = true;
947
+ for (const transport of transports) transport.stop();
948
+ polling.abort();
949
+ shutdown.resolve();
950
+ };
951
+ const signals = options.signals ?? process;
952
+ signals.on("SIGTERM", stop);
953
+ signals.on("SIGINT", stop);
862
954
  try {
863
- const config = await readGlobalConfig();
864
- const botToken = config.notifications?.telegram?.botToken;
865
- const chatId = config.notifications?.telegram?.chatId;
866
- const topicId = config.notifications?.telegram?.topicId;
867
- const perSessionTopics = config.notifications?.telegram?.perSessionTopics;
868
- if (!config.notifications?.enabled || !botToken || !chatId) {
869
- process.stderr.write("[jeo notify-daemon] notifications not configured — run `jeo notify setup` first.\n");
870
- return;
871
- }
872
- const daemon = new TelegramDaemon({ chatId, topicId, perSessionTopics, telegram: new TelegramApi(botToken) });
873
- let shuttingDown = false;
874
- const shutdown = () => {
875
- if (shuttingDown) return;
876
- shuttingDown = true;
877
- daemon.stop();
878
- process.exit(0);
879
- };
880
- process.on("SIGTERM", shutdown);
881
- process.on("SIGINT", shutdown);
882
- await daemon.start();
955
+ const n = (await readGlobalConfig()).notifications;
956
+ if (!n?.enabled) throw new Error("notifications disabled; run jeo notify setup first");
957
+ const discord = n.discord;
958
+ if (discord?.botToken && discord.channelId && discord.allowedUserIds?.length) {
959
+ const daemon = (options.createDiscord ?? ((config, dependencies) => new DiscordDaemon(config, dependencies)))({ botToken: discord.botToken, channelId: discord.channelId, allowedUserIds: discord.allowedUserIds }, {
960
+ onError: message => process.stderr.write(`[jeo notify-daemon] Discord: ${message}\n`),
961
+ onFatal: () => {
962
+ if (stopping) return;
963
+ process.exitCode = 1;
964
+ stop();
965
+ },
966
+ });
967
+ transports.push(daemon);
968
+ await daemon.start();
969
+ }
970
+ const slack = n.slack;
971
+ if (!stopping && slack?.botToken && slack.appToken && slack.channelId && slack.allowedUserIds?.length) {
972
+ const daemon = (options.createSlack ?? ((config, dependencies) => new SlackDaemon(config, dependencies)))(slack, {
973
+ api: new SlackApi(slack.botToken, slack.appToken, { fetchImpl: options.fetchImpl }),
974
+ onError: message => process.stderr.write(`[jeo notify-daemon] Slack: ${message}\n`),
975
+ onFatal: () => {
976
+ if (stopping) return;
977
+ process.exitCode = 1;
978
+ stop();
979
+ },
980
+ });
981
+ transports.push(daemon);
982
+ await daemon.start();
983
+ }
984
+ const telegram = n.telegram;
985
+ if (!stopping && telegram?.botToken && telegram.chatId) {
986
+ const api = new TelegramApi(telegram.botToken, ((input, init) => (options.fetchImpl ?? fetch)(input, { ...init, signal: AbortSignal.any([polling.signal, AbortSignal.timeout(35_000)]) })) as typeof fetch);
987
+ const me = await api.getMe();
988
+ const chat = await api.getChat(telegram.chatId);
989
+ if (!me.ok || !me.result?.is_bot || !chat.ok || !chat.result?.type) throw new Error("Telegram requires valid bot credentials and an accessible chat");
990
+ if (chat.result.type !== "private" && !telegram.allowedUserIds?.length) throw new Error("Telegram requires allowedUserIds for group control");
991
+ if (stopping) return;
992
+ const daemon = (options.createTelegram ?? (config => new TelegramDaemon(config)))({ ...telegram, chatId: telegram.chatId, botId: String(me.result.id), telegram: api });
993
+ transports.push(daemon);
994
+ telegramRun = daemon.start().catch(() => {
995
+ if (!stopping) {
996
+ process.stderr.write("[jeo notify-daemon] Telegram transport failed; check credentials and connectivity.\n");
997
+ process.exitCode = 1;
998
+ stop();
999
+ }
1000
+ });
1001
+ }
1002
+ if (!transports.length) throw new Error("notifications not configured; run jeo notify setup first");
1003
+ if (!stopping) await lock.markReady();
1004
+ await shutdown.promise;
1005
+ } catch (error) {
1006
+ if (stopping) return;
1007
+ process.stderr.write(`[jeo notify-daemon] startup failed: ${error instanceof DiscordApiError || error instanceof SlackApiError ? `${error.message}${error.status ? ` (HTTP ${error.status})` : ""}` : error instanceof Error && /^(notifications |Discord requires|Slack requires|Telegram requires)/.test(error.message) ? error.message : "check platform credentials, permissions and connectivity"}.\n`);
1008
+ process.exitCode = 1;
883
1009
  } finally {
1010
+ stop();
1011
+ await telegramRun;
1012
+ // A start in flight may finish after a signal; stop those resources as well.
1013
+ for (const transport of transports) transport.stop();
1014
+ signals.off("SIGTERM", stop);
1015
+ signals.off("SIGINT", stop);
884
1016
  await lock.release();
885
1017
  }
886
1018
  }
@@ -64,7 +64,7 @@ export interface Config {
64
64
  onComplete?: boolean;
65
65
  onAsk?: boolean;
66
66
  };
67
- /** Remote subagent visibility/control over Telegram (see `src/agent/notify/`). */
67
+ /** Remote session visibility/control over Telegram, Discord, and Slack. */
68
68
  notifications?: {
69
69
  enabled?: boolean;
70
70
  /** Session-local default; see `config-schema.ts` for the mutability contract. */
@@ -74,12 +74,24 @@ export interface Config {
74
74
  telegram?: {
75
75
  botToken?: string;
76
76
  chatId?: string;
77
+ allowedUserIds?: string[];
77
78
  /** Forum-topic thread id (message_thread_id) for daemon pushes. */
78
79
  topicId?: number;
79
80
  /** Auto-create/manage one forum topic per interactive session instead of
80
81
  * the flat/global `topicId` above. See `TopicRegistry`. */
81
82
  perSessionTopics?: boolean;
82
83
  };
84
+ discord?: {
85
+ botToken?: string;
86
+ channelId?: string;
87
+ allowedUserIds?: string[];
88
+ };
89
+ slack?: {
90
+ botToken: string;
91
+ appToken: string;
92
+ channelId: string;
93
+ allowedUserIds: string[];
94
+ };
83
95
  };
84
96
 
85
97
  thinkingLevel?: "low" | "medium" | "high" | "xhigh";
@@ -70,13 +70,6 @@ const DEFAULT_LIMIT = 100;
70
70
  const CODEX_CLIENT_VERSION = "2.0.0";
71
71
  export const CODEX_MODELS_URL = `https://chatgpt.com/backend-api/codex/models?client_version=${CODEX_CLIENT_VERSION}`;
72
72
  const ANTIGRAVITY_MODELS_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
73
- const ANTIGRAVITY_MODEL_DENYLIST = new Set([
74
- "chat_20706",
75
- "chat_23310",
76
- "gemini-2.5-flash-thinking",
77
- "gemini-3-pro-low",
78
- "gemini-2.5-pro",
79
- ]);
80
73
 
81
74
  function anthropicHeaders(cred: Credential): Record<string, string> {
82
75
  if (cred.kind === "oauth") {
@@ -247,10 +240,11 @@ export function parseModelsBody(provider: ProviderName, body: unknown, opts: { o
247
240
  /** Array of ids OR an object keyed by deprecated id. */
248
241
  deprecatedModelIds?: string[] | Record<string, unknown>;
249
242
  };
250
- const roleIds = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
243
+ const normalizeId = (id: string): string => id.replace(/^models\//, "").replace(/^antigravity\//, "");
244
+ const roleIds = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string").map(normalizeId) : []);
251
245
  const deprecated = Array.isArray(payload.deprecatedModelIds)
252
246
  ? roleIds(payload.deprecatedModelIds)
253
- : Object.keys(payload.deprecatedModelIds ?? {});
247
+ : Object.keys(payload.deprecatedModelIds ?? {}).map(normalizeId);
254
248
  const agentIds = new Set(
255
249
  (Array.isArray(payload.agentModelSorts) ? payload.agentModelSorts : [])
256
250
  .flatMap(sort => (Array.isArray(sort?.groups) ? sort.groups : []))
@@ -265,18 +259,16 @@ export function parseModelsBody(provider: ProviderName, body: unknown, opts: { o
265
259
  ...deprecated,
266
260
  ]);
267
261
  const rawModels = payload.models;
268
- const ids = Array.isArray(rawModels)
269
- ? rawModels.map(m => m.slug ?? m.id ?? m.name ?? "").filter(Boolean)
270
- : Object.entries(rawModels ?? {})
271
- .filter(([id, model]) =>
272
- !ANTIGRAVITY_MODEL_DENYLIST.has(id) &&
273
- model?.isInternal !== true &&
274
- (agentIds.size > 0 ? agentIds.has(id) : !nonChat.has(id)))
275
- .map(([id]) => id);
276
- return ids
277
- .map(id => id.replace(/^models\//, ""))
278
- .filter(Boolean)
279
- .map(id => id.startsWith("antigravity/") ? id : `antigravity/${id}`);
262
+ const entries = Array.isArray(rawModels)
263
+ ? rawModels.map(model => [model?.slug ?? model?.id ?? model?.name ?? "", model] as const)
264
+ : Object.entries(rawModels ?? {});
265
+ return entries
266
+ .map(([id, model]) => [typeof id === "string" ? normalizeId(id) : "", model] as const)
267
+ .filter(([id, model]) =>
268
+ id.length > 0 &&
269
+ model?.isInternal !== true &&
270
+ (agentIds.size > 0 ? agentIds.has(id) : !nonChat.has(id)))
271
+ .map(([id]) => `antigravity/${id}`);
280
272
  }
281
273
  if (provider === "gemini") {
282
274
  // Keep only models the generateContent endpoint can serve (skip embeddings/tts/aqa/etc).