@sayknow-cli/coding-agent 0.3.0 → 0.3.2

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 (233) hide show
  1. package/bin/skc.js +4 -0
  2. package/dist/types/cli/mcp-cli.d.ts +25 -0
  3. package/dist/types/cli/notify-cli.d.ts +2 -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/config/settings-schema.d.ts +39 -2
  12. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  13. package/dist/types/defaults/skc-defaults.d.ts +2 -13
  14. package/dist/types/extensibility/shared-events.d.ts +1 -0
  15. package/dist/types/extensibility/skc-plugins/compiler.d.ts +19 -0
  16. package/dist/types/extensibility/skc-plugins/constrained-hooks.d.ts +29 -0
  17. package/dist/types/extensibility/skc-plugins/index.d.ts +9 -0
  18. package/dist/types/extensibility/skc-plugins/injection.d.ts +9 -0
  19. package/dist/types/extensibility/skc-plugins/installer.d.ts +13 -0
  20. package/dist/types/extensibility/skc-plugins/mcp-policy.d.ts +26 -0
  21. package/dist/types/extensibility/skc-plugins/observability.d.ts +27 -0
  22. package/dist/types/extensibility/skc-plugins/prompt-appendix.d.ts +16 -0
  23. package/dist/types/extensibility/skc-plugins/registry.d.ts +32 -0
  24. package/dist/types/extensibility/skc-plugins/runtime-adapters.d.ts +64 -0
  25. package/dist/types/extensibility/skc-plugins/session-validation.d.ts +42 -0
  26. package/dist/types/extensibility/skc-plugins/types.d.ts +158 -2
  27. package/dist/types/extensibility/skc-plugins/validation.d.ts +8 -1
  28. package/dist/types/lsp/types.d.ts +2 -0
  29. package/dist/types/main.d.ts +2 -0
  30. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  31. package/dist/types/modes/components/model-selector.d.ts +8 -0
  32. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  33. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  34. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  35. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  36. package/dist/types/notifications/config.d.ts +9 -1
  37. package/dist/types/notifications/engine.d.ts +59 -0
  38. package/dist/types/notifications/html-format.d.ts +11 -0
  39. package/dist/types/notifications/index.d.ts +149 -1
  40. package/dist/types/notifications/lifecycle-commands.d.ts +60 -0
  41. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  42. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  43. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  44. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  45. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  46. package/dist/types/notifications/recent-activity.d.ts +35 -0
  47. package/dist/types/notifications/telegram-daemon.d.ts +133 -16
  48. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  49. package/dist/types/notifications/threaded-inbound.d.ts +19 -0
  50. package/dist/types/notifications/threaded-render.d.ts +6 -1
  51. package/dist/types/notifications/topic-registry.d.ts +12 -9
  52. package/dist/types/runtime-mcp/types.d.ts +7 -0
  53. package/dist/types/sdk.d.ts +2 -0
  54. package/dist/types/session/agent-session.d.ts +16 -4
  55. package/dist/types/session/blob-store.d.ts +25 -0
  56. package/dist/types/session/session-manager.d.ts +57 -0
  57. package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
  58. package/dist/types/skc-runtime/psmux-detect.d.ts +78 -0
  59. package/dist/types/skc-runtime/ralplan-runtime.d.ts +1 -1
  60. package/dist/types/skc-runtime/team-runtime.d.ts +2 -0
  61. package/dist/types/skc-runtime/tmux-common.d.ts +30 -2
  62. package/dist/types/skc-runtime/tmux-sessions.d.ts +18 -0
  63. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  64. package/dist/types/system-prompt.d.ts +2 -0
  65. package/dist/types/task/executor.d.ts +9 -1
  66. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  67. package/dist/types/tools/fetch.d.ts +23 -0
  68. package/dist/types/tools/index.d.ts +4 -1
  69. package/dist/types/tools/telegram-send.d.ts +32 -0
  70. package/dist/types/utils/changelog.d.ts +1 -0
  71. package/dist/types/web/insane/bridge.d.ts +103 -0
  72. package/dist/types/web/insane/url-guard.d.ts +25 -0
  73. package/dist/types/web/scrapers/types.d.ts +5 -0
  74. package/dist/types/web/scrapers/utils.d.ts +7 -1
  75. package/dist/types/web/search/provider.d.ts +18 -1
  76. package/dist/types/web/search/providers/insane.d.ts +53 -0
  77. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  78. package/dist/types/web/search/types.d.ts +12 -4
  79. package/package.json +14 -10
  80. package/scripts/g004-tmux-smoke.ts +100 -0
  81. package/scripts/g005-daemon-smoke.ts +180 -0
  82. package/scripts/g011-daemon-path-smoke.ts +153 -0
  83. package/scripts/verify-insane-vendor.ts +132 -0
  84. package/src/cli/args.ts +1 -1
  85. package/src/cli/fast-help.ts +30 -2
  86. package/src/cli/mcp-cli.ts +272 -0
  87. package/src/cli/notify-cli.ts +152 -5
  88. package/src/cli/plugin-cli.ts +66 -3
  89. package/src/cli.ts +30 -12
  90. package/src/commands/mcp.ts +117 -0
  91. package/src/commands/plugin.ts +4 -0
  92. package/src/commands/session.ts +18 -0
  93. package/src/commands/team.ts +1 -1
  94. package/src/config/keybindings.ts +2 -2
  95. package/src/config/model-profile-activation.ts +55 -7
  96. package/src/config/settings-schema.ts +30 -1
  97. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  98. package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +1 -1
  99. package/src/defaults/skc/skills/deep-interview/SKILL.md +7 -6
  100. package/src/defaults/skc/skills/ralplan/SKILL.md +11 -4
  101. package/src/defaults/skc/skills/team/SKILL.md +5 -3
  102. package/src/defaults/skc/skills/ultragoal/SKILL.md +41 -13
  103. package/src/defaults/skc-defaults.ts +2 -27
  104. package/src/export/html/index.ts +2 -2
  105. package/src/extensibility/extensions/runner.ts +1 -0
  106. package/src/extensibility/shared-events.ts +1 -0
  107. package/src/extensibility/skc-plugins/compiler.ts +351 -0
  108. package/src/extensibility/skc-plugins/constrained-hooks.ts +170 -0
  109. package/src/extensibility/skc-plugins/index.ts +9 -0
  110. package/src/extensibility/skc-plugins/injection.ts +109 -0
  111. package/src/extensibility/skc-plugins/installer.ts +434 -0
  112. package/src/extensibility/skc-plugins/loader.ts +3 -1
  113. package/src/extensibility/skc-plugins/mcp-policy.ts +239 -0
  114. package/src/extensibility/skc-plugins/observability.ts +84 -0
  115. package/src/extensibility/skc-plugins/paths.ts +1 -1
  116. package/src/extensibility/skc-plugins/prompt-appendix.ts +109 -0
  117. package/src/extensibility/skc-plugins/registry.ts +180 -0
  118. package/src/extensibility/skc-plugins/runtime-adapters.ts +234 -0
  119. package/src/extensibility/skc-plugins/schema.ts +250 -20
  120. package/src/extensibility/skc-plugins/session-validation.ts +147 -0
  121. package/src/extensibility/skc-plugins/types.ts +199 -3
  122. package/src/extensibility/skc-plugins/validation.ts +80 -0
  123. package/src/extensibility/skills.ts +15 -0
  124. package/src/hooks/skill-state.ts +57 -0
  125. package/src/internal-urls/docs-index.generated.ts +17 -13
  126. package/src/lsp/config.ts +16 -3
  127. package/src/lsp/defaults.json +7 -0
  128. package/src/lsp/types.ts +2 -0
  129. package/src/main.ts +14 -3
  130. package/src/modes/bridge/bridge-mode.ts +11 -0
  131. package/src/modes/components/assistant-message.ts +49 -1
  132. package/src/modes/components/custom-editor.ts +2 -0
  133. package/src/modes/components/footer.ts +2 -3
  134. package/src/modes/components/hook-editor.ts +1 -1
  135. package/src/modes/components/hook-selector.ts +67 -43
  136. package/src/modes/components/model-selector.ts +56 -11
  137. package/src/modes/components/status-line/git-utils.ts +25 -0
  138. package/src/modes/components/status-line.ts +10 -11
  139. package/src/modes/components/welcome.ts +2 -3
  140. package/src/modes/controllers/event-controller.ts +15 -0
  141. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  142. package/src/modes/controllers/selector-controller.ts +53 -11
  143. package/src/modes/interactive-mode.ts +50 -3
  144. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  145. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  146. package/src/modes/theme/defaults/index.ts +2 -0
  147. package/src/modes/utils/context-usage.ts +2 -2
  148. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  149. package/src/notifications/attachment-registry.ts +23 -0
  150. package/src/notifications/chat-adapters.ts +147 -0
  151. package/src/notifications/config.ts +23 -2
  152. package/src/notifications/engine.ts +100 -0
  153. package/src/notifications/html-format.ts +38 -0
  154. package/src/notifications/index.ts +417 -45
  155. package/src/notifications/lifecycle-commands.ts +238 -0
  156. package/src/notifications/lifecycle-control-runtime.ts +405 -0
  157. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  158. package/src/notifications/managed-daemon.ts +163 -0
  159. package/src/notifications/operator-runtime.ts +171 -0
  160. package/src/notifications/rate-limit-pool.ts +19 -0
  161. package/src/notifications/recent-activity.ts +132 -0
  162. package/src/notifications/telegram-daemon.ts +984 -242
  163. package/src/notifications/telegram-reference.ts +25 -7
  164. package/src/notifications/threaded-inbound.ts +60 -4
  165. package/src/notifications/threaded-render.ts +20 -2
  166. package/src/notifications/topic-registry.ts +23 -9
  167. package/src/prompts/agents/executor.md +2 -2
  168. package/src/runtime-mcp/transports/stdio.ts +38 -4
  169. package/src/runtime-mcp/types.ts +7 -0
  170. package/src/sdk.ts +157 -10
  171. package/src/session/agent-session.ts +248 -125
  172. package/src/session/blob-store.ts +196 -8
  173. package/src/session/session-manager.ts +762 -12
  174. package/src/skc-runtime/launch-tmux.ts +85 -22
  175. package/src/skc-runtime/ledger-event-renderer.ts +1 -0
  176. package/src/skc-runtime/psmux-detect.ts +239 -0
  177. package/src/skc-runtime/ralplan-runtime.ts +2 -2
  178. package/src/skc-runtime/team-runtime.ts +56 -23
  179. package/src/skc-runtime/tmux-common.ts +88 -4
  180. package/src/skc-runtime/tmux-sessions.ts +111 -9
  181. package/src/skc-runtime/ultragoal-guard.ts +25 -8
  182. package/src/skc-runtime/ultragoal-runtime.ts +75 -15
  183. package/src/skc-runtime/workflow-manifest.generated.json +29 -0
  184. package/src/skc-runtime/workflow-manifest.ts +7 -2
  185. package/src/slash-commands/builtin-registry.ts +23 -3
  186. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  187. package/src/slash-commands/helpers/parse.ts +2 -1
  188. package/src/system-prompt.ts +9 -0
  189. package/src/task/executor.ts +31 -7
  190. package/src/task/index.ts +2 -0
  191. package/src/tools/ask.ts +5 -1
  192. package/src/tools/bash.ts +9 -0
  193. package/src/tools/composer-bash-policy.ts +96 -0
  194. package/src/tools/fetch.ts +94 -1
  195. package/src/tools/index.ts +6 -1
  196. package/src/tools/telegram-send.ts +137 -0
  197. package/src/utils/changelog.ts +8 -0
  198. package/src/web/insane/bridge.ts +350 -0
  199. package/src/web/insane/url-guard.ts +159 -0
  200. package/src/web/scrapers/types.ts +143 -45
  201. package/src/web/scrapers/utils.ts +70 -19
  202. package/src/web/search/provider.ts +77 -18
  203. package/src/web/search/providers/anthropic.ts +70 -3
  204. package/src/web/search/providers/codex.ts +1 -119
  205. package/src/web/search/providers/gemini.ts +99 -0
  206. package/src/web/search/providers/insane.ts +551 -0
  207. package/src/web/search/providers/openai-compatible.ts +66 -32
  208. package/src/web/search/providers/text-citations.ts +111 -0
  209. package/src/web/search/types.ts +13 -2
  210. package/vendor/insane-search/LICENSE +21 -0
  211. package/vendor/insane-search/MANIFEST.json +24 -0
  212. package/vendor/insane-search/engine/__init__.py +23 -0
  213. package/vendor/insane-search/engine/__main__.py +128 -0
  214. package/vendor/insane-search/engine/bias_check.py +183 -0
  215. package/vendor/insane-search/engine/executor.py +254 -0
  216. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  217. package/vendor/insane-search/engine/learning.py +175 -0
  218. package/vendor/insane-search/engine/phase0.py +214 -0
  219. package/vendor/insane-search/engine/safety.py +91 -0
  220. package/vendor/insane-search/engine/templates/package.json +11 -0
  221. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  222. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  223. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  224. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  225. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  226. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  227. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  228. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  229. package/vendor/insane-search/engine/transport.py +211 -0
  230. package/vendor/insane-search/engine/url_transforms.py +98 -0
  231. package/vendor/insane-search/engine/validators.py +331 -0
  232. package/vendor/insane-search/engine/waf_detector.py +214 -0
  233. package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
