@gajae-code/coding-agent 0.7.2 → 0.7.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/cli/mcp-cli.d.ts +25 -0
  3. package/dist/types/cli.d.ts +6 -0
  4. package/dist/types/commands/mcp.d.ts +70 -0
  5. package/dist/types/config/keybindings.d.ts +2 -2
  6. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  7. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  8. package/dist/types/modes/components/model-selector.d.ts +2 -0
  9. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  10. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  11. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  12. package/dist/types/notifications/telegram-daemon.d.ts +54 -16
  13. package/dist/types/notifications/topic-registry.d.ts +2 -0
  14. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  15. package/dist/types/web/insane/url-guard.d.ts +6 -3
  16. package/dist/types/web/scrapers/types.d.ts +5 -0
  17. package/dist/types/web/scrapers/utils.d.ts +7 -1
  18. package/package.json +7 -7
  19. package/src/cli/mcp-cli.ts +272 -0
  20. package/src/cli.ts +6 -2
  21. package/src/commands/mcp.ts +117 -0
  22. package/src/config/keybindings.ts +2 -2
  23. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  24. package/src/defaults/gjc/skills/deep-interview/SKILL.md +4 -3
  25. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  26. package/src/extensibility/extensions/runner.ts +1 -0
  27. package/src/gjc-runtime/tmux-common.ts +3 -1
  28. package/src/gjc-runtime/ultragoal-guard.ts +25 -8
  29. package/src/hooks/skill-state.ts +57 -0
  30. package/src/internal-urls/docs-index.generated.ts +10 -7
  31. package/src/modes/bridge/bridge-mode.ts +11 -0
  32. package/src/modes/components/custom-editor.ts +2 -0
  33. package/src/modes/components/footer.ts +2 -3
  34. package/src/modes/components/model-selector.ts +12 -0
  35. package/src/modes/components/status-line/git-utils.ts +25 -0
  36. package/src/modes/components/status-line.ts +10 -11
  37. package/src/modes/components/welcome.ts +2 -3
  38. package/src/modes/controllers/selector-controller.ts +3 -0
  39. package/src/modes/interactive-mode.ts +2 -1
  40. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  41. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  42. package/src/modes/theme/defaults/index.ts +2 -0
  43. package/src/notifications/operator-runtime.ts +171 -0
  44. package/src/notifications/telegram-daemon.ts +347 -251
  45. package/src/notifications/topic-registry.ts +5 -0
  46. package/src/slash-commands/helpers/parse.ts +2 -1
  47. package/src/tools/bash.ts +9 -0
  48. package/src/tools/composer-bash-policy.ts +96 -0
  49. package/src/tools/fetch.ts +18 -2
  50. package/src/web/insane/url-guard.ts +18 -14
  51. package/src/web/scrapers/types.ts +143 -45
  52. package/src/web/scrapers/utils.ts +70 -19
@@ -11,6 +11,7 @@ import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
11
11
  import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
12
12
  import { parseInThreadConfigCommand } from "./config-commands";
13
13
  import { buildButtonGrid, TELEGRAM_PARSE_MODE } from "./html-format";
14
+ import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
14
15
  import { RateLimitPool } from "./rate-limit-pool";
