@gajae-code/coding-agent 0.7.2 → 0.7.4

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 (154) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/mcp-cli.d.ts +25 -0
  4. package/dist/types/cli/plugin-cli.d.ts +2 -0
  5. package/dist/types/cli.d.ts +6 -0
  6. package/dist/types/commands/mcp.d.ts +70 -0
  7. package/dist/types/commands/plugin.d.ts +6 -0
  8. package/dist/types/commands/session.d.ts +6 -0
  9. package/dist/types/config/keybindings.d.ts +2 -2
  10. package/dist/types/config/model-profile-activation.d.ts +8 -1
  11. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  12. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  13. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  14. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  15. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  17. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  18. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  19. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  20. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  21. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  22. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  23. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  24. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  25. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  26. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  28. package/dist/types/gjc-runtime/tmux-common.d.ts +20 -1
  29. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  30. package/dist/types/main.d.ts +2 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  32. package/dist/types/modes/components/model-selector.d.ts +8 -0
  33. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  34. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  35. package/dist/types/notifications/html-format.d.ts +11 -0
  36. package/dist/types/notifications/index.d.ts +149 -1
  37. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  38. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  39. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  40. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  41. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  42. package/dist/types/notifications/recent-activity.d.ts +35 -0
  43. package/dist/types/notifications/telegram-daemon.d.ts +114 -16
  44. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  45. package/dist/types/notifications/topic-registry.d.ts +12 -9
  46. package/dist/types/runtime-mcp/types.d.ts +7 -0
  47. package/dist/types/sdk.d.ts +2 -0
  48. package/dist/types/session/agent-session.d.ts +14 -4
  49. package/dist/types/session/blob-store.d.ts +25 -0
  50. package/dist/types/session/session-manager.d.ts +57 -0
  51. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  52. package/dist/types/system-prompt.d.ts +2 -0
  53. package/dist/types/task/executor.d.ts +9 -1
  54. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  55. package/dist/types/tools/index.d.ts +3 -1
  56. package/dist/types/utils/changelog.d.ts +1 -0
  57. package/dist/types/web/insane/url-guard.d.ts +6 -3
  58. package/dist/types/web/scrapers/types.d.ts +5 -0
  59. package/dist/types/web/scrapers/utils.d.ts +7 -1
  60. package/package.json +11 -9
  61. package/scripts/g004-tmux-smoke.ts +100 -0
  62. package/scripts/g005-daemon-smoke.ts +181 -0
  63. package/scripts/g011-daemon-path-smoke.ts +153 -0
  64. package/src/cli/mcp-cli.ts +272 -0
  65. package/src/cli/plugin-cli.ts +66 -3
  66. package/src/cli.ts +27 -6
  67. package/src/commands/mcp.ts +117 -0
  68. package/src/commands/plugin.ts +4 -0
  69. package/src/commands/session.ts +18 -0
  70. package/src/config/keybindings.ts +2 -2
  71. package/src/config/model-profile-activation.ts +55 -7
  72. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +7 -6
  75. package/src/defaults/gjc/skills/team/SKILL.md +5 -3
  76. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  77. package/src/export/html/index.ts +2 -2
  78. package/src/extensibility/extensions/runner.ts +1 -0
  79. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  80. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  81. package/src/extensibility/gjc-plugins/index.ts +9 -0
  82. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  83. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  84. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  85. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  86. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  87. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  88. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  89. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  90. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  91. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  92. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  93. package/src/extensibility/gjc-plugins/types.ts +199 -3
  94. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  95. package/src/extensibility/skills.ts +15 -0
  96. package/src/gjc-runtime/launch-tmux.ts +61 -7
  97. package/src/gjc-runtime/psmux-detect.ts +239 -0
  98. package/src/gjc-runtime/team-runtime.ts +56 -23
  99. package/src/gjc-runtime/tmux-common.ts +30 -3
  100. package/src/gjc-runtime/tmux-sessions.ts +51 -1
  101. package/src/gjc-runtime/ultragoal-guard.ts +25 -8
  102. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  103. package/src/hooks/skill-state.ts +57 -0
  104. package/src/internal-urls/docs-index.generated.ts +12 -8
  105. package/src/main.ts +14 -3
  106. package/src/modes/bridge/bridge-mode.ts +11 -0
  107. package/src/modes/components/custom-editor.ts +2 -0
  108. package/src/modes/components/footer.ts +2 -3
  109. package/src/modes/components/hook-editor.ts +1 -1
  110. package/src/modes/components/hook-selector.ts +67 -43
  111. package/src/modes/components/model-selector.ts +56 -11
  112. package/src/modes/components/status-line/git-utils.ts +25 -0
  113. package/src/modes/components/status-line.ts +10 -11
  114. package/src/modes/components/welcome.ts +2 -3
  115. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  116. package/src/modes/controllers/selector-controller.ts +53 -11
  117. package/src/modes/interactive-mode.ts +4 -1
  118. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  119. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  120. package/src/modes/theme/defaults/index.ts +2 -0
  121. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  122. package/src/notifications/html-format.ts +38 -0
  123. package/src/notifications/index.ts +242 -12
  124. package/src/notifications/lifecycle-commands.ts +228 -0
  125. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  126. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  127. package/src/notifications/operator-runtime.ts +171 -0
  128. package/src/notifications/rate-limit-pool.ts +19 -0
  129. package/src/notifications/recent-activity.ts +132 -0
  130. package/src/notifications/telegram-daemon.ts +778 -257
  131. package/src/notifications/telegram-reference.ts +25 -7
  132. package/src/notifications/topic-registry.ts +23 -9
  133. package/src/prompts/agents/executor.md +2 -2
  134. package/src/runtime-mcp/transports/stdio.ts +38 -4
  135. package/src/runtime-mcp/types.ts +7 -0
  136. package/src/sdk.ts +157 -10
  137. package/src/session/agent-session.ts +166 -74
  138. package/src/session/blob-store.ts +196 -8
  139. package/src/session/session-manager.ts +678 -7
  140. package/src/slash-commands/builtin-registry.ts +23 -3
  141. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  142. package/src/slash-commands/helpers/parse.ts +2 -1
  143. package/src/system-prompt.ts +9 -0
  144. package/src/task/executor.ts +31 -7
  145. package/src/task/index.ts +2 -0
  146. package/src/tools/ask.ts +5 -1
  147. package/src/tools/bash.ts +9 -0
  148. package/src/tools/composer-bash-policy.ts +96 -0
  149. package/src/tools/fetch.ts +18 -2
  150. package/src/tools/index.ts +3 -1
  151. package/src/utils/changelog.ts +8 -0
  152. package/src/web/insane/url-guard.ts +18 -14
  153. package/src/web/scrapers/types.ts +143 -45
  154. package/src/web/scrapers/utils.ts +70 -19
@@ -10,8 +10,32 @@ import type { DaemonRuntimeInfo } from "../daemon/control-types";
10
10
  import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
11
11
  import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
12
12
  import { parseInThreadConfigCommand } from "./config-commands";