@@ -1,5 +1,7 @@
1
1
  import { spawn as childProcessSpawn } from "node:child_process";
2
+ import * as crypto from "node:crypto";
2
3
  import * as fs from "node:fs";
4
+ import * as os from "node:os";
3
5
  import * as path from "node:path";
4
6
  import { logger } from "@sayknow-cli/utils";
5
7
  import { withFileLock } from "../config/file-lock";
@@ -8,8 +10,32 @@ import type { DaemonRuntimeInfo } from "../daemon/control-types";
8
10
  import { resolveSkcRuntimeSpawnInfo } from "../daemon/runtime";
9
11
  import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
10
12
  import { parseInThreadConfigCommand } from "./config-commands";
11
- 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";
12
37
  import { RateLimitPool } from "./rate-limit-pool";
38
+ import { listRecentSessions } from "./recent-activity";
13
39
  import {
14
40
  type AliasTable,
15
41
  buildActionMessage,
@@ -19,7 +45,7 @@ import {
19
45
  readEndpoint,
20
46
  routeInboundUpdate,
21
47
  } from "./telegram-reference";
22
- import { decideThreadedInbound } from "./threaded-inbound";
48
+ import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
23
49
  import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
24
50
  import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
25
51
 
@@ -102,6 +128,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
102
128
  // Native reactions used as a two-stage delivery double-check on inbound thread
103
129
  // messages: queued on receipt, consumed once a turn picks the message up.
104
130
  const QUEUED_REACTION = "👀";
131
+ const PENDING_TOPIC_FRAME_LIMIT = 20;
105
132
  const CONSUMED_REACTION = "✅";
106
133
 
107
134
  /**
@@ -169,6 +196,31 @@ export function daemonPaths(agentDir: string): DaemonPaths {
169
196
  };
170
197
  }
171
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
+
172
224
  async function ensureDir(fsImpl: TelegramDaemonFs, dir: string): Promise<void> {
173
225
  await fsImpl.mkdir(dir, { recursive: true, mode: 0o700 });
174
226
  await fsImpl.chmod(dir, 0o700).catch(() => undefined);
@@ -525,6 +577,154 @@ export interface BotApi {
525
577
  call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown>;
526
578
  }
527
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
+
528
728
  /**
529
729
  * Cooperative control seam for the daemon run loop. Implemented by the
530
730
  * daemon-internal CLI / controller against the owner-scoped control-request
@@ -554,8 +754,17 @@ export interface TelegramDaemonOptions {
554
754
  idleTimeoutMs?: number;
555
755
  scanIntervalMs?: number;
556
756
  pid?: number;
757
+ /** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
758
+ pidAlive?: (pid: number) => boolean;
557
759
  botApi?: BotApi;
558
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;
559
768
  }
560
769
 
561
770
  interface SessionSocket {
@@ -573,31 +782,63 @@ interface SessionSocket {
573
782
  pingTimer: ReturnType<typeof setInterval> | undefined;
574
783
  }
575
784
 
785
+ interface PendingThreadedFrame {
786
+ send: ThreadedSend;
787
+ msg: Record<string, unknown>;
788
+ }
789
+
576
790
  export class TelegramNotificationDaemon {
577
791
  readonly aliasTable: AliasTable;
578
792
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
579
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 });
580
798
  private running = false;
581
- private offset = 0;
582
799
  private readonly fsImpl: TelegramDaemonFs;
583
800
  private readonly botApi: BotApi;
584
801
  private readonly topics = new TopicRegistry();
585
- private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId: string }>;
586
- private readonly seenUpdateIds = new Set<number>();
587
- private flushTimer: ReturnType<typeof setInterval> | undefined;
588
- private scanTimer: ReturnType<typeof setInterval> | undefined;
589
- private scanning = false;
590
- private typingTimer: ReturnType<typeof setInterval> | undefined;
802
+ private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
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[]>();
809
+ /** True once the daemon has nudged the user to enable Threaded Mode. */
810
+ private threadedFallbackNoticeSent = false;
811
+ /** Sessions whose identity header was already sent flat (Threaded Mode off). */
812
+ private readonly flatIdentitySent = new Set<string>();
813
+ /** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
814
+ private pairedChatPrivate: boolean | undefined;
591
815
  /** Sessions whose agent loop is currently busy (drives the typing indicator). */
592
- private readonly busy = new Set<string>();
816
+ private get busy(): Set<string> {
817
+ return this.dispatchState.busy;
818
+ }
593
819
  /** Inbound update id → originating Telegram message, for delivery reactions. */
594
- private readonly inboundReactions = new Map<number, { messageId: number }>();
595
- /** AbortController for the in-flight long poll; aborted by requestStop() to wake the loop. */
596
- private activePoll: AbortController | undefined;
597
- /** Set when a cooperative stop has been requested (signal or control request). */
598
- private stopRequested = false;
599
- /** Current bounded backoff after a Telegram getUpdates 409 conflict (0 when healthy). */
600
- 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;
601
842
 
602
843
  /**
603
844
  * Cooperatively stop the daemon: set the stop flag and abort the in-flight
@@ -605,62 +846,368 @@ export class TelegramNotificationDaemon {
605
846
  * ~25s getUpdates timeout. Safe to call from a signal handler.
606
847
  */
607
848
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
608
- this.stopRequested = true;
849
+ this.runtime.requestStop();
609
850
  this.running = false;
610
- 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);
611
1127
  }