15
16
  import {
16
17
  type AliasTable,
@@ -104,6 +105,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
104
105
  // Native reactions used as a two-stage delivery double-check on inbound thread
105
106
  // messages: queued on receipt, consumed once a turn picks the message up.
106
107
  const QUEUED_REACTION = "👀";
108
+ const PENDING_TOPIC_FRAME_LIMIT = 20;
107
109
  const CONSUMED_REACTION = "✅";
108
110
 
109
111
  /**
@@ -527,6 +529,154 @@ export interface BotApi {
527
529
  call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown>;
528
530
  }
529
531
 
532
+ export interface TelegramTransportOptions {
533
+ botToken: string;
534
+ apiBase?: string;
535
+ fetchImpl?: typeof fetch;
536
+ setTimeoutImpl?: typeof setTimeout;
537
+ }
538
+
539
+ /** Telegram Bot API transport: HTTP JSON/multipart details stay out of daemon orchestration. */
540
+ export class TelegramBotTransport implements BotApi {
541
+ #opts: TelegramTransportOptions;
542
+
543
+ constructor(opts: TelegramTransportOptions) {
544
+ this.#opts = opts;
545
+ }
546
+
547
+ async call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown> {
548
+ const apiBase = this.#opts.apiBase ?? "https://api.telegram.org";
549
+ const url = `${apiBase}/bot${this.#opts.botToken}/${method}`;
550
+ const fetchImpl = this.#opts.fetchImpl ?? fetch;
551
+ const setTimeoutImpl = this.#opts.setTimeoutImpl ?? setTimeout;
552
+ const sleep = (ms: number) => new Promise<void>(resolve => setTimeoutImpl(resolve, ms));
553
+ // sendPhoto with base64 bytes must be a multipart upload (Telegram does
554
+ // not accept base64 in JSON). Other methods stay JSON.
555
+ const photoBody = body as { photo?: unknown; mime?: unknown } | null;
556
+ if (method === "sendPhoto" && photoBody && typeof photoBody.photo === "string") {
557
+ const b = body as {
558
+ chat_id: unknown;
559
+ message_thread_id?: unknown;
560
+ photo: string;
561
+ mime?: string;
562
+ caption?: string;
563
+ parse_mode?: string;
564
+ };
565
+ const form = new FormData();
566
+ form.set("chat_id", String(b.chat_id));
567
+ if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
568
+ if (b.caption) form.set("caption", b.caption);
569
+ if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
570
+ form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
571
+ const res = await fetchWithRetry(fetchImpl, url, { method: "POST", body: form, signal: opts?.signal }, sleep);
572
+ return res.json();
573
+ }
574
+ const docBody = body as { document?: unknown } | null;
575
+ if (method === "sendDocument" && docBody && typeof docBody.document === "string") {
576
+ const b = body as {
577
+ chat_id: unknown;
578
+ message_thread_id?: unknown;
579
+ document: string;
580
+ mime?: string;
581
+ fileName?: string;
582
+ caption?: string;
583
+ parse_mode?: string;
584
+ };
585
+ const form = new FormData();
586
+ form.set("chat_id", String(b.chat_id));
587
+ if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
588
+ if (b.caption) form.set("caption", b.caption);
589
+ if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
590
+ form.set(
591
+ "document",
592
+ new Blob([Buffer.from(b.document, "base64")], { type: b.mime ?? "application/octet-stream" }),
593
+ b.fileName ?? "file",
594
+ );
595
+ const res = await fetchWithRetry(fetchImpl, url, { method: "POST", body: form, signal: opts?.signal }, sleep);
596
+ return res.json();
597
+ }
598
+ const res = await fetchWithRetry(
599
+ fetchImpl,
600
+ url,
601
+ {
602
+ method: "POST",
603
+ headers: { "content-type": "application/json" },
604
+ body: JSON.stringify(body),
605
+ signal: opts?.signal,
606
+ },
607
+ sleep,
608
+ );
609
+ return res.json();
610
+ }
611
+ }
612
+
613
+ export interface TelegramUpdatePollerOptions {
614
+ botApi: BotApi;
615
+ runtime: NotificationOperatorRuntime;
616
+ backoff: OperatorBackoffPolicy;
617
+ processUpdate: (update: unknown) => Promise<void>;
618
+ }
619
+
620
+ /** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
621
+ export class TelegramUpdatePoller {
622
+ #offset = 0;
623
+ #opts: TelegramUpdatePollerOptions;
624
+
625
+ constructor(opts: TelegramUpdatePollerOptions) {
626
+ this.#opts = opts;
627
+ }
628
+
629
+ async pollOnce(signal?: AbortSignal): Promise<number> {
630
+ let body: {
631
+ ok?: boolean;
632
+ error_code?: number;
633
+ description?: string;
634
+ result?: Array<{ update_id: number } & Record<string, unknown>>;
635
+ };
636
+ try {
637
+ body = (await this.#opts.botApi.call(
638
+ "getUpdates",
639
+ { offset: this.#offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
640
+ { signal },
641
+ )) as typeof body;
642
+ } catch (err) {
643
+ // A cooperative stop aborts the in-flight long poll; treat as a clean wake.
644
+ if (isAbortError(err)) return 0;
645
+ // A transient Telegram API failure must never crash the daemon.
646
+ logger.error("notifications daemon: getUpdates failed", { error: String(err) });
647
+ await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
648
+ return 0;
649
+ }
650
+ // Telegram allows only one active getUpdates poller per bot. A 409 means
651
+ // another poller is live; back off boundedly instead of hot-looping.
652
+ if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
653
+ const backoffMs = this.#opts.backoff.next();
654
+ logger.error(
655
+ `notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${backoffMs}ms`,
656
+ );
657
+ await this.#opts.runtime.sleep(backoffMs, signal);
658
+ return 0;
659
+ }
660
+ this.#opts.backoff.reset();
661
+ for (const update of body.result ?? []) {
662
+ this.#offset = update.update_id + 1;
663
+ try {
664
+ await this.#opts.processUpdate(update);
665
+ } catch (err) {
666
+ logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
667
+ }
668
+ }
669
+ return body.result?.length ?? 0;
670
+ }
671
+ }
672
+
673
+ /** Mutable dispatch state shared by session frames and inbound Telegram updates. */
674
+ export class TelegramEventDispatchState {
675
+ readonly busy = new Set<string>();
676
+ readonly inboundReactions = new Map<number, { messageId: number }>();
677
+ readonly seenUpdateIds = new Set<number>();
678
+ }
679
+
530
680
  /**
531
681
  * Cooperative control seam for the daemon run loop. Implemented by the
532
682
  * daemon-internal CLI / controller against the owner-scoped control-request
@@ -575,37 +725,44 @@ interface SessionSocket {
575
725
  pingTimer: ReturnType<typeof setInterval> | undefined;
576
726
  }
577
727
 
728
+ interface PendingThreadedFrame {
729
+ send: ThreadedSend;
730
+ msg: Record<string, unknown>;
731
+ }
732
+
578
733
  export class TelegramNotificationDaemon {
579
734
  readonly aliasTable: AliasTable;
580
735
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
581
736
  readonly sessions = new Map<string, SessionSocket>();
737
+ private readonly runtime: NotificationOperatorRuntime;
738
+ private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
739
+ private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
740
+ private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
582
741
  private running = false;
583
- private offset = 0;
584
742
  private readonly fsImpl: TelegramDaemonFs;
585
743
  private readonly botApi: BotApi;
586
744
  private readonly topics = new TopicRegistry();
587
745
  private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
588
- private readonly seenUpdateIds = new Set<number>();
746
+ private readonly poller: TelegramUpdatePoller;
747
+ private readonly dispatchState = new TelegramEventDispatchState();
748
+ /** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
749
+ private readonly topicOwnerByIdentity = new Map<string, string>();
750
+ /** Non-identity frames held until identity creates the correct thread. */
751
+ private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
589
752
  /** True once the daemon has nudged the user to enable Threaded Mode. */
590
753
  private threadedFallbackNoticeSent = false;
591
754
  /** Sessions whose identity header was already sent flat (Threaded Mode off). */
592
755
  private readonly flatIdentitySent = new Set<string>();
593
756
  /** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
594
757
  private pairedChatPrivate: boolean | undefined;
595
- private flushTimer: ReturnType<typeof setInterval> | undefined;
596
- private scanTimer: ReturnType<typeof setInterval> | undefined;
597
- private scanning = false;
598
- private typingTimer: ReturnType<typeof setInterval> | undefined;
599
758
  /** Sessions whose agent loop is currently busy (drives the typing indicator). */
600
- private readonly busy = new Set<string>();
759
+ private get busy(): Set<string> {
760
+ return this.dispatchState.busy;
761
+ }
601
762
  /** Inbound update id → originating Telegram message, for delivery reactions. */
602
- private readonly inboundReactions = new Map<number, { messageId: number }>();
603
- /** AbortController for the in-flight long poll; aborted by requestStop() to wake the loop. */
604
- private activePoll: AbortController | undefined;
605
- /** Set when a cooperative stop has been requested (signal or control request). */
606
- private stopRequested = false;
607
- /** Current bounded backoff after a Telegram getUpdates 409 conflict (0 when healthy). */
608
- private pollConflictBackoffMs = 0;
763
+ private get inboundReactions(): Map<number, { messageId: number }> {
764
+ return this.dispatchState.inboundReactions;
765
+ }
609
766
 
610
767
  /**
611
768
  * Cooperatively stop the daemon: set the stop flag and abort the in-flight
@@ -613,91 +770,84 @@ export class TelegramNotificationDaemon {
613
770
  * ~25s getUpdates timeout. Safe to call from a signal handler.
614
771
  */
615
772
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
616
- this.stopRequested = true;
773
+ this.runtime.requestStop();
617
774
  this.running = false;
618
- this.activePoll?.abort();
619
775
  }