13
- import { buildButtonGrid, TELEGRAM_PARSE_MODE } from "./html-format";
13
+ import { buildCompactChoiceGrid, TELEGRAM_PARSE_MODE } from "./html-format";
14
+ import type {
15
+ SessionCloseTarget,
16
+ SessionCreateTarget,
17
+ SessionLifecycleRequest,
18
+ SessionLifecycleResponse,
19
+ SessionResumeTarget,
20
+ } from "./index";
21
+ import {
22
+ formatLifecycleOutcome,
23
+ isLifecycleCommandText,
24
+ lifecycleUsage,
25
+ parseLifecycleCommand,
26
+ validateLifecycleTarget,
27
+ } from "./lifecycle-commands";
28
+ import {
29
+ attachLifecycleControl,
30
+ buildOrchestratorDeps,
31
+ type ControlServerLike,
32
+ createNativeControlServer,
33
+ type LifecycleControlServer,
34
+ type LifecycleControlServerFactory,
35
+ } from "./lifecycle-control-runtime";
36
+ import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
14
37
  import { RateLimitPool } from "./rate-limit-pool";
38
+ import { listRecentSessions } from "./recent-activity";
15
39
  import {
16
40
  type AliasTable,
17
41
  buildActionMessage,
@@ -104,6 +128,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
104
128
  // Native reactions used as a two-stage delivery double-check on inbound thread
105
129
  // messages: queued on receipt, consumed once a turn picks the message up.
106
130
  const QUEUED_REACTION = "👀";
131
+ const PENDING_TOPIC_FRAME_LIMIT = 20;
107
132
  const CONSUMED_REACTION = "✅";
108
133
 
109
134
  /**
@@ -171,6 +196,31 @@ export function daemonPaths(agentDir: string): DaemonPaths {
171
196
  };
172
197
  }
173
198
 
199
+ /**
200
+ * Attach session-lifecycle control (create/close/resume) to the running daemon.
201
+ *
202
+ * Wires an already-started, authenticated control server to the lifecycle
203
+ * orchestrator with real daemon-side effects (tmux launcher / force-close /
204
+ * resume), a durable fsynced idempotency ledger + audit JSONL under the agent
205
+ * notifications dir, and strict paired-chat gating. The control server itself
206
+ * (NotificationControlServer) is owned/started by the daemon process; this
207
+ * function only connects it to policy. Returns the orchestrator deps for tests.
208
+ */
209
+ export function startDaemonLifecycleControl(input: {
210
+ controlServer: ControlServerLike;
211
+ pairedChatId: string;
212
+ agentDir: string;
213
+ env?: NodeJS.ProcessEnv;
214
+ }): void {
215
+ const deps = buildOrchestratorDeps({
216
+ pairedChatId: input.pairedChatId,
217
+ agentNotificationsDir: daemonPaths(input.agentDir).dir,
218
+ sessionsRoot: path.join(input.agentDir, "sessions"),
219
+ env: input.env,
220
+ });
221
+ attachLifecycleControl(input.controlServer, deps);
222
+ }
223
+
174
224
  async function ensureDir(fsImpl: TelegramDaemonFs, dir: string): Promise<void> {
175
225
  await fsImpl.mkdir(dir, { recursive: true, mode: 0o700 });
176
226
  await fsImpl.chmod(dir, 0o700).catch(() => undefined);
@@ -527,6 +577,154 @@ export interface BotApi {
527
577
  call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown>;
528
578
  }
529
579
 
580
+ export interface TelegramTransportOptions {
581
+ botToken: string;
582
+ apiBase?: string;
583
+ fetchImpl?: typeof fetch;
584
+ setTimeoutImpl?: typeof setTimeout;
585
+ }
586
+
587
+ /** Telegram Bot API transport: HTTP JSON/multipart details stay out of daemon orchestration. */
588
+ export class TelegramBotTransport implements BotApi {
589
+ #opts: TelegramTransportOptions;
590
+
591
+ constructor(opts: TelegramTransportOptions) {
592
+ this.#opts = opts;
593
+ }
594
+
595
+ async call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown> {
596
+ const apiBase = this.#opts.apiBase ?? "https://api.telegram.org";
597
+ const url = `${apiBase}/bot${this.#opts.botToken}/${method}`;
598
+ const fetchImpl = this.#opts.fetchImpl ?? fetch;
599
+ const setTimeoutImpl = this.#opts.setTimeoutImpl ?? setTimeout;
600
+ const sleep = (ms: number) => new Promise<void>(resolve => setTimeoutImpl(resolve, ms));
601
+ // sendPhoto with base64 bytes must be a multipart upload (Telegram does
602
+ // not accept base64 in JSON). Other methods stay JSON.
603
+ const photoBody = body as { photo?: unknown; mime?: unknown } | null;
604
+ if (method === "sendPhoto" && photoBody && typeof photoBody.photo === "string") {
605
+ const b = body as {
606
+ chat_id: unknown;
607
+ message_thread_id?: unknown;
608
+ photo: string;
609
+ mime?: string;
610
+ caption?: string;
611
+ parse_mode?: string;
612
+ };
613
+ const form = new FormData();
614
+ form.set("chat_id", String(b.chat_id));
615
+ if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
616
+ if (b.caption) form.set("caption", b.caption);
617
+ if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
618
+ form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
619
+ const res = await fetchWithRetry(fetchImpl, url, { method: "POST", body: form, signal: opts?.signal }, sleep);
620
+ return res.json();
621
+ }
622
+ const docBody = body as { document?: unknown } | null;
623
+ if (method === "sendDocument" && docBody && typeof docBody.document === "string") {
624
+ const b = body as {
625
+ chat_id: unknown;
626
+ message_thread_id?: unknown;
627
+ document: string;
628
+ mime?: string;
629
+ fileName?: string;
630
+ caption?: string;
631
+ parse_mode?: string;
632
+ };
633
+ const form = new FormData();
634
+ form.set("chat_id", String(b.chat_id));
635
+ if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
636
+ if (b.caption) form.set("caption", b.caption);
637
+ if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
638
+ form.set(
639
+ "document",
640
+ new Blob([Buffer.from(b.document, "base64")], { type: b.mime ?? "application/octet-stream" }),
641
+ b.fileName ?? "file",
642
+ );
643
+ const res = await fetchWithRetry(fetchImpl, url, { method: "POST", body: form, signal: opts?.signal }, sleep);
644
+ return res.json();
645
+ }
646
+ const res = await fetchWithRetry(
647
+ fetchImpl,
648
+ url,
649
+ {
650
+ method: "POST",
651
+ headers: { "content-type": "application/json" },
652
+ body: JSON.stringify(body),
653
+ signal: opts?.signal,
654
+ },
655
+ sleep,
656
+ );
657
+ return res.json();
658
+ }
659
+ }
660
+
661
+ export interface TelegramUpdatePollerOptions {
662
+ botApi: BotApi;
663
+ runtime: NotificationOperatorRuntime;
664
+ backoff: OperatorBackoffPolicy;
665
+ processUpdate: (update: unknown) => Promise<void>;
666
+ }
667
+
668
+ /** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
669
+ export class TelegramUpdatePoller {
670
+ #offset = 0;
671
+ #opts: TelegramUpdatePollerOptions;
672
+
673
+ constructor(opts: TelegramUpdatePollerOptions) {
674
+ this.#opts = opts;
675
+ }
676
+
677
+ async pollOnce(signal?: AbortSignal): Promise<number> {
678
+ let body: {
679
+ ok?: boolean;
680
+ error_code?: number;
681
+ description?: string;
682
+ result?: Array<{ update_id: number } & Record<string, unknown>>;
683
+ };
684
+ try {
685
+ body = (await this.#opts.botApi.call(
686
+ "getUpdates",
687
+ { offset: this.#offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
688
+ { signal },
689
+ )) as typeof body;
690
+ } catch (err) {
691
+ // A cooperative stop aborts the in-flight long poll; treat as a clean wake.
692
+ if (isAbortError(err)) return 0;
693
+ // A transient Telegram API failure must never crash the daemon.
694
+ logger.error("notifications daemon: getUpdates failed", { error: String(err) });
695
+ await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
696
+ return 0;
697
+ }
698
+ // Telegram allows only one active getUpdates poller per bot. A 409 means
699
+ // another poller is live; back off boundedly instead of hot-looping.
700
+ if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
701
+ const backoffMs = this.#opts.backoff.next();
702
+ logger.error(
703
+ `notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${backoffMs}ms`,
704
+ );
705
+ await this.#opts.runtime.sleep(backoffMs, signal);
706
+ return 0;
707
+ }
708
+ this.#opts.backoff.reset();
709
+ for (const update of body.result ?? []) {
710
+ this.#offset = update.update_id + 1;
711
+ try {
712
+ await this.#opts.processUpdate(update);
713
+ } catch (err) {
714
+ logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
715
+ }
716
+ }
717
+ return body.result?.length ?? 0;
718
+ }
719
+ }
720
+
721
+ /** Mutable dispatch state shared by session frames and inbound Telegram updates. */
722
+ export class TelegramEventDispatchState {
723
+ readonly busy = new Set<string>();
724
+ readonly inboundReactions = new Map<number, { messageId: number }>();
725
+ readonly seenUpdateIds = new Set<number>();
726
+ }
727
+
530
728
  /**
531
729
  * Cooperative control seam for the daemon run loop. Implemented by the
532
730
  * daemon-internal CLI / controller against the owner-scoped control-request
@@ -556,8 +754,17 @@ export interface TelegramDaemonOptions {
556
754
  idleTimeoutMs?: number;
557
755
  scanIntervalMs?: number;
558
756
  pid?: number;
757
+ /** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
758
+ pidAlive?: (pid: number) => boolean;
559
759
  botApi?: BotApi;
560
760
  control?: DaemonControlHooks;
761
+ /**
762
+ * Factory for the session-lifecycle control server. Defaults to the real
763
+ * native NotificationControlServer; tests inject a fake to verify the
764
+ * owner-bound start/stop lifecycle without a socket. When `undefined` AND no
765
+ * default applies (e.g. lifecycle control disabled), no control server starts.
766
+ */
767
+ createLifecycleControlServer?: LifecycleControlServerFactory | null;
561
768
  }
562
769
 
563
770
  interface SessionSocket {
@@ -575,37 +782,63 @@ interface SessionSocket {
575
782
  pingTimer: ReturnType<typeof setInterval> | undefined;
576
783
  }
577
784
 
785
+ interface PendingThreadedFrame {
786
+ send: ThreadedSend;
787
+ msg: Record<string, unknown>;
788
+ }
789
+
578
790
  export class TelegramNotificationDaemon {
579
791
  readonly aliasTable: AliasTable;
580
792
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
581
793
  readonly sessions = new Map<string, SessionSocket>();
794
+ private readonly runtime: NotificationOperatorRuntime;
795
+ private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
796
+ private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
797
+ private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
582
798
  private running = false;
583
- private offset = 0;
584
799
  private readonly fsImpl: TelegramDaemonFs;
585
800
  private readonly botApi: BotApi;
586
801
  private readonly topics = new TopicRegistry();
587
802
  private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
588
- private readonly seenUpdateIds = new Set<number>();
803
+ private readonly poller: TelegramUpdatePoller;
804
+ private readonly dispatchState = new TelegramEventDispatchState();
805
+ /** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
806
+ private readonly topicOwnerByIdentity = new Map<string, string>();
807
+ /** Non-identity frames held until identity creates the correct thread. */
808
+ private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
589
809
  /** True once the daemon has nudged the user to enable Threaded Mode. */
590
810
  private threadedFallbackNoticeSent = false;
591
811
  /** Sessions whose identity header was already sent flat (Threaded Mode off). */
592
812
  private readonly flatIdentitySent = new Set<string>();
593
813
  /** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
594
814
  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
815
  /** Sessions whose agent loop is currently busy (drives the typing indicator). */
600
- private readonly busy = new Set<string>();
816
+ private get busy(): Set<string> {
817
+ return this.dispatchState.busy;
818
+ }
601
819
  /** 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;
820
+ private get inboundReactions(): Map<number, { messageId: number }> {
821
+ return this.dispatchState.inboundReactions;
822
+ }
823
+ /**
824
+ * The owner-bound session-lifecycle control server (create/close/resume).
825
+ * Started in {@link run} after ownership is confirmed (so exactly one owner
826
+ * ever runs one), stopped in run()'s finally on any exit path.
827
+ */
828
+ private controlServer: LifecycleControlServer | undefined;
829
+ /** True while lifecycle control is active, so the loop keeps polling at idle. */
830
+ private lifecycleControlActive = false;
831
+ /** Control token (in-memory) the loopback client presents; never persisted/logged. */
832
+ private controlToken: string | undefined;
833
+ /** Loopback WS client to the daemon's own control endpoint (Option A real wire path). */
834
+ private controlClient: WebSocket | undefined;
835
+ /** Pending lifecycle responses awaiting a control-endpoint reply, by requestId. */
836
+ private readonly pendingLifecycle = new Map<
837
+ string,
838
+ { resolve: (r: SessionLifecycleResponse) => void; timer: ReturnType<typeof setTimeout> }
839
+ >();
840
+ /** Monotonic counter for unique lifecycle request ids. */
841
+ private lifecycleSeq = 0;
609
842
 
610
843
  /**
611
844
  * Cooperatively stop the daemon: set the stop flag and abort the in-flight
@@ -613,91 +846,368 @@ export class TelegramNotificationDaemon {
613
846
  * ~25s getUpdates timeout. Safe to call from a signal handler.
614
847
  */
615
848
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
616
- this.stopRequested = true;
849
+ this.runtime.requestStop();
617
850
  this.running = false;
618
- this.activePoll?.abort();
851
+ }
852
+
853
+ /**
854
+ * Start the owner-bound lifecycle control server and wire it to the
855
+ * orchestrator. Called from {@link run} ONLY after ownership is confirmed, so
856
+ * exactly one owner ever starts exactly one control server (no second poller
857
+ * / 409). A control-server failure degrades gracefully: the daemon keeps
858
+ * serving notifications without lifecycle control. Returns true when started.
859
+ */
860
+ private async startLifecycleControl(): Promise<boolean> {
861
+ const factory =
862
+ this.opts.createLifecycleControlServer === null
863
+ ? undefined
864
+ : (this.opts.createLifecycleControlServer ?? createNativeControlServer);
865
+ if (!factory) return false;
866
+ let server: LifecycleControlServer | undefined;
867
+ try {
868
+ // High-entropy, in-memory control token (never persisted raw / logged).
869
+ const token = crypto.randomBytes(32).toString("base64url");
870
+ const agentDir = this.opts.settings.getAgentDir();
871
+ server = factory({ token, ownerId: this.opts.ownerId, agentDir });
872
+ const deps = buildOrchestratorDeps({
873
+ pairedChatId: this.opts.chatId,
874
+ agentNotificationsDir: daemonPaths(agentDir).dir,
875
+ sessionsRoot: path.join(agentDir, "sessions"),
876
+ });
877
+ // Register the lifecycle-request handler BEFORE start(): the native
878
+ // control server captures the callback at start time, so wiring must
879
+ // precede start or forwarded requests never reach the orchestrator.
880
+ attachLifecycleControl(server, deps);
881
+ const endpoint = (await server.start()) as { url?: string } | undefined;
882
+ this.controlServer = server;
883
+ this.controlToken = token;
884
+ // Option A: connect a loopback WS client to our own control endpoint so
885
+ // parsed /session_* commands traverse the real authenticated wire path.
886
+ // Mark control active ONLY after the client is open, so a first-poll
887
+ // /session_create never races a still-CONNECTING socket.
888
+ const opened = endpoint?.url ? await this.connectControlClient(endpoint.url, token) : false;
889
+ this.lifecycleControlActive = opened;
890
+ if (!opened) {
891
+ logger.warn("notifications: lifecycle control client did not open; lifecycle commands disabled");
892
+ }
893
+ return opened;
894
+ } catch (e) {
895
+ // Never let lifecycle-control startup kill the notifications daemon.
896
+ // Stop any partially-started server so it cannot leak.
897
+ try {
898
+ server?.stop();
899
+ } catch {
900
+ // best-effort
901
+ }
902
+ logger.warn(`notifications: lifecycle control failed to start: ${String(e)}`);
903
+ this.controlServer = undefined;
904
+ this.lifecycleControlActive = false;
905
+ return false;
906
+ }
907
+ }
908
+
909
+ /** Stop the lifecycle control server (idempotent); called from run()'s finally. */
910
+ private stopLifecycleControl(): void {
911
+ this.lifecycleControlActive = false;
912
+ this.controlToken = undefined;
913
+ const client = this.controlClient;
914
+ this.controlClient = undefined;
915
+ try {
916
+ client?.close();
917
+ } catch {
918
+ // best-effort
919
+ }
920
+ // Reject any in-flight lifecycle requests so callers do not hang.
921
+ for (const [requestId, pending] of this.pendingLifecycle) {
922
+ clearTimeout(pending.timer);
923
+ pending.resolve({
924
+ type: "session_lifecycle_error",
925
+ requestId,
926
+ status: "error",
927
+ reason: "terminal_uncertain",
928
+ message: "control server stopped",
929
+ });
930
+ }
931
+ this.pendingLifecycle.clear();
932
+ const server = this.controlServer;
933
+ this.controlServer = undefined;
934
+ try {
935
+ server?.stop();
936
+ } catch (e) {
937
+ logger.warn(`notifications: lifecycle control failed to stop cleanly: ${String(e)}`);
938
+ }
939
+ }
940
+
941
+ /**
942
+ * Connect the loopback control client and resolve responses by requestId.
943
+ * Resolves true once the socket is OPEN (bounded), false on error/timeout, so
944
+ * the caller only marks lifecycle control active when commands can be sent.
945
+ */
946
+ private connectControlClient(url: string, token: string): Promise<boolean> {
947
+ return new Promise<boolean>(resolve => {
948
+ let settled = false;
949
+ const finish = (ok: boolean) => {
950
+ if (settled) return;
951
+ settled = true;
952
+ resolve(ok);
953
+ };
954
+ try {
955
+ const WsCtor = this.opts.WebSocketImpl ?? WebSocket;
956
+ const client = new WsCtor(`${url}/?token=${encodeURIComponent(token)}`);
957
+ this.controlClient = client;
958
+ const openTimer = (this.opts.setTimeoutImpl ?? setTimeout)(() => finish(false), 5_000);
959
+ client.addEventListener("open", () => {
960
+ clearTimeout(openTimer);
961
+ finish(true);
962
+ });
963
+ client.addEventListener("error", () => {
964
+ clearTimeout(openTimer);
965
+ finish(false);
966
+ });
967
+ client.addEventListener("message", (ev: MessageEvent) => {
968
+ let msg: SessionLifecycleResponse;
969
+ try {
970
+ msg = JSON.parse(String((ev as { data: unknown }).data)) as SessionLifecycleResponse;
971
+ } catch {
972
+ return;
973
+ }
974
+ const requestId = (msg as { requestId?: string }).requestId;
975
+ if (!requestId) return;
976
+ const pending = this.pendingLifecycle.get(requestId);
977
+ if (!pending) return;
978
+ clearTimeout(pending.timer);
979
+ this.pendingLifecycle.delete(requestId);
980
+ pending.resolve(msg);
981
+ });
982
+ } catch (e) {
983
+ logger.warn(`notifications: lifecycle control client failed to connect: ${String(e)}`);
984
+ finish(false);
985
+ }
986
+ });
987
+ }
988
+
989
+ /** Send a lifecycle frame over the loopback client and await the response. */
990
+ private submitLifecycleFrame(frame: SessionLifecycleRequest): Promise<SessionLifecycleResponse> {
991
+ return new Promise<SessionLifecycleResponse>(resolve => {
992
+ const client = this.controlClient;
993
+ if (!client || client.readyState !== WebSocket.OPEN) {
994
+ resolve({
995
+ type: "session_lifecycle_error",
996
+ requestId: frame.requestId,
997
+ status: "error",
998
+ reason: "terminal_uncertain",
999
+ message: "lifecycle control unavailable",
1000
+ });
1001
+ return;
1002
+ }
1003
+ const timer = (this.opts.setTimeoutImpl ?? setTimeout)(() => {
1004
+ this.pendingLifecycle.delete(frame.requestId);
1005
+ resolve({
1006
+ type: "session_lifecycle_error",
1007
+ requestId: frame.requestId,
1008
+ status: "error",
1009
+ reason: "readiness_timeout",
1010
+ message: "lifecycle request timed out",
1011
+ });
1012
+ }, 120_000);
1013
+ this.pendingLifecycle.set(frame.requestId, { resolve, timer });
1014
+ try {
1015
+ client.send(JSON.stringify(frame));
1016
+ } catch (e) {
1017
+ clearTimeout(timer);
1018
+ this.pendingLifecycle.delete(frame.requestId);
1019
+ resolve({
1020
+ type: "session_lifecycle_error",
1021
+ requestId: frame.requestId,
1022
+ status: "error",
1023
+ reason: "terminal_uncertain",
1024
+ message: `lifecycle send failed: ${String(e)}`,
1025
+ });
1026
+ }
1027
+ });
1028
+ }
1029
+
1030
+ private nextLifecycleRequestId(): string {
1031
+ this.lifecycleSeq += 1;
1032
+ return `tg-${this.opts.ownerId}-${this.lifecycleSeq}-${crypto.randomBytes(4).toString("hex")}`;
1033
+ }
1034
+
1035
+ /** Build an authenticated lifecycle frame from a parsed command + identity. */
1036
+ private buildLifecycleFrame(
1037
+ parsed:
1038
+ | { kind: "create"; target: SessionCreateTarget }
1039
+ | { kind: "close"; target: SessionCloseTarget }
1040
+ | { kind: "resume"; target: SessionResumeTarget },
1041
+ updateId: number,
1042
+ ): SessionLifecycleRequest {
1043
+ const requestId = this.nextLifecycleRequestId();
1044
+ const token = this.controlToken ?? "";
1045
+ const chatId = this.opts.chatId;
1046
+ if (parsed.kind === "create") {
1047
+ return {
1048
+ type: "session_create",
1049
+ requestId,
1050
+ lifecycleRequestId: requestId,
1051
+ intendedSessionId: `s${crypto.randomBytes(6).toString("hex")}`,
1052
+ updateId,
1053
+ chatId,
1054
+ token,
1055
+ target: parsed.target,
1056
+ };
1057
+ }
1058
+ if (parsed.kind === "close") {
1059
+ return { type: "session_close", requestId, updateId, chatId, token, target: parsed.target, force: true };
1060
+ }
1061
+ return { type: "session_resume", requestId, updateId, chatId, token, target: parsed.target };
1062
+ }
1063
+
1064
+ /**
1065
+ * Handle a paired-chat /session_* command: validate (shared validator),
1066
+ * route to the control endpoint, and reply with the outcome. Returns true
1067
+ * when the message was a lifecycle command (so the caller stops processing).
1068
+ */
1069
+ private async handleLifecycleCommand(
1070
+ text: string | undefined,
1071
+ updateId: number | undefined,
1072
+ threadId: number | undefined,
1073
+ ): Promise<boolean> {
1074
+ if (!isLifecycleCommandText(text)) return false;
1075
+ const reply = (body: string) =>
1076
+ this.botApi
1077
+ .call("sendMessage", {
1078
+ chat_id: this.opts.chatId,
1079
+ ...(threadId !== undefined ? { message_thread_id: threadId } : {}),
1080
+ text: body,
1081
+ })
1082
+ .catch(() => undefined);
1083
+
1084
+ if (!this.lifecycleControlActive) {
1085
+ await reply("Session lifecycle control is not available right now.");
1086
+ return true;
1087
+ }
1088
+ if (updateId !== undefined && this.dispatchState.seenUpdateIds.has(updateId)) return true;
1089
+ if (updateId !== undefined) this.dispatchState.seenUpdateIds.add(updateId);
1090
+
1091
+ const parsed = parseLifecycleCommand(text);
1092
+ if (parsed.kind === "none") return false;
1093
+ if (parsed.kind === "usage" || parsed.kind === "reject") {
1094
+ await reply(parsed.message);
1095
+ return true;
1096
+ }
1097
+ if (parsed.kind === "recent") {
1098
+ const recent = listRecentSessions({
1099
+ sessionsRoot: path.join(this.opts.settings.getAgentDir(), "sessions"),
1100
+ limit: 10,
1101
+ });
1102
+ const lines = recent.length
1103
+ ? recent.map(e => `\u2022 ${e.sessionId}${e.path ? ` (${e.path})` : ""}`).join("\n")
1104
+ : "No recent sessions.";
1105
+ await reply(lines);
1106
+ return true;
1107
+ }
1108
+
1109
+ // Defensive shared-validator pre-check before any effect.
1110
+ const verb =
1111
+ parsed.kind === "create" ? "session_create" : parsed.kind === "close" ? "session_close" : "session_resume";
1112
+ const valid = validateLifecycleTarget(verb, parsed.target);
1113
+ if (!valid.ok) {
1114
+ await reply(`${valid.message}\n\n${lifecycleUsage()}`);
1115
+ return true;
1116
+ }
1117
+
1118
+ const frame = this.buildLifecycleFrame(parsed, updateId ?? Date.now());
1119
+ const response = await this.submitLifecycleFrame(frame);
1120
+ await reply(this.formatLifecycleResponse(response));
1121
+ return true;
1122
+ }
1123
+
1124
+ /** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
1125
+ private formatLifecycleResponse(r: SessionLifecycleResponse): string {
1126
+ return formatLifecycleOutcome(r);
619
1127
  }
620
1128
 
621
1129
  constructor(private readonly opts: TelegramDaemonOptions) {
622
1130
  this.fsImpl = opts.fs ?? nodeFs;
623
1131
  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
- };
1132
+ this.botApi =
1133
+ opts.botApi ??
1134
+ new TelegramBotTransport({
1135
+ botToken: opts.botToken,
1136
+ apiBase: opts.apiBase,
1137
+ fetchImpl: opts.fetchImpl,
1138
+ setTimeoutImpl: opts.setTimeoutImpl,
1139
+ });
1140
+ this.runtime = new NotificationOperatorRuntime({
1141
+ now: opts.now,
1142
+ setTimeoutImpl: opts.setTimeoutImpl,
1143
+ clearTimeoutImpl: opts.clearTimeoutImpl,
1144
+ setIntervalImpl: opts.setIntervalImpl,
1145
+ clearIntervalImpl: opts.clearIntervalImpl,
1146
+ });
1147
+ this.sessionRouter = this.createSessionRouter();
700
1148
  this.pool = new RateLimitPool<{ send: ThreadedSend; topicId?: string }>({ now: opts.now });
1149
+ this.poller = new TelegramUpdatePoller({
1150
+ botApi: this.botApi,
1151
+ runtime: this.runtime,
1152
+ backoff: this.pollConflictBackoff,
1153
+ processUpdate: update => this.handleTelegramUpdate(update),
1154
+ });
1155
+ }
1156
+
1157
+ private createSessionRouter(): OperatorEventRouter<SessionSocket> {
1158
+ return new OperatorEventRouter<SessionSocket>()
1159
+ .add({
1160
+ name: "hello",
1161
+ matches: msg => msg.type === "hello",
1162
+ handle: (session, msg) => {
1163
+ const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
1164
+ if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
1165
+ session.capable = true;
1166
+ this.startLiveness(session);
1167
+ }
1168
+ },
1169
+ })
1170
+ .add({
1171
+ name: "pong",
1172
+ matches: msg => msg.type === "pong",
1173
+ handle: (session, msg) => {
1174
+ if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
1175
+ session.awaitingNonce = undefined;
1176
+ session.lastPongAt = this.runtime.now();
1177
+ }
1178
+ },
1179
+ })
1180
+ .add({
1181
+ name: "activity",
1182
+ matches: msg => msg.type === "activity",
1183
+ handle: async (session, msg) => {
1184
+ if (msg.state === "busy") {
1185
+ this.busy.add(session.sessionId);
1186
+ await this.sendTyping(session.sessionId);
1187
+ } else {
1188
+ this.busy.delete(session.sessionId);
1189
+ }
1190
+ },
1191
+ })
1192
+ .add({
1193
+ name: "inbound_ack",
1194
+ matches: msg => msg.type === "inbound_ack" && typeof msg.updateId === "number",
1195
+ handle: async (_session, msg) => {
1196
+ const target = this.inboundReactions.get(msg.updateId as number);
1197
+ if (target && msg.state === "consumed") {
1198
+ this.inboundReactions.delete(msg.updateId as number);
1199
+ await this.setReaction(target.messageId, CONSUMED_REACTION);
1200
+ }
1201
+ },
1202
+ })
1203
+ .add({
1204
+ name: "session_closed",
1205
+ matches: msg => msg.type === "session_closed",
1206
+ handle: async session => {
1207
+ this.busy.delete(session.sessionId);
1208
+ await this.deleteTopic(session.sessionId);
1209
+ },
1210
+ });
701
1211
  }
702
1212
 
703
1213
  async loadAliases(): Promise<void> {
@@ -727,6 +1237,11 @@ export class TelegramNotificationDaemon {
727
1237
  if (this.sessions.has(sessionId)) continue;
728
1238
  try {
729
1239
  const endpoint = readEndpoint(path.join(dir, file));
1240
+ // Skip endpoint files whose owning process is gone or that are
1241
+ // explicitly stale (e.g. a hard-closed session): reconnecting
1242
+ // would chase a dead, token-bearing record forever.
1243
+ const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
1244
+ if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) continue;
730
1245
  this.connectSession(sessionId, endpoint.url, endpoint.token);
731
1246
  } catch {}
732
1247
  }
@@ -762,6 +1277,12 @@ export class TelegramNotificationDaemon {
762
1277
  );
763
1278
  } catch {}
764
1279
  }
1280
+ // Eagerly create the session's Telegram topic as soon as it connects, so
1281
+ // a thread exists the moment a notifications-enabled session is live —
1282
+ // not lazily on the first delivered frame (which only arrives once the
1283
+ // user sends a prompt). A provisional "GJC <id>" name is used; the
1284
+ // identity_header frame renames it to "{repo}/{branch} - {title}" later.
1285
+ void this.ensureTopic(sessionId, this.topicNameFor(sessionId, {})).catch(() => undefined);
765
1286
  });
766
1287
  ws.addEventListener("message", ev => {
767
1288
  // Identity guard: a delayed frame from a superseded socket must not act
@@ -770,7 +1291,7 @@ export class TelegramNotificationDaemon {
770
1291
  void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(err => {
771
1292
  // Surface frame-handling failures (e.g. a rejected ask sendMessage) to
772
1293
  // the daemon log instead of an invisible unhandled rejection.
773
- console.error("notifications daemon: handleSessionMessage failed:", err);
1294
+ logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
774
1295
  });
775
1296
  });
776
1297
  ws.addEventListener("close", () => {
@@ -788,7 +1309,7 @@ export class TelegramNotificationDaemon {
788
1309
  private startLiveness(session: SessionSocket): void {
789
1310
  if (session.pingTimer) return;
790
1311
  const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
791
- const now = () => (this.opts.now ?? Date.now)();
1312
+ const now = () => this.runtime.now();
792
1313
  session.lastPongAt = now();
793
1314
  session.pingTimer = setIntervalImpl(() => {
794
1315
  if (this.sessions.get(session.sessionId) !== session) return;
@@ -851,6 +1372,60 @@ export class TelegramNotificationDaemon {
851
1372
  return `GJC ${sessionId.slice(-6)}`;
852
1373
  }
853
1374
 
1375
+ private topicIdentityKey(msg: { repo?: unknown; branch?: unknown }): string | undefined {
1376
+ const repo = typeof msg?.repo === "string" && msg.repo.trim() ? msg.repo.trim() : undefined;
1377
+ if (!repo) return undefined;
1378
+ const branch = typeof msg?.branch === "string" && msg.branch.trim() ? msg.branch.trim() : "";
1379
+ return `${repo}\0${branch}`;
1380
+ }
1381
+
1382
+ private topicIdentityBase(msg: { repo?: unknown; branch?: unknown }): string | undefined {
1383
+ const repo = typeof msg?.repo === "string" && msg.repo.trim() ? msg.repo.trim() : undefined;
1384
+ if (!repo) return undefined;
1385
+ const branch = typeof msg?.branch === "string" && msg.branch.trim() ? msg.branch.trim() : undefined;
1386
+ return branch ? `${repo}/${branch}` : repo;
1387
+ }
1388
+
1389
+ private topicOwnerForIdentity(msg: { repo?: unknown; branch?: unknown }): string | undefined {
1390
+ const identityKey = this.topicIdentityKey(msg);
1391
+ const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
1392
+ if (remembered && this.topics.get(remembered)) return remembered;
1393
+ const base = this.topicIdentityBase(msg);
1394
+ if (!identityKey || !base) return undefined;
1395
+ for (const sessionId of this.topics.sessionIds()) {
1396
+ const name = this.topics.get(sessionId)?.name;
1397
+ if (name === base || name?.startsWith(`${base} - `)) {
1398
+ this.topicOwnerByIdentity.set(identityKey, sessionId);
1399
+ return sessionId;
1400
+ }
1401
+ }
1402
+ return undefined;
1403
+ }
1404
+
1405
+ private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
1406
+ this.pool.submit({
1407
+ sessionId,
1408
+ lane: send.lane,
1409
+ coalesceKey: send.coalesceKey,
1410
+ payload: { send, topicId },
1411
+ });
1412
+ await this.flushPool();
1413
+ }
1414
+
1415
+ private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
1416
+ const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
1417
+ frames.push({ send, msg });
1418
+ if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
1419
+ this.pendingThreadedFrames.set(sessionId, frames);
1420
+ }
1421
+
1422
+ private async flushPendingThreadedFrames(sessionId: string, topicId: string): Promise<void> {
1423
+ const frames = this.pendingThreadedFrames.get(sessionId);
1424
+ if (!frames || frames.length === 0) return;
1425
+ this.pendingThreadedFrames.delete(sessionId);
1426
+ for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicId);
1427
+ }
1428
+
854
1429
  /**
855
1430
  * Resolve (creating once via `createForumTopic`) the forum topic for a
856
1431
  * session. On capability failure (e.g. Threaded Mode off) this returns
@@ -873,8 +1448,12 @@ export class TelegramNotificationDaemon {
873
1448
  return String(tid);
874
1449
  },
875
1450
  this.opts.now,
1451
+ // The create winner records the name it actually used; callers that
1452
+ // merely JOIN an in-flight create must not overwrite it locally, or a
1453
+ // later identity rename would be wrongly skipped (topic stuck at the
1454
+ // provisional name on Telegram).
1455
+ name,
876
1456
  );
877
- this.topics.applyName(sessionId, name);
878
1457
  await this.persistTopics();
879
1458
  return rec.topicId;
880
1459
  } catch {
@@ -882,6 +1461,31 @@ export class TelegramNotificationDaemon {
882
1461
  }
883
1462
  }
884
1463
 
1464
+ /** Best-effort delete of a session topic once its local notification endpoint shuts down. */
1465
+ private async deleteTopic(sessionId: string): Promise<void> {
1466
+ const record = this.topics.get(sessionId);
1467
+ if (!record) return;
1468
+ try {
1469
+ // Drop queued sends for this session before deleting the topic; otherwise
1470
+ // rate-limited frames can flush later into a deleted topic or across resume.
1471
+ this.pool.removeWhere(item => item.sessionId === sessionId);
1472
+ await this.flushPool();
1473
+ const res = (await this.botApi.call("deleteForumTopic", {
1474
+ chat_id: this.opts.chatId,
1475
+ message_thread_id: Number(record.topicId),
1476
+ })) as { ok?: boolean };
1477
+ if (res?.ok === false) return;
1478
+ this.topics.delete(sessionId);
1479
+ this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
1480
+ if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
1481
+ });
1482
+ this.pendingThreadedFrames.delete(sessionId);
1483
+ await this.persistTopics();
1484
+ } catch {
1485
+ // Best-effort: missing Telegram topic permissions must not stop teardown.
1486
+ }
1487
+ }
1488
+
885
1489
  private async persistTopics(): Promise<void> {
886
1490
  const paths = daemonPaths(this.opts.settings.getAgentDir());
887
1491
  await ensureDir(this.fsImpl, paths.dir);
@@ -1092,46 +1696,32 @@ export class TelegramNotificationDaemon {
1092
1696
  }
1093
1697
 
1094
1698
  private startFlushTimer(): void {
1095
- if (this.flushTimer) return;
1096
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
1097
- this.flushTimer = setIntervalImpl(() => {
1699
+ this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
1098
1700
  if (!this.running || this.pool.pending === 0) return;
1099
1701
  void this.flushPool();
1100
- }, RATE_LIMIT_FLUSH_INTERVAL_MS);
1702
+ });
1101
1703
  }