612
1128
 
613
1129
  constructor(private readonly opts: TelegramDaemonOptions) {
614
1130
  this.fsImpl = opts.fs ?? nodeFs;
615
1131
  this.aliasTable = createAliasTable();
616
- this.botApi = opts.botApi ?? {
617
- call: async (method, body, callOpts) => {
618
- const apiBase = opts.apiBase ?? "https://api.telegram.org";
619
- const url = `${apiBase}/bot${opts.botToken}/${method}`;
620
- const fetchImpl = opts.fetchImpl ?? fetch;
621
- const setTimeoutImpl = opts.setTimeoutImpl ?? setTimeout;
622
- const sleep = (ms: number) => new Promise<void>(resolve => setTimeoutImpl(resolve, ms));
623
- // sendPhoto with base64 bytes must be a multipart upload (Telegram does
624
- // not accept base64 in JSON). Other methods stay JSON.
625
- const photoBody = body as { photo?: unknown; mime?: unknown } | null;
626
- if (method === "sendPhoto" && photoBody && typeof photoBody.photo === "string") {
627
- const b = body as {
628
- chat_id: unknown;
629
- message_thread_id?: unknown;
630
- photo: string;
631
- mime?: string;
632
- caption?: string;
633
- parse_mode?: string;
634
- };
635
- const form = new FormData();
636
- form.set("chat_id", String(b.chat_id));
637
- if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
638
- if (b.caption) form.set("caption", b.caption);
639
- if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
640
- form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
641
- const res = await fetchWithRetry(
642
- fetchImpl,
643
- url,
644
- { method: "POST", body: form, signal: callOpts?.signal },
645
- sleep,
646
- );
647
- return res.json();
648
- }
649
- const res = await fetchWithRetry(
650
- fetchImpl,
651
- url,
652
- {
653
- method: "POST",
654
- headers: { "content-type": "application/json" },
655
- body: JSON.stringify(body),
656
- signal: callOpts?.signal,
657
- },
658
- sleep,
659
- );
660
- return res.json();
661
- },
662
- };
663
- this.pool = new RateLimitPool<{ send: ThreadedSend; topicId: string }>({ now: opts.now });
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();
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
+ });
664
1211
  }