620
776
 
621
777
  constructor(private readonly opts: TelegramDaemonOptions) {
622
778
  this.fsImpl = opts.fs ?? nodeFs;
623
779
  this.aliasTable = createAliasTable();
624
- this.botApi = opts.botApi ?? {
625
- call: async (method, body, callOpts) => {
626
- const apiBase = opts.apiBase ?? "https://api.telegram.org";
627
- const url = `${apiBase}/bot${opts.botToken}/${method}`;
628
- const fetchImpl = opts.fetchImpl ?? fetch;
629
- const setTimeoutImpl = opts.setTimeoutImpl ?? setTimeout;
630
- const sleep = (ms: number) => new Promise<void>(resolve => setTimeoutImpl(resolve, ms));
631
- // sendPhoto with base64 bytes must be a multipart upload (Telegram does
632
- // not accept base64 in JSON). Other methods stay JSON.
633
- const photoBody = body as { photo?: unknown; mime?: unknown } | null;
634
- if (method === "sendPhoto" && photoBody && typeof photoBody.photo === "string") {
635
- const b = body as {
636
- chat_id: unknown;
637
- message_thread_id?: unknown;
638
- photo: string;
639
- mime?: string;
640
- caption?: string;
641
- parse_mode?: string;
642
- };
643
- const form = new FormData();
644
- form.set("chat_id", String(b.chat_id));
645
- if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
646
- if (b.caption) form.set("caption", b.caption);
647
- if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
648
- form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
649
- const res = await fetchWithRetry(
650
- fetchImpl,
651
- url,
652
- { method: "POST", body: form, signal: callOpts?.signal },
653
- sleep,
654
- );
655
- return res.json();
656
- }
657
- const docBody = body as { document?: unknown } | null;
658
- if (method === "sendDocument" && docBody && typeof docBody.document === "string") {
659
- const b = body as {
660
- chat_id: unknown;
661
- message_thread_id?: unknown;
662
- document: string;
663
- mime?: string;
664
- fileName?: string;
665
- caption?: string;
666
- parse_mode?: string;
667
- };
668
- const form = new FormData();
669
- form.set("chat_id", String(b.chat_id));
670
- if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
671
- if (b.caption) form.set("caption", b.caption);
672
- if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
673
- form.set(
674
- "document",
675
- new Blob([Buffer.from(b.document, "base64")], { type: b.mime ?? "application/octet-stream" }),
676
- b.fileName ?? "file",
677
- );
678
- const res = await fetchWithRetry(
679
- fetchImpl,
680
- url,
681
- { method: "POST", body: form, signal: callOpts?.signal },
682
- sleep,
683
- );
684
- return res.json();
685
- }
686
- const res = await fetchWithRetry(
687
- fetchImpl,
688
- url,
689
- {
690
- method: "POST",
691
- headers: { "content-type": "application/json" },
692
- body: JSON.stringify(body),
693
- signal: callOpts?.signal,
694
- },
695
- sleep,
696
- );
697
- return res.json();
698
- },
699
- };
780
+ this.botApi =
781
+ opts.botApi ??
782
+ new TelegramBotTransport({
783
+ botToken: opts.botToken,
784
+ apiBase: opts.apiBase,
785
+ fetchImpl: opts.fetchImpl,
786
+ setTimeoutImpl: opts.setTimeoutImpl,
787
+ });
788
+ this.runtime = new NotificationOperatorRuntime({
789
+ now: opts.now,
790
+ setTimeoutImpl: opts.setTimeoutImpl,
791
+ clearTimeoutImpl: opts.clearTimeoutImpl,
792
+ setIntervalImpl: opts.setIntervalImpl,
793
+ clearIntervalImpl: opts.clearIntervalImpl,
794
+ });
795
+ this.sessionRouter = this.createSessionRouter();
700
796
  this.pool = new RateLimitPool<{ send: ThreadedSend; topicId?: string }>({ now: opts.now });
797
+ this.poller = new TelegramUpdatePoller({
798
+ botApi: this.botApi,
799
+ runtime: this.runtime,
800
+ backoff: this.pollConflictBackoff,
801
+ processUpdate: update => this.handleTelegramUpdate(update),
802
+ });
803
+ }
804
+
805
+ private createSessionRouter(): OperatorEventRouter<SessionSocket> {
806
+ return new OperatorEventRouter<SessionSocket>()
807
+ .add({
808
+ name: "hello",
809
+ matches: msg => msg.type === "hello",
810
+ handle: (session, msg) => {
811
+ const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
812
+ if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
813
+ session.capable = true;
814
+ this.startLiveness(session);
815
+ }
816
+ },
817
+ })
818
+ .add({
819
+ name: "pong",
820
+ matches: msg => msg.type === "pong",
821
+ handle: (session, msg) => {
822
+ if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
823
+ session.awaitingNonce = undefined;
824
+ session.lastPongAt = this.runtime.now();
825
+ }
826
+ },
827
+ })
828
+ .add({
829
+ name: "activity",
830
+ matches: msg => msg.type === "activity",
831
+ handle: async (session, msg) => {
832
+ if (msg.state === "busy") {
833
+ this.busy.add(session.sessionId);
834
+ await this.sendTyping(session.sessionId);
835
+ } else {
836
+ this.busy.delete(session.sessionId);
837
+ }
838
+ },
839
+ })
840
+ .add({
841
+ name: "inbound_ack",
842
+ matches: msg => msg.type === "inbound_ack" && typeof msg.updateId === "number",
843
+ handle: async (_session, msg) => {
844
+ const target = this.inboundReactions.get(msg.updateId as number);
845
+ if (target && msg.state === "consumed") {
846
+ this.inboundReactions.delete(msg.updateId as number);
847
+ await this.setReaction(target.messageId, CONSUMED_REACTION);
848
+ }
849
+ },
850
+ });
701
851
  }