1102
1704
 
1103
1705
  private stopFlushTimer(): void {
1104
- if (!this.flushTimer) return;
1105
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
1106
- clearIntervalImpl(this.flushTimer);
1107
- this.flushTimer = undefined;
1706
+ this.runtime.stopInterval("telegram-flush");
1108
1707
  }
1109
1708
 
1110
1709
  /** Run a root scan, guarding against overlapping scans from the timer + loop. */
1111
1710
  private async runScan(): Promise<void> {
1112
- if (this.scanning) return;
1113
- this.scanning = true;
1114
- try {
1711
+ await this.runtime.runExclusive("telegram-scan", async () => {
1115
1712
  await this.scanRoots();
1116
- } finally {
1117
- this.scanning = false;
1118
- }
1713
+ });
1119
1714
  }
1120
1715
 
1121
1716
  private startScanTimer(): void {
1122
- if (this.scanTimer) return;
1123
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
1124
- this.scanTimer = setIntervalImpl(() => {
1717
+ this.runtime.startInterval("telegram-scan", this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS, () => {
1125
1718
  if (!this.running) return;
1126
1719
  void this.runScan();
1127
- }, this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS);
1720
+ });
1128
1721
  }
1129
1722
 
1130
1723
  private stopScanTimer(): void {
1131
- if (!this.scanTimer) return;
1132
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
1133
- clearIntervalImpl(this.scanTimer);
1134
- this.scanTimer = undefined;
1724
+ this.runtime.stopInterval("telegram-scan");
1135
1725
  }