665
1212
 
666
1213
  async loadAliases(): Promise<void> {
@@ -690,6 +1237,11 @@ export class TelegramNotificationDaemon {
690
1237
  if (this.sessions.has(sessionId)) continue;
691
1238
  try {
692
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;
693
1245
  this.connectSession(sessionId, endpoint.url, endpoint.token);
694
1246
  } catch {}
695
1247
  }
@@ -725,6 +1277,12 @@ export class TelegramNotificationDaemon {
725
1277
  );
726
1278
  } catch {}
727
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 "SKC <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);
728
1286
  });
729
1287
  ws.addEventListener("message", ev => {
730
1288
  // Identity guard: a delayed frame from a superseded socket must not act
@@ -733,7 +1291,7 @@ export class TelegramNotificationDaemon {
733
1291
  void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(err => {
734
1292
  // Surface frame-handling failures (e.g. a rejected ask sendMessage) to
735
1293
  // the daemon log instead of an invisible unhandled rejection.
736
- console.error("notifications daemon: handleSessionMessage failed:", err);
1294
+ logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
737
1295
  });
738
1296
  });
739
1297
  ws.addEventListener("close", () => {
@@ -751,7 +1309,7 @@ export class TelegramNotificationDaemon {
751
1309
  private startLiveness(session: SessionSocket): void {
752
1310
  if (session.pingTimer) return;
753
1311
  const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
754
- const now = () => (this.opts.now ?? Date.now)();
1312
+ const now = () => this.runtime.now();
755
1313
  session.lastPongAt = now();
756
1314
  session.pingTimer = setIntervalImpl(() => {
757
1315
  if (this.sessions.get(session.sessionId) !== session) return;
@@ -797,6 +1355,7 @@ export class TelegramNotificationDaemon {
797
1355
  "context_update",
798
1356
  "turn_stream",
799
1357
  "image_attachment",
1358
+ "file_attachment",
800
1359
  "config_update",
801
1360
  ]);
802
1361
 
@@ -813,10 +1372,65 @@ export class TelegramNotificationDaemon {
813
1372
  return `SKC ${sessionId.slice(-6)}`;
814
1373
  }
815
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
+
816
1429
  /**
817
1430
  * Resolve (creating once via `createForumTopic`) the forum topic for a
818
- * session. Threaded mode is required: on capability failure this returns
819
- * `undefined` and the caller drops the send (no flat fallback).
1431
+ * session. On capability failure (e.g. Threaded Mode off) this returns
1432
+ * `undefined`; callers then flat-deliver to a private paired chat (with a
1433
+ * one-time nudge) or drop fail-closed for a non-private chat.
820
1434
  */
821
1435
  private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
822
1436
  const existing = this.topics.get(sessionId);
@@ -834,8 +1448,12 @@ export class TelegramNotificationDaemon {
834
1448
  return String(tid);
835
1449
  },
836
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,
837
1456
  );
838
- this.topics.applyName(sessionId, name);
839
1457
  await this.persistTopics();
840
1458
  return rec.topicId;
841
1459
  } catch {
@@ -843,6 +1461,31 @@ export class TelegramNotificationDaemon {
843
1461
  }
844
1462
  }
845
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
+
846
1489
  private async persistTopics(): Promise<void> {
847
1490
  const paths = daemonPaths(this.opts.settings.getAgentDir());
848
1491
  await ensureDir(this.fsImpl, paths.dir);
@@ -857,26 +1500,140 @@ export class TelegramNotificationDaemon {
857
1500
  if (raw && typeof raw === "object") this.topics.load(raw);
858
1501
  }
859
1502
 
1503
+ /** Download a Telegram file by its file_path (from getFile) into memory. */
1504
+ private async downloadTelegramFile(filePath: string): Promise<Buffer | undefined> {
1505
+ const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
1506
+ const fetchImpl = this.opts.fetchImpl ?? fetch;
1507
+ // `filePath` is remote metadata from getFile; reject suspicious segments
1508
+ // (traversal/absolute/backslash) and percent-encode each component before
1509
+ // composing the download URL.
1510
+ if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
1511
+ logger.warn("notifications: rejecting suspicious Telegram file_path");
1512
+ return undefined;
1513
+ }
1514
+ const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
1515
+ const url = `${apiBase}/file/bot${this.opts.botToken}/${encodedPath}`;
1516
+ try {
1517
+ const res = await fetchImpl(url);
1518
+ if (!res.ok) return undefined;
1519
+ return Buffer.from(await res.arrayBuffer());
1520
+ } catch (e) {
1521
+ logger.warn(`notifications: file download failed: ${String(e)}`);
1522
+ return undefined;
1523
+ }
1524
+ }
1525
+
1526
+ /**
1527
+ * Per-session private temp directories (mode 0700) holding inbound non-image
1528
+ * attachments. Keyed by session id and reused across transient reconnects;
1529
+ * removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
1530
+ */
1531
+ private readonly attachmentDirs = new Map<string, string>();
1532
+
1533
+ /** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
1534
+ private async ensureAttachmentDir(sessionId: string): Promise<string> {
1535
+ const existing = this.attachmentDirs.get(sessionId);
1536
+ if (existing) return existing;
1537
+ // mkdtemp creates a directory with an unguessable suffix and 0700 perms;
1538
+ // chmod defensively in case of an unusual platform/umask.
1539
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "skc-telegram-"));
1540
+ await fs.promises.chmod(dir, 0o700).catch(() => undefined);
1541
+ this.attachmentDirs.set(sessionId, dir);
1542
+ return dir;
1543
+ }
1544
+
1545
+ /** Remove all per-session attachment directories. Called on daemon shutdown. */
1546
+ private async cleanupAllAttachmentDirs(): Promise<void> {
1547
+ const dirs = [...this.attachmentDirs.values()];
1548
+ this.attachmentDirs.clear();
1549
+ await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
1550
+ }
1551
+
1552
+ /**
1553
+ * Resolve an inbound attachment to inline image bytes (forwarded as images) or
1554
+ * a securely-saved file path note (non-images). Non-image bytes are written
1555
+ * into a private per-session temp dir (0700) under an unguessable name via an
1556
+ * exclusive 0600 create (`wx`), so the files are not world-readable and the
1557
+ * write never follows a pre-existing symlink. The directory is removed when the
1558
+ * daemon stops. Returns base64 images to inline plus human-readable file notes
1559
+ * to append to the injected text.
1560
+ */
1561
+ private async resolveInboundAttachment(
1562
+ att: InboundAttachment,
1563
+ sessionId: string,
1564
+ ): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
1565
+ const images: { data: string; mime?: string }[] = [];
1566
+ const fileNotes: string[] = [];
1567
+ const label = att.fileName ?? att.kind;
1568
+ try {
1569
+ const got = (await this.botApi.call("getFile", { file_id: att.fileId })) as {
1570
+ result?: { file_path?: unknown };
1571
+ };
1572
+ const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
1573
+ if (!filePath) {
1574
+ fileNotes.push(`[attachment unavailable: ${label}]`);
1575
+ return { images, fileNotes };
1576
+ }
1577
+ const bytes = await this.downloadTelegramFile(filePath);
1578
+ if (!bytes) {
1579
+ fileNotes.push(`[attachment download failed: ${label}]`);
1580
+ return { images, fileNotes };
1581
+ }
1582
+ const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
1583
+ if (isImage) {
1584
+ images.push({ data: bytes.toString("base64"), mime: att.mime ?? "image/jpeg" });
1585
+ } else {
1586
+ const safeBase =
1587
+ (att.fileName?.trim() || path.basename(filePath) || `${att.kind}-${att.fileId}`)
1588
+ .replace(/[^\w.-]+/g, "_") // drop path separators and unusual chars
1589
+ .replace(/\.\.+/g, "_") // neutralize any ".." traversal-looking runs
1590
+ .replace(/^[.-]+/, "_") // no leading dot/hyphen
1591
+ .slice(-128) || "file";
1592
+ const dir = await this.ensureAttachmentDir(sessionId);
1593
+ // Unguessable, non-colliding name inside the private 0700 dir; the
1594
+ // exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
1595
+ const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
1596
+ await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
1597
+ fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
1598
+ }
1599
+ } catch (e) {
1600
+ logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
1601
+ fileNotes.push(`[attachment error: ${label}]`);
1602
+ }
1603
+ return { images, fileNotes };
1604
+ }
1605
+
860
1606
  /** Drain the shared rate-limit pool and deliver each granted send to its topic. */
861
1607
  private async flushPool(): Promise<void> {
862
1608
  for (const item of this.pool.drain()) {
863
1609
  const { send, topicId } = item.payload;
864
- const thread = Number(topicId);
1610
+ // Threaded topic when available; otherwise deliver flat to the paired chat.
1611
+ const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
865
1612
  try {
866
1613
  if (send.method === "sendPhoto" && send.photoBase64) {
867
1614
  // Real photo upload (the default botApi multiparts base64 -> file).
868
1615
  await this.botApi.call("sendPhoto", {
869
1616
  chat_id: this.opts.chatId,
870
- message_thread_id: thread,
1617
+ ...threadField,
871
1618
  photo: send.photoBase64,
872
1619
  mime: send.mime,
873
1620
  caption: send.text,
874
1621
  parse_mode: TELEGRAM_PARSE_MODE,
875
1622
  });
1623
+ } else if (send.method === "sendDocument" && send.documentBase64) {
1624
+ await this.botApi.call("sendDocument", {
1625
+ chat_id: this.opts.chatId,
1626
+ ...threadField,
1627
+ document: send.documentBase64,
1628
+ mime: send.mime,
1629
+ fileName: send.fileName,
1630
+ caption: send.text,
1631
+ parse_mode: TELEGRAM_PARSE_MODE,
1632
+ });
876
1633
  } else if (send.text) {
877
1634
  await this.botApi.call("sendMessage", {
878
1635
  chat_id: this.opts.chatId,
879
- message_thread_id: thread,
1636
+ ...threadField,
880
1637
  text: send.text,
881
1638
  parse_mode: TELEGRAM_PARSE_MODE,
882
1639
  });
@@ -887,47 +1644,84 @@ export class TelegramNotificationDaemon {
887
1644
  }
888
1645
  }
889
1646
 
1647
+ /**
1648
+ * Threaded Mode is unavailable (the bot owner has not enabled forum topics in
1649
+ * @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to
1650
+ * the paired chat instead of dropping it, and nudge the user once. Flat delivery
1651
+ * is gated on the paired chat being a private chat: for a group/supergroup/channel
1652
+ * (e.g. a legacy or hand-edited `chatId`) we keep dropping fail-closed so session
1653
+ * content never lands in a shared chat. Identity headers are sent at most once per
1654
+ * session in flat mode.
1655
+ */
1656
+ private async deliverFlatFallback(sessionId: string, send: ThreadedSend): Promise<void> {
1657
+ if (!(await this.pairedChatIsPrivate())) return;
1658
+ await this.notifyThreadedFallback();
1659
+ if (send.identity && this.flatIdentitySent.has(sessionId)) return;
1660
+ this.pool.submit({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
1661
+ await this.flushPool();
1662
+ if (send.identity) this.flatIdentitySent.add(sessionId);
1663
+ }
1664
+
1665
+ /**
1666
+ * Resolve once (cached) whether the paired `chatId` is a private chat. Flat
1667
+ * fallback is only safe in a private DM; any non-private chat or an unresolvable
1668
+ * `getChat` is treated as not-private so delivery fails closed.
1669
+ */
1670
+ private async pairedChatIsPrivate(): Promise<boolean> {
1671
+ if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
1672
+ try {
1673
+ const res = (await this.botApi.call("getChat", { chat_id: this.opts.chatId })) as {
1674
+ result?: { type?: string };
1675
+ };
1676
+ this.pairedChatPrivate = res.result?.type === "private";
1677
+ } catch {
1678
+ this.pairedChatPrivate = false;
1679
+ }
1680
+ return this.pairedChatPrivate;
1681
+ }
1682
+
1683
+ /** Tell the user once (per daemon run) how to enable Threaded Mode. */
1684
+ private async notifyThreadedFallback(): Promise<void> {
1685
+ if (this.threadedFallbackNoticeSent) return;
1686
+ this.threadedFallbackNoticeSent = true;
1687
+ try {
1688
+ await this.botApi.call("sendMessage", {
1689
+ chat_id: this.opts.chatId,
1690
+ text: "turn on threaded mode from botfather miniapp to receive skc notification!",
1691
+ parse_mode: TELEGRAM_PARSE_MODE,
1692
+ });
1693
+ } catch {
1694
+ // Best-effort nudge; never block delivery.
1695
+ }
1696
+ }
1697
+
890
1698
  private startFlushTimer(): void {
891
- if (this.flushTimer) return;
892
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
893
- this.flushTimer = setIntervalImpl(() => {
1699
+ this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
894
1700
  if (!this.running || this.pool.pending === 0) return;
895
1701
  void this.flushPool();
896
- }, RATE_LIMIT_FLUSH_INTERVAL_MS);
1702
+ });
897
1703
  }
898
1704
 
899
1705
  private stopFlushTimer(): void {
900
- if (!this.flushTimer) return;
901
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
902
- clearIntervalImpl(this.flushTimer);
903
- this.flushTimer = undefined;
1706
+ this.runtime.stopInterval("telegram-flush");
904
1707
  }
905
1708
 
906
1709
  /** Run a root scan, guarding against overlapping scans from the timer + loop. */
907
1710
  private async runScan(): Promise<void> {
908
- if (this.scanning) return;
909
- this.scanning = true;
910
- try {
1711
+ await this.runtime.runExclusive("telegram-scan", async () => {
911
1712
  await this.scanRoots();
912
- } finally {
913
- this.scanning = false;
914
- }
1713
+ });
915
1714
  }
916
1715
 
917
1716
  private startScanTimer(): void {
918
- if (this.scanTimer) return;
919
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
920
- this.scanTimer = setIntervalImpl(() => {
1717
+ this.runtime.startInterval("telegram-scan", this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS, () => {
921
1718
  if (!this.running) return;
922
1719
  void this.runScan();
923
- }, this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS);
1720
+ });
924
1721
  }
925
1722
 
926
1723
  private stopScanTimer(): void {
927
- if (!this.scanTimer) return;
928
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
929
- clearIntervalImpl(this.scanTimer);
930
- this.scanTimer = undefined;
1724
+ this.runtime.stopInterval("telegram-scan");
931
1725
  }
932
1726
 
933
1727
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
@@ -959,64 +1753,43 @@ export class TelegramNotificationDaemon {
959
1753
  }
960
1754
 
961
1755
  private startTypingTimer(): void {
962
- if (this.typingTimer) return;
963
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
964
- this.typingTimer = setIntervalImpl(() => {
1756
+ this.runtime.startInterval("telegram-typing", TYPING_REFRESH_INTERVAL_MS, () => {
965
1757
  if (!this.running || this.busy.size === 0) return;
966
1758
  for (const sessionId of this.busy) void this.sendTyping(sessionId);
967
- }, TYPING_REFRESH_INTERVAL_MS);
1759
+ });
968
1760
  }
969
1761
 
970
1762
  private stopTypingTimer(): void {
971
- if (!this.typingTimer) return;
972
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
973
- clearIntervalImpl(this.typingTimer);
974
- this.typingTimer = undefined;
1763
+ this.runtime.stopInterval("telegram-typing");
975
1764
  }
976
1765
 
977
1766
  async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
978
- if (msg?.type === "hello") {
979
- const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
980
- if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
981
- session.capable = true;
982
- this.startLiveness(session);
983
- }
984
- return;
985
- }
986
- if (msg?.type === "pong") {
987
- if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
988
- session.awaitingNonce = undefined;
989
- session.lastPongAt = (this.opts.now ?? Date.now)();
990
- }
991
- return;
992
- }
993
- // Live typing indicator: track busy/idle per session and push an immediate
994
- // chat action so "typing…" appears without waiting for the refresh tick.
995
- if (msg?.type === "activity") {
996
- if (msg.state === "busy") {
997
- this.busy.add(session.sessionId);
998
- await this.sendTyping(session.sessionId);
999
- } else {
1000
- this.busy.delete(session.sessionId);
1001
- }
1002
- return;
1003
- }
1004
- // Inbound delivery double-check: flip the queued reaction to the consumed
1005
- // reaction once the session reports a turn picked the message up.
1006
- if (msg?.type === "inbound_ack" && typeof msg.updateId === "number") {
1007
- const target = this.inboundReactions.get(msg.updateId);
1008
- if (target && msg.state === "consumed") {
1009
- this.inboundReactions.delete(msg.updateId);
1010
- await this.setReaction(target.messageId, CONSUMED_REACTION);
1011
- }
1012
- return;
1013
- }
1767
+ if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
1014
1768
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
1015
1769
  const send = renderThreadedFrame(msg);
1016
1770
  if (!send) return;
1017
- const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
1018
- if (!topicId) return;
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
+ }
1019
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)));
1786
+ if (!topicId) {
1787
+ await this.deliverFlatFallback(session.sessionId, send);
1788
+ return;
1789
+ }
1790
+ if (send.identity) {
1791
+ const identityKey = this.topicIdentityKey(msg);
1792
+ if (identityKey) this.topicOwnerByIdentity.set(identityKey, session.sessionId);
1020
1793
  // Rename the topic if the title changed (e.g. the session title was
1021
1794
  // auto-generated after the topic was first created). This runs on
1022
1795
  // every identity frame, but does NOT re-send the bulleted message.
@@ -1034,31 +1807,25 @@ export class TelegramNotificationDaemon {
1034
1807
  }
1035
1808
  // Send the full bulleted identity header EXACTLY ONCE per topic.
1036
1809
  if (this.topics.needsIdentity(session.sessionId)) {
1037
- this.pool.submit({
1038
- sessionId: session.sessionId,
1039
- lane: send.lane,
1040
- coalesceKey: send.coalesceKey,
1041
- payload: { send, topicId },
1042
- });
1043
- await this.flushPool();
1810
+ await this.submitThreadedFrame(session.sessionId, send, topicId);
1044
1811
  this.topics.markIdentitySent(session.sessionId);
1045
1812
  }
1813
+ await this.flushPendingThreadedFrames(session.sessionId, topicId);
1046
1814
  await this.persistTopics();
1047
1815
  return;
1048
1816
  }