702
852
 
703
853
  async loadAliases(): Promise<void> {
@@ -770,7 +920,7 @@ export class TelegramNotificationDaemon {
770
920
  void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(err => {
771
921
  // Surface frame-handling failures (e.g. a rejected ask sendMessage) to
772
922
  // the daemon log instead of an invisible unhandled rejection.
773
- console.error("notifications daemon: handleSessionMessage failed:", err);
923
+ logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
774
924
  });
775
925
  });
776
926
  ws.addEventListener("close", () => {
@@ -788,7 +938,7 @@ export class TelegramNotificationDaemon {
788
938
  private startLiveness(session: SessionSocket): void {
789
939
  if (session.pingTimer) return;
790
940
  const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
791
- const now = () => (this.opts.now ?? Date.now)();
941
+ const now = () => this.runtime.now();
792
942
  session.lastPongAt = now();
793
943
  session.pingTimer = setIntervalImpl(() => {
794
944
  if (this.sessions.get(session.sessionId) !== session) return;
@@ -851,6 +1001,60 @@ export class TelegramNotificationDaemon {
851
1001
  return `GJC ${sessionId.slice(-6)}`;
852
1002
  }
853
1003
 
1004
+ private topicIdentityKey(msg: { repo?: unknown; branch?: unknown }): string | undefined {
1005
+ const repo = typeof msg?.repo === "string" && msg.repo.trim() ? msg.repo.trim() : undefined;
1006
+ if (!repo) return undefined;
1007
+ const branch = typeof msg?.branch === "string" && msg.branch.trim() ? msg.branch.trim() : "";
1008
+ return `${repo}\0${branch}`;
1009
+ }
1010
+
1011
+ private topicIdentityBase(msg: { repo?: unknown; branch?: unknown }): string | undefined {
1012
+ const repo = typeof msg?.repo === "string" && msg.repo.trim() ? msg.repo.trim() : undefined;
1013
+ if (!repo) return undefined;
1014
+ const branch = typeof msg?.branch === "string" && msg.branch.trim() ? msg.branch.trim() : undefined;
1015
+ return branch ? `${repo}/${branch}` : repo;
1016
+ }
1017
+
1018
+ private topicOwnerForIdentity(msg: { repo?: unknown; branch?: unknown }): string | undefined {
1019
+ const identityKey = this.topicIdentityKey(msg);
1020
+ const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
1021
+ if (remembered && this.topics.get(remembered)) return remembered;
1022
+ const base = this.topicIdentityBase(msg);
1023
+ if (!identityKey || !base) return undefined;
1024
+ for (const sessionId of this.topics.sessionIds()) {
1025
+ const name = this.topics.get(sessionId)?.name;
1026
+ if (name === base || name?.startsWith(`${base} - `)) {
1027
+ this.topicOwnerByIdentity.set(identityKey, sessionId);
1028
+ return sessionId;
1029
+ }
1030
+ }
1031
+ return undefined;
1032
+ }
1033
+
1034
+ private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
1035
+ this.pool.submit({
1036
+ sessionId,
1037
+ lane: send.lane,
1038
+ coalesceKey: send.coalesceKey,
1039
+ payload: { send, topicId },
1040
+ });
1041
+ await this.flushPool();
1042
+ }
1043
+
1044
+ private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
1045
+ const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
1046
+ frames.push({ send, msg });
1047
+ if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
1048
+ this.pendingThreadedFrames.set(sessionId, frames);
1049
+ }
1050
+
1051
+ private async flushPendingThreadedFrames(sessionId: string, topicId: string): Promise<void> {
1052
+ const frames = this.pendingThreadedFrames.get(sessionId);
1053
+ if (!frames || frames.length === 0) return;
1054
+ this.pendingThreadedFrames.delete(sessionId);
1055
+ for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicId);
1056
+ }
1057
+
854
1058
  /**
855
1059
  * Resolve (creating once via `createForumTopic`) the forum topic for a
856
1060
  * session. On capability failure (e.g. Threaded Mode off) this returns
@@ -1092,46 +1296,32 @@ export class TelegramNotificationDaemon {
1092
1296
  }
1093
1297
 
1094
1298
  private startFlushTimer(): void {
1095
- if (this.flushTimer) return;
1096
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
1097
- this.flushTimer = setIntervalImpl(() => {
1299
+ this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
1098
1300
  if (!this.running || this.pool.pending === 0) return;
1099
1301
  void this.flushPool();
1100
- }, RATE_LIMIT_FLUSH_INTERVAL_MS);
1302
+ });
1101
1303
  }
1102
1304
 
1103
1305
  private stopFlushTimer(): void {
1104
- if (!this.flushTimer) return;
1105
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
1106
- clearIntervalImpl(this.flushTimer);
1107
- this.flushTimer = undefined;
1306
+ this.runtime.stopInterval("telegram-flush");
1108
1307
  }
1109
1308
 
1110
1309
  /** Run a root scan, guarding against overlapping scans from the timer + loop. */
1111
1310
  private async runScan(): Promise<void> {
1112
- if (this.scanning) return;
1113
- this.scanning = true;
1114
- try {
1311
+ await this.runtime.runExclusive("telegram-scan", async () => {
1115
1312
  await this.scanRoots();
1116
- } finally {
1117
- this.scanning = false;
1118
- }
1313
+ });
1119
1314
  }
1120
1315
 
1121
1316
  private startScanTimer(): void {
1122
- if (this.scanTimer) return;
1123
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
1124
- this.scanTimer = setIntervalImpl(() => {
1317
+ this.runtime.startInterval("telegram-scan", this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS, () => {
1125
1318
  if (!this.running) return;
1126
1319
  void this.runScan();
1127
- }, this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS);
1320
+ });
1128
1321
  }
1129
1322
 
1130
1323
  private stopScanTimer(): void {
1131
- if (!this.scanTimer) return;
1132
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
1133
- clearIntervalImpl(this.scanTimer);
1134
- this.scanTimer = undefined;
1324
+ this.runtime.stopInterval("telegram-scan");
1135
1325
  }
1136
1326
 
1137
1327
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
@@ -1163,67 +1353,43 @@ export class TelegramNotificationDaemon {
1163
1353
  }
1164
1354
 
1165
1355
  private startTypingTimer(): void {
1166
- if (this.typingTimer) return;
1167
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
1168
- this.typingTimer = setIntervalImpl(() => {
1356
+ this.runtime.startInterval("telegram-typing", TYPING_REFRESH_INTERVAL_MS, () => {
1169
1357
  if (!this.running || this.busy.size === 0) return;
1170
1358
  for (const sessionId of this.busy) void this.sendTyping(sessionId);
1171
- }, TYPING_REFRESH_INTERVAL_MS);
1359
+ });
1172
1360
  }
1173
1361
 
1174
1362
  private stopTypingTimer(): void {
1175
- if (!this.typingTimer) return;
1176
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
1177
- clearIntervalImpl(this.typingTimer);
1178
- this.typingTimer = undefined;
1363
+ this.runtime.stopInterval("telegram-typing");
1179
1364
  }
1180
1365
 
1181
1366
  async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
1182
- if (msg?.type === "hello") {
1183
- const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
1184
- if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
1185
- session.capable = true;
1186
- this.startLiveness(session);
1187
- }
1188
- return;
1189
- }
1190
- if (msg?.type === "pong") {
1191
- if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
1192
- session.awaitingNonce = undefined;
1193
- session.lastPongAt = (this.opts.now ?? Date.now)();
1194
- }
1195
- return;
1196
- }
1197
- // Live typing indicator: track busy/idle per session and push an immediate
1198
- // chat action so "typing…" appears without waiting for the refresh tick.
1199
- if (msg?.type === "activity") {
1200
- if (msg.state === "busy") {
1201
- this.busy.add(session.sessionId);
1202
- await this.sendTyping(session.sessionId);
1203
- } else {
1204
- this.busy.delete(session.sessionId);
1205
- }
1206
- return;
1207
- }
1208
- // Inbound delivery double-check: flip the queued reaction to the consumed
1209
- // reaction once the session reports a turn picked the message up.
1210
- if (msg?.type === "inbound_ack" && typeof msg.updateId === "number") {
1211
- const target = this.inboundReactions.get(msg.updateId);
1212
- if (target && msg.state === "consumed") {
1213
- this.inboundReactions.delete(msg.updateId);
1214
- await this.setReaction(target.messageId, CONSUMED_REACTION);
1215
- }
1216
- return;
1217
- }
1367
+ if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
1218
1368
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
1219
1369
  const send = renderThreadedFrame(msg);
1220
1370
  if (!send) return;
1221
- const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
1371
+ const existingTopic = this.topics.get(session.sessionId)?.topicId;
1372
+ if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
1373
+ this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
1374
+ return;
1375
+ }
1376
+ if (send.identity) {
1377
+ const ownerId = this.topicOwnerForIdentity(msg);
1378
+ const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
1379
+ if (ownerId && ownerId !== session.sessionId && ownerTopic) {
1380
+ await this.flushPendingThreadedFrames(session.sessionId, ownerTopic.topicId);
1381
+ return;
1382
+ }
1383
+ }
1384
+ const topicId =
1385
+ existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
1222
1386
  if (!topicId) {
1223
1387
  await this.deliverFlatFallback(session.sessionId, send);
1224
1388
  return;
1225
1389
  }