1136
1726
 
1137
1727
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
@@ -1163,67 +1753,43 @@ export class TelegramNotificationDaemon {
1163
1753
  }
1164
1754
 
1165
1755
  private startTypingTimer(): void {
1166
- if (this.typingTimer) return;
1167
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
1168
- this.typingTimer = setIntervalImpl(() => {
1756
+ this.runtime.startInterval("telegram-typing", TYPING_REFRESH_INTERVAL_MS, () => {
1169
1757
  if (!this.running || this.busy.size === 0) return;
1170
1758
  for (const sessionId of this.busy) void this.sendTyping(sessionId);
1171
- }, TYPING_REFRESH_INTERVAL_MS);
1759
+ });
1172
1760
  }
1173
1761
 
1174
1762
  private stopTypingTimer(): void {
1175
- if (!this.typingTimer) return;
1176
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
1177
- clearIntervalImpl(this.typingTimer);
1178
- this.typingTimer = undefined;
1763
+ this.runtime.stopInterval("telegram-typing");
1179
1764
  }
1180
1765
 
1181
1766
  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
- }
1767
+ if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
1218
1768
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
1219
1769
  const send = renderThreadedFrame(msg);
1220
1770
  if (!send) return;
1221
- const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
1771
+ const existingTopic = this.topics.get(session.sessionId)?.topicId;
1772
+ if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
1773
+ this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
1774
+ return;
1775
+ }
1776
+ if (send.identity) {
1777
+ const ownerId = this.topicOwnerForIdentity(msg);
1778
+ const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
1779
+ if (ownerId && ownerId !== session.sessionId && ownerTopic) {
1780
+ await this.flushPendingThreadedFrames(session.sessionId, ownerTopic.topicId);
1781
+ return;
1782
+ }
1783
+ }
1784
+ const topicId =
1785
+ existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
1222
1786
  if (!topicId) {
1223
1787
  await this.deliverFlatFallback(session.sessionId, send);
1224
1788
  return;
1225
1789
  }