1049
- this.pool.submit({
1050
- sessionId: session.sessionId,
1051
- lane: send.lane,
1052
- coalesceKey: send.coalesceKey,
1053
- payload: { send, topicId },
1054
- });
1055
- await this.flushPool();
1817
+ await this.submitThreadedFrame(session.sessionId, send, topicId);
1056
1818
  return;
1057
1819
  }
1058
1820
  if (msg.type === "action_needed" && msg.id) {
1059
1821
  if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
1060
1822
  const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
1061
- if (!topicId) return;
1823
+ if (!topicId) {
1824
+ // Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
1825
+ if (!(await this.pairedChatIsPrivate())) return;
1826
+ await this.notifyThreadedFallback();
1827
+ }
1828
+ const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
1062
1829
  const rendered = buildActionMessage({
1063
1830
  kind: msg.kind ?? "ask",
1064
1831
  id: msg.id,
@@ -1067,14 +1834,14 @@ export class TelegramNotificationDaemon {
1067
1834
  summary: msg.summary,
1068
1835
  });
1069
1836
  const options = Array.isArray(msg.options) ? msg.options : [];
1070
- // Daemon keyboards MUST use alias callback data (not reference encodeCallbackData).
1071
- // Labels show one-based numbers; the stored alias answer stays zero-based.
1072
- 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) =>
1073
1840
  this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
1074
1841
  );