1226
1390
  if (send.identity) {
1391
+ const identityKey = this.topicIdentityKey(msg);
1392
+ if (identityKey) this.topicOwnerByIdentity.set(identityKey, session.sessionId);
1227
1393
  // Rename the topic if the title changed (e.g. the session title was
1228
1394
  // auto-generated after the topic was first created). This runs on
1229
1395
  // every identity frame, but does NOT re-send the bulleted message.
@@ -1241,25 +1407,14 @@ export class TelegramNotificationDaemon {
1241
1407
  }
1242
1408
  // Send the full bulleted identity header EXACTLY ONCE per topic.
1243
1409
  if (this.topics.needsIdentity(session.sessionId)) {
1244
- this.pool.submit({
1245
- sessionId: session.sessionId,
1246
- lane: send.lane,
1247
- coalesceKey: send.coalesceKey,
1248
- payload: { send, topicId },
1249
- });
1250
- await this.flushPool();
1410
+ await this.submitThreadedFrame(session.sessionId, send, topicId);
1251
1411
  this.topics.markIdentitySent(session.sessionId);
1252
1412
  }
1413
+ await this.flushPendingThreadedFrames(session.sessionId, topicId);
1253
1414
  await this.persistTopics();
1254
1415
  return;
1255
1416
  }