1226
1790
  if (send.identity) {
1791
+ const identityKey = this.topicIdentityKey(msg);
1792
+ if (identityKey) this.topicOwnerByIdentity.set(identityKey, session.sessionId);
1227
1793
  // Rename the topic if the title changed (e.g. the session title was
1228
1794
  // auto-generated after the topic was first created). This runs on
1229
1795
  // every identity frame, but does NOT re-send the bulleted message.
@@ -1241,25 +1807,14 @@ export class TelegramNotificationDaemon {
1241
1807
  }
1242
1808
  // Send the full bulleted identity header EXACTLY ONCE per topic.
1243
1809
  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();
1810
+ await this.submitThreadedFrame(session.sessionId, send, topicId);
1251
1811
  this.topics.markIdentitySent(session.sessionId);
1252
1812
  }
1813
+ await this.flushPendingThreadedFrames(session.sessionId, topicId);
1253
1814
  await this.persistTopics();
1254
1815
  return;
1255
1816
  }
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();
1817
+ await this.submitThreadedFrame(session.sessionId, send, topicId);
1263
1818
  return;
1264
1819
  }
1265
1820
  if (msg.type === "action_needed" && msg.id) {
@@ -1279,9 +1834,9 @@ export class TelegramNotificationDaemon {
1279
1834
  summary: msg.summary,
1280
1835
  });