1075
1842
  const result = (await this.botApi.call("sendMessage", {
1076
1843
  chat_id: this.opts.chatId,
1077
- message_thread_id: Number(topicId),
1844
+ ...threadField,
1078
1845
  text: rendered.text,
1079
1846
  parse_mode: TELEGRAM_PARSE_MODE,
1080
1847
  ...(inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
@@ -1113,6 +1880,20 @@ export class TelegramNotificationDaemon {
1113
1880
  }
1114
1881
 
1115
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
+ }
1116
1897
  // Threaded injection: a free-text message in a known topic (not a button
1117
1898
  // tap and not a reply to a specific ask message) injects a user turn or an
1118
1899
  // in-thread config command. Fail-closed: paired chat + known topic +
@@ -1131,19 +1912,26 @@ export class TelegramNotificationDaemon {
1131
1912
  const inbound = decideThreadedInbound(update as never, {
1132
1913
  pairedChatId: this.opts.chatId,
1133
1914
  topicToSession: t => this.topics.sessionForTopic(t),
1134
- isDuplicate: id => this.seenUpdateIds.has(id),
1915
+ isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
1135
1916
  });
1136
1917
  if (inbound.kind === "duplicate") return;
1137
1918
  if (inbound.kind === "inject") {
1138
- this.seenUpdateIds.add(inbound.updateId);
1919
+ this.dispatchState.seenUpdateIds.add(inbound.updateId);
1139
1920
  const session = this.sessions.get(inbound.sessionId);
1140
1921
  if (session?.ws.readyState === WebSocket.OPEN) {
1141
- const cfg = parseInThreadConfigCommand(inbound.text);
1922
+ const attachmentResult = inbound.attachment
1923
+ ? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
1924
+ : undefined;
1925
+ const images = attachmentResult?.images ?? [];
1926
+ const fileNotes = attachmentResult?.fileNotes ?? [];
1927
+ const hasMedia = images.length > 0 || fileNotes.length > 0;
1928
+ const injectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
1929
+ const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
1142
1930
  // A plain (non-config) message while an ask is pending for this session
1143
1931
  // answers that ask as free-input — instead of starting a new user turn.
1144
1932
  // Telegram asks always accept custom text (the SDK maps a string answer
1145
1933
  // to the ask's custom-input slot), so route the latest pending ask here.
1146
- const pendingAsk = cfg ? undefined : [...session.pending.values()].at(-1);
1934
+ const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
1147
1935
  if (pendingAsk) {
1148
1936
  session.ws.send(
1149
1937
  JSON.stringify({
@@ -1163,10 +1951,11 @@ export class TelegramNotificationDaemon {
1163
1951
  : {
1164
1952
  type: "user_message",
1165
1953
  sessionId: inbound.sessionId,
1166
- text: inbound.text,
1954
+ text: injectedText,
1167
1955
  token: session.token,
1168
1956
  updateId: inbound.updateId,
1169
1957
  threadId: inbound.threadId,
1958
+ images,
1170
1959
  },
1171
1960
  ),
1172
1961
  );
@@ -1205,67 +1994,7 @@ export class TelegramNotificationDaemon {
1205
1994
  }
1206
1995
 
1207
1996
  async pollOnce(signal?: AbortSignal): Promise<number> {
1208
- let body: {
1209
- ok?: boolean;
1210
- error_code?: number;
1211
- description?: string;
1212
- result?: Array<{ update_id: number } & Record<string, unknown>>;
1213
- };
1214
- try {
1215
- body = (await this.botApi.call(
1216
- "getUpdates",
1217
- { offset: this.offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
1218
- { signal },
1219
- )) as typeof body;
1220
- } catch (err) {
1221
- // A cooperative stop aborts the in-flight long poll; treat as a clean wake.
1222
- if (isAbortError(err)) return 0;
1223
- // A transient Telegram API failure (e.g. ECONNRESET on the long-poll) must
1224
- // never crash the daemon — that silently stops all delivery, including ask
1225
- // notifications. Log, back off, and let the run loop retry.
1226
- console.error("notifications daemon: getUpdates failed:", err);
1227
- await this.sleep(POLL_BACKOFF_MS, signal);
1228
- return 0;
1229
- }
1230
- // Telegram allows only one active getUpdates poller per bot. A 409 means
1231
- // another poller is live; back off boundedly instead of hot-looping.
1232
- if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
1233
- this.pollConflictBackoffMs = Math.min(
1234
- this.pollConflictBackoffMs ? this.pollConflictBackoffMs * 2 : 500,
1235
- 5_000,
1236
- );
1237
- console.error(
1238
- `notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${this.pollConflictBackoffMs}ms`,
1239
- );
1240
- await this.sleep(this.pollConflictBackoffMs, signal);
1241
- return 0;
1242
- }
1243
- this.pollConflictBackoffMs = 0;
1244
- for (const update of body.result ?? []) {
1245
- this.offset = update.update_id + 1;
1246
- try {
1247
- await this.handleTelegramUpdate(update);
1248
- } catch (err) {
1249
- console.error("notifications daemon: handleTelegramUpdate failed:", err);
1250
- }
1251
- }
1252
- return body.result?.length ?? 0;
1253
- }
1254
-
1255
- /** Abortable sleep honoring the injected timer; resolves early on abort. */
1256
- private sleep(ms: number, signal?: AbortSignal): Promise<void> {
1257
- return new Promise<void>(resolve => {
1258
- if (signal?.aborted) return resolve();
1259
- const timer = (this.opts.setTimeoutImpl ?? setTimeout)(() => resolve(), ms);
1260
- signal?.addEventListener(
1261
- "abort",
1262
- () => {
1263
- (this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
1264
- resolve();
1265
- },
1266
- { once: true },
1267
- );
1268
- });
1997
+ return this.poller.pollOnce(signal);
1269
1998
  }
1270
1999
 
1271
2000
  /** Sync the bot's Telegram command menu to what the daemon actually handles. */
@@ -1292,6 +2021,7 @@ export class TelegramNotificationDaemon {
1292
2021
  pid: this.opts.pid ?? process.pid,
1293
2022
  });
1294
2023
  if (!this.running) return;
2024
+ this.runtime.start();
1295
2025
  this.startFlushTimer();
1296
2026
  this.startScanTimer();
1297
2027
  this.startTypingTimer();
@@ -1300,8 +2030,10 @@ export class TelegramNotificationDaemon {
1300
2030
  await this.loadAliases();
1301
2031
  await this.loadTopics();
1302
2032
  await this.runScan();
1303
- let idleSince = (this.opts.now ?? Date.now)();
1304
- 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();
1305
2037
  while (this.running) {
1306
2038
  if (await this.controlStopRequested()) break;
1307
2039
  if (
@@ -1316,33 +2048,43 @@ export class TelegramNotificationDaemon {
1316
2048
  break;
1317
2049
  await this.runScan();
1318
2050
  if (await this.controlStopRequested()) break;
1319
- if (this.sessions.size === 0) {
1320
- 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;
1321
2055
  } else {
1322
- idleSince = (this.opts.now ?? Date.now)();
1323
- 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();
1324
2063
  try {
1325
- await this.pollOnce(this.activePoll.signal);
1326
- pollBackoffMs = 0;
2064
+ await this.pollOnce(activePoll.signal);
2065
+ this.loopBackoff.reset();
1327
2066
  } catch (e) {
1328
2067
  // A transient getUpdates/network failure must not kill the
1329
2068
  // daemon. Back off (bounded, below the heartbeat TTL) and keep
1330
2069
  // renewing ownership at the loop top.
1331
- pollBackoffMs = pollBackoffMs === 0 ? 250 : Math.min(pollBackoffMs * 2, 4_000);
1332
- logger.warn(`notifications: getUpdates failed, backing off ${pollBackoffMs}ms: ${String(e)}`);
1333
- 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);
1334
2073
  continue;
1335
2074
  } finally {
1336
- this.activePoll = undefined;
2075
+ this.runtime.clearAbortController(activePoll);
1337
2076
  }
1338
2077
  }
1339
2078
  if (await this.controlStopRequested()) break;
1340
- await new Promise(resolve => (this.opts.setTimeoutImpl ?? setTimeout)(resolve, 10));
2079
+ await this.runtime.sleep(10);
1341
2080
  }
1342
2081
  } finally {
2082
+ this.runtime.stop();
1343
2083
  this.stopFlushTimer();
1344
2084
  this.stopScanTimer();
1345
2085
  this.stopTypingTimer();
2086
+ this.stopLifecycleControl();
2087
+ await this.cleanupAllAttachmentDirs();
1346
2088
  // Persist durable state before releasing ownership so a fresh daemon
1347
2089
  // (e.g. after reload) reloads aliases/topics seamlessly.
1348
2090
  await this.persistAliases().catch(() => undefined);
@@ -1359,7 +2101,7 @@ export class TelegramNotificationDaemon {
1359
2101
 
1360
2102
  /** True when a signal-driven stop or an owner-scoped control request asks the loop to exit. */
1361
2103
  private async controlStopRequested(): Promise<boolean> {
1362
- if (this.stopRequested) return true;
2104
+ if (this.runtime.stopRequested) return true;
1363
2105
  if (!this.opts.control) return false;
1364
2106
  try {
1365
2107
  return await this.opts.control.shouldStop(this.opts.ownerId);