1256
- this.pool.submit({
1257
- sessionId: session.sessionId,
1258
- lane: send.lane,
1259
- coalesceKey: send.coalesceKey,
1260
- payload: { send, topicId },
1261
- });
1262
- await this.flushPool();
1417
+ await this.submitThreadedFrame(session.sessionId, send, topicId);
1263
1418
  return;
1264
1419
  }
1265
1420
  if (msg.type === "action_needed" && msg.id) {
@@ -1343,11 +1498,11 @@ export class TelegramNotificationDaemon {
1343
1498
  const inbound = decideThreadedInbound(update as never, {
1344
1499
  pairedChatId: this.opts.chatId,
1345
1500
  topicToSession: t => this.topics.sessionForTopic(t),
1346
- isDuplicate: id => this.seenUpdateIds.has(id),
1501
+ isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
1347
1502
  });
1348
1503
  if (inbound.kind === "duplicate") return;
1349
1504
  if (inbound.kind === "inject") {
1350
- this.seenUpdateIds.add(inbound.updateId);
1505
+ this.dispatchState.seenUpdateIds.add(inbound.updateId);
1351
1506
  const session = this.sessions.get(inbound.sessionId);
1352
1507
  if (session?.ws.readyState === WebSocket.OPEN) {
1353
1508
  const attachmentResult = inbound.attachment
@@ -1425,67 +1580,7 @@ export class TelegramNotificationDaemon {
1425
1580
  }
1426
1581
 
1427
1582
  async pollOnce(signal?: AbortSignal): Promise<number> {
1428
- let body: {
1429
- ok?: boolean;
1430
- error_code?: number;
1431
- description?: string;
1432
- result?: Array<{ update_id: number } & Record<string, unknown>>;
1433
- };
1434
- try {
1435
- body = (await this.botApi.call(
1436
- "getUpdates",
1437
- { offset: this.offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
1438
- { signal },
1439
- )) as typeof body;
1440
- } catch (err) {
1441
- // A cooperative stop aborts the in-flight long poll; treat as a clean wake.
1442
- if (isAbortError(err)) return 0;
1443
- // A transient Telegram API failure (e.g. ECONNRESET on the long-poll) must
1444
- // never crash the daemon — that silently stops all delivery, including ask
1445
- // notifications. Log, back off, and let the run loop retry.
1446
- console.error("notifications daemon: getUpdates failed:", err);
1447
- await this.sleep(POLL_BACKOFF_MS, signal);
1448
- return 0;
1449
- }
1450
- // Telegram allows only one active getUpdates poller per bot. A 409 means
1451
- // another poller is live; back off boundedly instead of hot-looping.
1452
- if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
1453
- this.pollConflictBackoffMs = Math.min(
1454
- this.pollConflictBackoffMs ? this.pollConflictBackoffMs * 2 : 500,
1455
- 5_000,
1456
- );
1457
- console.error(
1458
- `notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${this.pollConflictBackoffMs}ms`,
1459
- );
1460
- await this.sleep(this.pollConflictBackoffMs, signal);
1461
- return 0;
1462
- }
1463
- this.pollConflictBackoffMs = 0;
1464
- for (const update of body.result ?? []) {
1465
- this.offset = update.update_id + 1;
1466
- try {
1467
- await this.handleTelegramUpdate(update);
1468
- } catch (err) {
1469
- console.error("notifications daemon: handleTelegramUpdate failed:", err);
1470
- }
1471
- }
1472
- return body.result?.length ?? 0;
1473
- }
1474
-
1475
- /** Abortable sleep honoring the injected timer; resolves early on abort. */
1476
- private sleep(ms: number, signal?: AbortSignal): Promise<void> {
1477
- return new Promise<void>(resolve => {
1478
- if (signal?.aborted) return resolve();
1479
- const timer = (this.opts.setTimeoutImpl ?? setTimeout)(() => resolve(), ms);
1480
- signal?.addEventListener(
1481
- "abort",
1482
- () => {
1483
- (this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
1484
- resolve();
1485
- },
1486
- { once: true },
1487
- );
1488
- });
1583
+ return this.poller.pollOnce(signal);
1489
1584
  }
1490
1585
 
1491
1586
  /** Sync the bot's Telegram command menu to what the daemon actually handles. */
@@ -1512,6 +1607,7 @@ export class TelegramNotificationDaemon {
1512
1607
  pid: this.opts.pid ?? process.pid,
1513
1608
  });
1514
1609
  if (!this.running) return;
1610
+ this.runtime.start();
1515
1611
  this.startFlushTimer();
1516
1612
  this.startScanTimer();
1517
1613
  this.startTypingTimer();
@@ -1520,8 +1616,7 @@ export class TelegramNotificationDaemon {
1520
1616
  await this.loadAliases();
1521
1617
  await this.loadTopics();
1522
1618
  await this.runScan();
1523
- let idleSince = (this.opts.now ?? Date.now)();
1524
- let pollBackoffMs = 0;
1619
+ let idleSince = this.runtime.now();
1525
1620
  while (this.running) {
1526
1621
  if (await this.controlStopRequested()) break;
1527
1622
  if (
@@ -1537,29 +1632,30 @@ export class TelegramNotificationDaemon {
1537
1632
  await this.runScan();
1538
1633
  if (await this.controlStopRequested()) break;
1539
1634
  if (this.sessions.size === 0) {
1540
- if ((this.opts.now ?? Date.now)() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000)) break;
1635
+ if (this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000)) break;
1541
1636
  } else {
1542
- idleSince = (this.opts.now ?? Date.now)();
1543
- this.activePoll = new AbortController();
1637
+ idleSince = this.runtime.now();
1638
+ const activePoll = this.runtime.createAbortController();
1544
1639
  try {
1545
- await this.pollOnce(this.activePoll.signal);
1546
- pollBackoffMs = 0;
1640
+ await this.pollOnce(activePoll.signal);
1641
+ this.loopBackoff.reset();
1547
1642
  } catch (e) {
1548
1643
  // A transient getUpdates/network failure must not kill the
1549
1644
  // daemon. Back off (bounded, below the heartbeat TTL) and keep
1550
1645
  // renewing ownership at the loop top.
1551
- pollBackoffMs = pollBackoffMs === 0 ? 250 : Math.min(pollBackoffMs * 2, 4_000);
1552
- logger.warn(`notifications: getUpdates failed, backing off ${pollBackoffMs}ms: ${String(e)}`);
1553
- await new Promise(resolve => (this.opts.setTimeoutImpl ?? setTimeout)(resolve, pollBackoffMs));
1646
+ const backoffMs = this.loopBackoff.next();
1647
+ logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
1648
+ await this.runtime.sleep(backoffMs);
1554
1649
  continue;
1555
1650
  } finally {
1556
- this.activePoll = undefined;
1651
+ this.runtime.clearAbortController(activePoll);
1557
1652
  }
1558
1653
  }
1559
1654
  if (await this.controlStopRequested()) break;
1560
- await new Promise(resolve => (this.opts.setTimeoutImpl ?? setTimeout)(resolve, 10));
1655
+ await this.runtime.sleep(10);
1561
1656
  }
1562
1657
  } finally {
1658
+ this.runtime.stop();
1563
1659
  this.stopFlushTimer();
1564
1660
  this.stopScanTimer();
1565
1661
  this.stopTypingTimer();
@@ -1580,7 +1676,7 @@ export class TelegramNotificationDaemon {
1580
1676
 
1581
1677
  /** True when a signal-driven stop or an owner-scoped control request asks the loop to exit. */
1582
1678
  private async controlStopRequested(): Promise<boolean> {
1583
- if (this.stopRequested) return true;
1679
+ if (this.runtime.stopRequested) return true;
1584
1680
  if (!this.opts.control) return false;
1585
1681
  try {
1586
1682
  return await this.opts.control.shouldStop(this.opts.ownerId);