1281
1836
  const options = Array.isArray(msg.options) ? msg.options : [];
1282
- // Daemon keyboards MUST use alias callback data (not reference encodeCallbackData).
1283
- // Labels show one-based numbers; the stored alias answer stays zero-based.
1284
- const inline_keyboard = buildButtonGrid(options, (i: number) =>
1837
+ // Daemon keyboards use alias callback data with compact one-based tap targets;
1838
+ // full option text is rendered in the message body by buildActionMessage.
1839
+ const inline_keyboard = buildCompactChoiceGrid(options, (i: number) =>
1285
1840
  this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
1286
1841
  );
1287
1842
  const result = (await this.botApi.call("sendMessage", {
@@ -1325,6 +1880,20 @@ export class TelegramNotificationDaemon {
1325
1880
  }
1326
1881
 
1327
1882
  async handleTelegramUpdate(update: unknown): Promise<void> {
1883
+ // Session-lifecycle command (/session_*): handled ONLY from the paired chat,
1884
+ // gated before any arg parsing or side effect, and routed through the control
1885
+ // endpoint. Must run before threaded-injection so commands are not treated as
1886
+ // session input.
1887
+ {
1888
+ const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
1889
+ const chatId = (m?.chat as { id?: unknown } | undefined)?.id;
1890
+ const cmdText = typeof m?.text === "string" ? m.text : undefined;
1891
+ if (m !== undefined && String(chatId) === String(this.opts.chatId) && isLifecycleCommandText(cmdText)) {
1892
+ const updateId = (update as { update_id?: number }).update_id;
1893
+ const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
1894
+ if (await this.handleLifecycleCommand(cmdText, updateId, threadId)) return;
1895
+ }
1896
+ }
1328
1897
  // Threaded injection: a free-text message in a known topic (not a button
1329
1898
  // tap and not a reply to a specific ask message) injects a user turn or an
1330
1899
  // in-thread config command. Fail-closed: paired chat + known topic +
@@ -1343,11 +1912,11 @@ export class TelegramNotificationDaemon {
1343
1912
  const inbound = decideThreadedInbound(update as never, {
1344
1913
  pairedChatId: this.opts.chatId,
1345
1914
  topicToSession: t => this.topics.sessionForTopic(t),
1346
- isDuplicate: id => this.seenUpdateIds.has(id),
1915
+ isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
1347
1916
  });
1348
1917
  if (inbound.kind === "duplicate") return;
1349
1918
  if (inbound.kind === "inject") {
1350
- this.seenUpdateIds.add(inbound.updateId);
1919
+ this.dispatchState.seenUpdateIds.add(inbound.updateId);
1351
1920
  const session = this.sessions.get(inbound.sessionId);
1352
1921
  if (session?.ws.readyState === WebSocket.OPEN) {
1353
1922
  const attachmentResult = inbound.attachment
@@ -1425,67 +1994,7 @@ export class TelegramNotificationDaemon {
1425
1994
  }
1426
1995
 
1427
1996
  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
- });
1997
+ return this.poller.pollOnce(signal);
1489
1998
  }
1490
1999
 
1491
2000
  /** Sync the bot's Telegram command menu to what the daemon actually handles. */
@@ -1512,6 +2021,7 @@ export class TelegramNotificationDaemon {
1512
2021
  pid: this.opts.pid ?? process.pid,
1513
2022
  });
1514
2023
  if (!this.running) return;
2024
+ this.runtime.start();
1515
2025
  this.startFlushTimer();
1516
2026
  this.startScanTimer();
1517
2027
  this.startTypingTimer();
@@ -1520,8 +2030,10 @@ export class TelegramNotificationDaemon {
1520
2030
  await this.loadAliases();
1521
2031
  await this.loadTopics();
1522
2032
  await this.runScan();
1523
- let idleSince = (this.opts.now ?? Date.now)();
1524
- let pollBackoffMs = 0;
2033
+ // Owner-only: start the session-lifecycle control server now that
2034
+ // ownership is confirmed (singleton-safe). Best-effort; degrades.
2035
+ await this.startLifecycleControl();
2036
+ let idleSince = this.runtime.now();
1525
2037
  while (this.running) {
1526
2038
  if (await this.controlStopRequested()) break;
1527
2039
  if (
@@ -1536,33 +2048,42 @@ export class TelegramNotificationDaemon {
1536
2048
  break;
1537
2049
  await this.runScan();
1538
2050
  if (await this.controlStopRequested()) break;
1539
- if (this.sessions.size === 0) {
1540
- if ((this.opts.now ?? Date.now)() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000)) break;
2051
+ const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
2052
+ if (this.sessions.size === 0 && !this.lifecycleControlActive) {
2053
+ // No sessions and no lifecycle control: idle-exit on timeout.
2054
+ if (idleElapsed) break;
1541
2055
  } else {
1542
- idleSince = (this.opts.now ?? Date.now)();
1543
- this.activePoll = new AbortController();
2056
+ // Poll getUpdates when sessions exist OR lifecycle control is active
2057
+ // (so phone /session_* commands are received even with zero sessions).
2058
+ // With zero sessions, still idle-exit after the timeout so the owner
2059
+ // does not run forever; an active session resets the idle window.
2060
+ if (this.sessions.size > 0) idleSince = this.runtime.now();
2061
+ else if (idleElapsed) break;
2062
+ const activePoll = this.runtime.createAbortController();
1544
2063
  try {
1545
- await this.pollOnce(this.activePoll.signal);
1546
- pollBackoffMs = 0;
2064
+ await this.pollOnce(activePoll.signal);
2065
+ this.loopBackoff.reset();
1547
2066
  } catch (e) {
1548
2067
  // A transient getUpdates/network failure must not kill the
1549
2068
  // daemon. Back off (bounded, below the heartbeat TTL) and keep
1550
2069
  // 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));
2070
+ const backoffMs = this.loopBackoff.next();
2071
+ logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
2072
+ await this.runtime.sleep(backoffMs);
1554
2073
  continue;
1555
2074
  } finally {
1556
- this.activePoll = undefined;
2075
+ this.runtime.clearAbortController(activePoll);
1557
2076
  }
1558
2077
  }
1559
2078
  if (await this.controlStopRequested()) break;
1560
- await new Promise(resolve => (this.opts.setTimeoutImpl ?? setTimeout)(resolve, 10));
2079
+ await this.runtime.sleep(10);
1561
2080
  }
1562
2081
  } finally {
2082
+ this.runtime.stop();
1563
2083
  this.stopFlushTimer();
1564
2084
  this.stopScanTimer();
1565
2085
  this.stopTypingTimer();
2086
+ this.stopLifecycleControl();
1566
2087
  await this.cleanupAllAttachmentDirs();
1567
2088
  // Persist durable state before releasing ownership so a fresh daemon
1568
2089
  // (e.g. after reload) reloads aliases/topics seamlessly.
@@ -1580,7 +2101,7 @@ export class TelegramNotificationDaemon {
1580
2101
 
1581
2102
  /** True when a signal-driven stop or an owner-scoped control request asks the loop to exit. */
1582
2103
  private async controlStopRequested(): Promise<boolean> {
1583
- if (this.stopRequested) return true;
2104
+ if (this.runtime.stopRequested) return true;
1584
2105
  if (!this.opts.control) return false;
1585
2106
  try {
1586
2107
  return await this.opts.control.shouldStop(this.opts.ownerId);