@gajae-code/coding-agent 0.6.4 → 0.7.0

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 (231) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/types/async/job-manager.d.ts +3 -1
  3. package/dist/types/cli/daemon-cli.d.ts +25 -0
  4. package/dist/types/cli/migrate-cli.d.ts +20 -0
  5. package/dist/types/cli/notify-cli.d.ts +23 -0
  6. package/dist/types/cli/setup-cli.d.ts +20 -1
  7. package/dist/types/commands/daemon.d.ts +41 -0
  8. package/dist/types/commands/migrate.d.ts +33 -0
  9. package/dist/types/commands/notify.d.ts +41 -0
  10. package/dist/types/config/keybindings.d.ts +4 -0
  11. package/dist/types/config/model-profile-activation.d.ts +12 -0
  12. package/dist/types/config/model-profiles.d.ts +2 -1
  13. package/dist/types/config/model-registry.d.ts +3 -3
  14. package/dist/types/config/models-config-schema.d.ts +5 -0
  15. package/dist/types/config/settings-schema.d.ts +38 -0
  16. package/dist/types/coordinator/contract.d.ts +1 -1
  17. package/dist/types/daemon/builtin.d.ts +20 -0
  18. package/dist/types/daemon/control-types.d.ts +57 -0
  19. package/dist/types/daemon/runtime.d.ts +25 -0
  20. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  21. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +2 -0
  22. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -2
  23. package/dist/types/gjc-runtime/goal-mode-request.d.ts +1 -1
  24. package/dist/types/gjc-runtime/session-layout.d.ts +59 -0
  25. package/dist/types/gjc-runtime/session-resolution.d.ts +47 -0
  26. package/dist/types/gjc-runtime/state-graph.d.ts +1 -1
  27. package/dist/types/gjc-runtime/state-runtime.d.ts +5 -4
  28. package/dist/types/gjc-runtime/state-schema.d.ts +2 -0
  29. package/dist/types/gjc-runtime/state-writer.d.ts +38 -7
  30. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +15 -0
  31. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +21 -4
  32. package/dist/types/gjc-runtime/workflow-command-ref.d.ts +1 -1
  33. package/dist/types/gjc-runtime/workflow-manifest.d.ts +1 -1
  34. package/dist/types/harness-control-plane/storage.d.ts +2 -1
  35. package/dist/types/hooks/skill-state.d.ts +12 -4
  36. package/dist/types/migrate/action-planner.d.ts +11 -0
  37. package/dist/types/migrate/adapters/claude-code.d.ts +2 -0
  38. package/dist/types/migrate/adapters/codex.d.ts +5 -0
  39. package/dist/types/migrate/adapters/index.d.ts +45 -0
  40. package/dist/types/migrate/adapters/opencode.d.ts +2 -0
  41. package/dist/types/migrate/executor.d.ts +2 -0
  42. package/dist/types/migrate/mcp-mapper.d.ts +20 -0
  43. package/dist/types/migrate/report.d.ts +18 -0
  44. package/dist/types/migrate/skill-normalizer.d.ts +27 -0
  45. package/dist/types/migrate/types.d.ts +126 -0
  46. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  47. package/dist/types/modes/components/oauth-selector.d.ts +2 -0
  48. package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
  49. package/dist/types/modes/interactive-mode.d.ts +1 -1
  50. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +1 -1
  51. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  52. package/dist/types/modes/types.d.ts +7 -1
  53. package/dist/types/notifications/config-commands.d.ts +26 -0
  54. package/dist/types/notifications/config.d.ts +61 -0
  55. package/dist/types/notifications/helpers.d.ts +55 -0
  56. package/dist/types/notifications/html-format.d.ts +62 -0
  57. package/dist/types/notifications/index.d.ts +28 -0
  58. package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
  59. package/dist/types/notifications/telegram-cli.d.ts +19 -0
  60. package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
  61. package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
  62. package/dist/types/notifications/telegram-daemon.d.ts +276 -0
  63. package/dist/types/notifications/telegram-reference.d.ts +111 -0
  64. package/dist/types/notifications/threaded-inbound.d.ts +58 -0
  65. package/dist/types/notifications/threaded-render.d.ts +66 -0
  66. package/dist/types/notifications/topic-registry.d.ts +67 -0
  67. package/dist/types/research-plan/index.d.ts +1 -0
  68. package/dist/types/research-plan/ledger.d.ts +33 -0
  69. package/dist/types/rlm/artifacts.d.ts +1 -1
  70. package/dist/types/rlm/index.d.ts +12 -0
  71. package/dist/types/runtime-mcp/config-writer.d.ts +26 -0
  72. package/dist/types/session/agent-session.d.ts +39 -2
  73. package/dist/types/session/auth-storage.d.ts +1 -1
  74. package/dist/types/setup/credential-auto-import.d.ts +63 -0
  75. package/dist/types/setup/credential-import.d.ts +3 -0
  76. package/dist/types/setup/host-plugin-setup.d.ts +39 -0
  77. package/dist/types/skill-state/active-state.d.ts +6 -11
  78. package/dist/types/skill-state/canonical-skills.d.ts +3 -0
  79. package/dist/types/skill-state/workflow-hud.d.ts +2 -0
  80. package/dist/types/task/spawn-gate.d.ts +1 -10
  81. package/dist/types/tools/ask-answer-registry.d.ts +13 -0
  82. package/dist/types/tools/index.d.ts +18 -0
  83. package/dist/types/tools/subagent.d.ts +3 -0
  84. package/package.json +7 -7
  85. package/scripts/build-binary.ts +3 -0
  86. package/src/async/job-manager.ts +5 -1
  87. package/src/cli/daemon-cli.ts +122 -0
  88. package/src/cli/migrate-cli.ts +106 -0
  89. package/src/cli/notify-cli.ts +274 -0
  90. package/src/cli/setup-cli.ts +173 -84
  91. package/src/cli.ts +3 -0
  92. package/src/commands/daemon.ts +47 -0
  93. package/src/commands/deep-interview.ts +2 -2
  94. package/src/commands/migrate.ts +46 -0
  95. package/src/commands/notify.ts +61 -0
  96. package/src/commands/setup.ts +11 -1
  97. package/src/commands/state.ts +2 -1
  98. package/src/commands/team.ts +7 -3
  99. package/src/config/model-profile-activation.ts +74 -5
  100. package/src/config/model-profiles.ts +7 -4
  101. package/src/config/model-registry.ts +6 -3
  102. package/src/config/models-config-schema.ts +1 -1
  103. package/src/config/settings-schema.ts +29 -0
  104. package/src/coordinator/contract.ts +3 -0
  105. package/src/coordinator-mcp/policy.ts +10 -2
  106. package/src/coordinator-mcp/server.ts +270 -1
  107. package/src/daemon/builtin.ts +46 -0
  108. package/src/daemon/control-types.ts +65 -0
  109. package/src/daemon/runtime.ts +51 -0
  110. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +0 -1
  111. package/src/defaults/gjc/skills/deep-interview/SKILL.md +28 -24
  112. package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -4
  113. package/src/defaults/gjc/skills/team/SKILL.md +51 -47
  114. package/src/defaults/gjc/skills/ultragoal/SKILL.md +33 -13
  115. package/src/extensibility/custom-commands/loader.ts +0 -7
  116. package/src/extensibility/extensions/runner.ts +4 -0
  117. package/src/extensibility/extensions/types.ts +8 -0
  118. package/src/extensibility/gjc-plugins/injection.ts +23 -4
  119. package/src/extensibility/gjc-plugins/state.ts +16 -1
  120. package/src/gjc-runtime/deep-interview-recorder.ts +51 -18
  121. package/src/gjc-runtime/deep-interview-runtime.ts +49 -23
  122. package/src/gjc-runtime/goal-mode-request.ts +26 -11
  123. package/src/gjc-runtime/launch-tmux.ts +6 -1
  124. package/src/gjc-runtime/ralplan-runtime.ts +79 -50
  125. package/src/gjc-runtime/session-layout.ts +180 -0
  126. package/src/gjc-runtime/session-resolution.ts +217 -0
  127. package/src/gjc-runtime/state-graph.ts +1 -2
  128. package/src/gjc-runtime/state-migrations.ts +1 -0
  129. package/src/gjc-runtime/state-runtime.ts +247 -124
  130. package/src/gjc-runtime/state-schema.ts +2 -0
  131. package/src/gjc-runtime/state-writer.ts +289 -41
  132. package/src/gjc-runtime/team-runtime.ts +43 -19
  133. package/src/gjc-runtime/tmux-sessions.ts +7 -1
  134. package/src/gjc-runtime/ultragoal-guard.ts +102 -4
  135. package/src/gjc-runtime/ultragoal-runtime.ts +226 -60
  136. package/src/gjc-runtime/workflow-command-ref.ts +1 -2
  137. package/src/gjc-runtime/workflow-manifest.generated.json +27 -2
  138. package/src/gjc-runtime/workflow-manifest.ts +12 -3
  139. package/src/goals/tools/goal-tool.ts +11 -2
  140. package/src/harness-control-plane/storage.ts +14 -4
  141. package/src/hooks/native-skill-hook.ts +38 -12
  142. package/src/hooks/skill-state.ts +178 -83
  143. package/src/internal-urls/docs-index.generated.ts +9 -6
  144. package/src/main.ts +30 -0
  145. package/src/migrate/action-planner.ts +318 -0
  146. package/src/migrate/adapters/claude-code.ts +39 -0
  147. package/src/migrate/adapters/codex.ts +70 -0
  148. package/src/migrate/adapters/index.ts +277 -0
  149. package/src/migrate/adapters/opencode.ts +52 -0
  150. package/src/migrate/executor.ts +81 -0
  151. package/src/migrate/mcp-mapper.ts +152 -0
  152. package/src/migrate/report.ts +104 -0
  153. package/src/migrate/skill-normalizer.ts +80 -0
  154. package/src/migrate/types.ts +163 -0
  155. package/src/modes/acp/acp-event-mapper.ts +1 -0
  156. package/src/modes/bridge/bridge-mode.ts +2 -2
  157. package/src/modes/components/custom-editor.ts +30 -20
  158. package/src/modes/components/hook-editor.ts +7 -2
  159. package/src/modes/components/oauth-selector.ts +19 -0
  160. package/src/modes/controllers/event-controller.ts +20 -0
  161. package/src/modes/controllers/selector-controller.ts +80 -17
  162. package/src/modes/interactive-mode.ts +6 -2
  163. package/src/modes/rpc/rpc-mode.ts +2 -2
  164. package/src/modes/runtime-init.ts +1 -0
  165. package/src/modes/shared/agent-wire/event-contract.ts +1 -0
  166. package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
  167. package/src/modes/shared/agent-wire/event-observation.ts +16 -0
  168. package/src/modes/shared/agent-wire/unattended-audit.ts +3 -2
  169. package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
  170. package/src/modes/types.ts +7 -1
  171. package/src/modes/utils/ui-helpers.ts +23 -0
  172. package/src/notifications/config-commands.ts +50 -0
  173. package/src/notifications/config.ts +107 -0
  174. package/src/notifications/helpers.ts +135 -0
  175. package/src/notifications/html-format.ts +389 -0
  176. package/src/notifications/index.ts +663 -0
  177. package/src/notifications/rate-limit-pool.ts +179 -0
  178. package/src/notifications/telegram-cli.ts +194 -0
  179. package/src/notifications/telegram-daemon-cli.ts +74 -0
  180. package/src/notifications/telegram-daemon-control.ts +370 -0
  181. package/src/notifications/telegram-daemon.ts +1370 -0
  182. package/src/notifications/telegram-reference.ts +335 -0
  183. package/src/notifications/threaded-inbound.ts +80 -0
  184. package/src/notifications/threaded-render.ts +155 -0
  185. package/src/notifications/topic-registry.ts +133 -0
  186. package/src/prompts/agents/init.md +1 -1
  187. package/src/prompts/system/plan-mode-active.md +1 -1
  188. package/src/prompts/tools/ast-grep.md +1 -1
  189. package/src/prompts/tools/search.md +1 -1
  190. package/src/prompts/tools/task.md +1 -2
  191. package/src/research-plan/index.ts +1 -0
  192. package/src/research-plan/ledger.ts +177 -0
  193. package/src/rlm/artifacts.ts +12 -3
  194. package/src/rlm/index.ts +26 -0
  195. package/src/runtime-mcp/config-writer.ts +46 -0
  196. package/src/sdk.ts +16 -0
  197. package/src/session/agent-session.ts +128 -24
  198. package/src/session/auth-storage.ts +3 -0
  199. package/src/session/session-dump-format.ts +43 -2
  200. package/src/session/session-manager.ts +39 -5
  201. package/src/setup/credential-auto-import.ts +258 -0
  202. package/src/setup/credential-import.ts +17 -0
  203. package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
  204. package/src/setup/hermes-setup.ts +1 -1
  205. package/src/setup/host-plugin-setup.ts +142 -0
  206. package/src/skill-state/active-state.ts +72 -108
  207. package/src/skill-state/canonical-skills.ts +4 -0
  208. package/src/skill-state/deep-interview-mutation-guard.ts +28 -109
  209. package/src/skill-state/workflow-hud.ts +4 -2
  210. package/src/skill-state/workflow-state-contract.ts +3 -3
  211. package/src/slash-commands/builtin-registry.ts +4 -1
  212. package/src/task/agents.ts +1 -22
  213. package/src/task/executor.ts +5 -1
  214. package/src/task/index.ts +1 -41
  215. package/src/task/spawn-gate.ts +1 -38
  216. package/src/task/types.ts +1 -1
  217. package/src/tools/ask-answer-registry.ts +25 -0
  218. package/src/tools/ask.ts +108 -16
  219. package/src/tools/computer.ts +58 -4
  220. package/src/tools/image-gen.ts +5 -8
  221. package/src/tools/index.ts +19 -0
  222. package/src/tools/inspect-image.ts +16 -11
  223. package/src/tools/subagent-render.ts +7 -0
  224. package/src/tools/subagent.ts +38 -7
  225. package/dist/types/extensibility/custom-commands/bundled/review/index.d.ts +0 -10
  226. package/src/extensibility/custom-commands/bundled/review/index.ts +0 -456
  227. package/src/prompts/agents/explore.md +0 -58
  228. package/src/prompts/agents/plan.md +0 -49
  229. package/src/prompts/agents/reviewer.md +0 -141
  230. package/src/prompts/agents/task.md +0 -16
  231. package/src/prompts/review-request.md +0 -70
@@ -0,0 +1,1370 @@
1
+ import { spawn as childProcessSpawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { logger } from "@gajae-code/utils";
5
+ import { withFileLock } from "../config/file-lock";
6
+ import type { Settings } from "../config/settings";
7
+ import type { DaemonRuntimeInfo } from "../daemon/control-types";
8
+ import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
9
+ import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
10
+ import { parseInThreadConfigCommand } from "./config-commands";
11
+ import { buildButtonGrid, TELEGRAM_PARSE_MODE } from "./html-format";
12
+ import { RateLimitPool } from "./rate-limit-pool";
13
+ import {
14
+ type AliasTable,
15
+ buildActionMessage,
16
+ type CallbackRoute,
17
+ createAliasTable,
18
+ type PendingAsk,
19
+ readEndpoint,
20
+ routeInboundUpdate,
21
+ } from "./telegram-reference";
22
+ import { decideThreadedInbound } from "./threaded-inbound";
23
+ import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
24
+ import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
25
+
26
+ export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled";
27
+
28
+ export interface DaemonState {
29
+ pid: number;
30
+ ownerId: string;
31
+ tokenFingerprint: string;
32
+ chatId: string;
33
+ startedAt: number;
34
+ heartbeatAt: number;
35
+ roots: string[];
36
+ version: 1;
37
+ stoppedAt?: number;
38
+ }
39
+
40
+ export interface DaemonPaths {
41
+ dir: string;
42
+ lock: string;
43
+ state: string;
44
+ roots: string;
45
+ steal: string;
46
+ aliases: string;
47
+ }
48
+
49
+ export interface TelegramDaemonFs {
50
+ mkdir(path: string, opts?: fs.MakeDirectoryOptions): Promise<void>;
51
+ readFile(path: string, encoding: BufferEncoding): Promise<string>;
52
+ writeFile(path: string, data: string, opts?: fs.WriteFileOptions): Promise<void>;
53
+ rename(oldPath: string, newPath: string): Promise<void>;
54
+ unlink(path: string): Promise<void>;
55
+ open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
56
+ readdir(path: string): Promise<string[]>;
57
+ chmod(path: string, mode: number): Promise<void>;
58
+ }
59
+
60
+ export interface SpawnResult {
61
+ unref?: () => void;
62
+ }
63
+
64
+ export interface TelegramDaemonDeps {
65
+ fs?: TelegramDaemonFs;
66
+ now?: () => number;
67
+ pid?: number;
68
+ pidAlive?: (pid: number) => boolean;
69
+ spawn?: (
70
+ command: string,
71
+ args: string[],
72
+ opts: { detached: boolean; stdio: "ignore"; logPath?: string },
73
+ ) => SpawnResult;
74
+ execPath?: string;
75
+ randomId?: () => string;
76
+ }
77
+
78
+ export const HEARTBEAT_INTERVAL_MS = 5_000;
79
+ export const HEARTBEAT_TTL_MS = 20_000;
80
+ export const DAEMON_VERSION = 1;
81
+ /** Capability token advertised when the server supports app-level ping/pong. */
82
+ export const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong";
83
+ /** Protocol version the daemon advertises in its ClientHello. */
84
+ export const NOTIFICATION_PROTOCOL_VERSION = 2;
85
+
86
+ const nodeFs: TelegramDaemonFs = fs.promises as unknown as TelegramDaemonFs;
87
+ const RATE_LIMIT_FLUSH_INTERVAL_MS = 1_000;
88
+ // How often the daemon rescans for newly-started sessions. This MUST run
89
+ // independently of the Telegram getUpdates long-poll (up to 25s): otherwise a
90
+ // session that starts mid-poll is not connected until the poll returns, so its
91
+ // buffered ask is delivered up to 25s late — or never, if the user answers the
92
+ // local ask first (which clears the buffered ask).
93
+ const SESSION_SCAN_INTERVAL_MS = 1_000;
94
+ // Transient Telegram API delivery is retried this many times before giving up.
95
+ const BOT_API_RETRY_ATTEMPTS = 3;
96
+ // Backoff after a failed getUpdates long-poll so a persistent outage does not
97
+ // busy-loop the daemon.
98
+ const POLL_BACKOFF_MS = 1_000;
99
+ // Telegram clears a chat action after ~5s; refresh slightly sooner to keep the
100
+ // typing indicator alive while the agent is busy.
101
+ const TYPING_REFRESH_INTERVAL_MS = 4_000;
102
+ // Native reactions used as a two-stage delivery double-check on inbound thread
103
+ // messages: queued on receipt, consumed once a turn picks the message up.
104
+ const QUEUED_REACTION = "👀";
105
+ const CONSUMED_REACTION = "✅";
106
+
107
+ /**
108
+ * Whether `err` is a transient network failure worth retrying. Telegram API
109
+ * calls over HTTP/2 occasionally surface mid-stream `ECONNRESET` (and similar)
110
+ * that the global h2 fallback does not catch; treating these as fatal drops ask
111
+ * notifications and (in the polling loop) crashes the daemon.
112
+ */
113
+ function isTransientNetworkError(err: unknown): boolean {
114
+ const code = (err as { code?: unknown } | null)?.code;
115
+ if (typeof code === "string") {
116
+ const transient = new Set([
117
+ "ECONNRESET",
118
+ "ECONNREFUSED",
119
+ "ETIMEDOUT",
120
+ "EPIPE",
121
+ "ENOTFOUND",
122
+ "EAI_AGAIN",
123
+ "UND_ERR_SOCKET",
124
+ "ConnectionClosed",
125
+ "ConnectionReset",
126
+ "ConnectionRefused",
127
+ "ConnectionTimeout",
128
+ "FailedToOpenSocket",
129
+ ]);
130
+ if (transient.has(code)) return true;
131
+ }
132
+ const message = (err as { message?: unknown } | null)?.message;
133
+ return (
134
+ typeof message === "string" &&
135
+ /socket connection was closed|econnreset|fetch failed|network|timed out|terminated/i.test(message)
136
+ );
137
+ }
138
+
139
+ /** `fetch` with bounded retries on transient network failures. */
140
+ async function fetchWithRetry(
141
+ fetchImpl: typeof fetch,
142
+ url: string,
143
+ init: RequestInit,
144
+ sleep: (ms: number) => Promise<void>,
145
+ attempts: number = BOT_API_RETRY_ATTEMPTS,
146
+ ): Promise<Response> {
147
+ let lastErr: unknown;
148
+ for (let attempt = 0; attempt < attempts; attempt++) {
149
+ try {
150
+ return await fetchImpl(url, init);
151
+ } catch (err) {
152
+ lastErr = err;
153
+ if (!isTransientNetworkError(err) || attempt === attempts - 1) throw err;
154
+ await sleep(200 * 2 ** attempt);
155
+ }
156
+ }
157
+ throw lastErr;
158
+ }
159
+
160
+ export function daemonPaths(agentDir: string): DaemonPaths {
161
+ const dir = path.join(agentDir, "notifications");
162
+ return {
163
+ dir,
164
+ lock: path.join(dir, "telegram-daemon.lock"),
165
+ state: path.join(dir, "telegram-daemon.state.json"),
166
+ roots: path.join(dir, "telegram-daemon.roots.json"),
167
+ steal: path.join(dir, "telegram-daemon.steal"),
168
+ aliases: path.join(dir, "telegram-callback-aliases.json"),
169
+ };
170
+ }
171
+
172
+ async function ensureDir(fsImpl: TelegramDaemonFs, dir: string): Promise<void> {
173
+ await fsImpl.mkdir(dir, { recursive: true, mode: 0o700 });
174
+ await fsImpl.chmod(dir, 0o700).catch(() => undefined);
175
+ }
176
+
177
+ async function readJson<T>(fsImpl: TelegramDaemonFs, file: string): Promise<T | undefined> {
178
+ try {
179
+ return JSON.parse(await fsImpl.readFile(file, "utf8")) as T;
180
+ } catch (error) {
181
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
182
+ throw error;
183
+ }
184
+ }
185
+
186
+ async function writeJsonAtomic(fsImpl: TelegramDaemonFs, file: string, data: unknown): Promise<void> {
187
+ const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
188
+ await fsImpl.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
189
+ await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
190
+ await fsImpl.rename(tmp, file);
191
+ }
192
+
193
+ async function tryOpenWx(fsImpl: TelegramDaemonFs, file: string): Promise<boolean> {
194
+ try {
195
+ const handle = await fsImpl.open(file, "wx", 0o600);
196
+ await handle.close();
197
+ return true;
198
+ } catch (error) {
199
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ export async function registerNotificationRoot(input: {
205
+ settings: Settings;
206
+ cwd: string;
207
+ sessionId: string;
208
+ fs?: TelegramDaemonFs;
209
+ }): Promise<string> {
210
+ const fsImpl = input.fs ?? nodeFs;
211
+ const paths = daemonPaths(input.settings.getAgentDir());
212
+ await ensureDir(fsImpl, paths.dir);
213
+ const root = path.join(input.cwd, ".gjc", "state");
214
+ await withFileLock(
215
+ paths.roots,
216
+ async () => {
217
+ const current =
218
+ (await readJson<{ roots?: string[]; sessions?: Record<string, string> }>(fsImpl, paths.roots)) ?? {};
219
+ const roots = new Set(current.roots ?? []);
220
+ roots.add(root);
221
+ await writeJsonAtomic(fsImpl, paths.roots, {
222
+ version: 1,
223
+ roots: Array.from(roots).sort(),
224
+ sessions: { ...(current.sessions ?? {}), [input.sessionId]: root },
225
+ });
226
+ },
227
+ { staleMs: 10_000 },
228
+ );
229
+ return root;
230
+ }
231
+
232
+ export function isFreshLiveOwner(input: {
233
+ state: DaemonState | undefined;
234
+ now: number;
235
+ tokenFingerprint: string;
236
+ chatId: string;
237
+ pidAlive: (pid: number) => boolean;
238
+ }): boolean {
239
+ const { state } = input;
240
+ return Boolean(
241
+ state &&
242
+ state.version === DAEMON_VERSION &&
243
+ state.tokenFingerprint === input.tokenFingerprint &&
244
+ state.chatId === input.chatId &&
245
+ input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
246
+ input.pidAlive(state.pid),
247
+ );
248
+ }
249
+
250
+ export async function acquireDaemonOwnership(input: {
251
+ settings: Settings;
252
+ roots?: string[];
253
+ tokenFingerprint: string;
254
+ chatId: string;
255
+ fs?: TelegramDaemonFs;
256
+ now?: () => number;
257
+ pid?: number;
258
+ pidAlive?: (pid: number) => boolean;
259
+ randomId?: () => string;
260
+ }): Promise<{ acquired: boolean; ownerId?: string; attached?: boolean }> {
261
+ const fsImpl = input.fs ?? nodeFs;
262
+ const now = input.now ?? Date.now;
263
+ const pid = input.pid ?? process.pid;
264
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
265
+ const paths = daemonPaths(input.settings.getAgentDir());
266
+ await ensureDir(fsImpl, paths.dir);
267
+ const ownerId = input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
268
+ const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
269
+ const existing = await readJson<DaemonState>(fsImpl, paths.state);
270
+ if (
271
+ isFreshLiveOwner({
272
+ state: existing,
273
+ now: now(),
274
+ tokenFingerprint: input.tokenFingerprint,
275
+ chatId: input.chatId,
276
+ pidAlive,
277
+ })
278
+ ) {
279
+ return { acquired: false, attached: true };
280
+ }
281
+ if (await tryOpenWx(fsImpl, paths.lock)) {
282
+ await writeJsonAtomic(fsImpl, paths.state, {
283
+ pid,
284
+ ownerId,
285
+ tokenFingerprint: input.tokenFingerprint,
286
+ chatId: input.chatId,
287
+ startedAt: now(),
288
+ heartbeatAt: now(),
289
+ roots,
290
+ version: DAEMON_VERSION,
291
+ } satisfies DaemonState);
292
+ return { acquired: true, ownerId };
293
+ }
294
+ const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
295
+ if (
296
+ isFreshLiveOwner({
297
+ state: afterLock,
298
+ now: now(),
299
+ tokenFingerprint: input.tokenFingerprint,
300
+ chatId: input.chatId,
301
+ pidAlive,
302
+ })
303
+ ) {
304
+ return { acquired: false, attached: true };
305
+ }
306
+ if (!afterLock) return { acquired: false, attached: true };
307
+ if (!(await tryOpenWx(fsImpl, paths.steal))) return { acquired: false, attached: true };
308
+ try {
309
+ const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
310
+ if (
311
+ isFreshLiveOwner({
312
+ state: rechecked,
313
+ now: now(),
314
+ tokenFingerprint: input.tokenFingerprint,
315
+ chatId: input.chatId,
316
+ pidAlive,
317
+ })
318
+ ) {
319
+ return { acquired: false, attached: true };
320
+ }
321
+ if (rechecked && pidAlive(rechecked.pid)) {
322
+ return { acquired: false, attached: true };
323
+ }
324
+ await fsImpl.unlink(paths.lock).catch(() => undefined);
325
+ if (!(await tryOpenWx(fsImpl, paths.lock))) return { acquired: false, attached: true };
326
+ await writeJsonAtomic(fsImpl, paths.state, {
327
+ pid,
328
+ ownerId,
329
+ tokenFingerprint: input.tokenFingerprint,
330
+ chatId: input.chatId,
331
+ startedAt: now(),
332
+ heartbeatAt: now(),
333
+ roots,
334
+ version: DAEMON_VERSION,
335
+ } satisfies DaemonState);
336
+ return { acquired: true, ownerId };
337
+ } finally {
338
+ await fsImpl.unlink(paths.steal).catch(() => undefined);
339
+ }
340
+ }
341
+
342
+ export async function renewDaemonHeartbeat(input: {
343
+ settings: Settings;
344
+ ownerId: string;
345
+ fs?: TelegramDaemonFs;
346
+ now?: () => number;
347
+ pid?: number;
348
+ }): Promise<boolean> {
349
+ const fsImpl = input.fs ?? nodeFs;
350
+ const paths = daemonPaths(input.settings.getAgentDir());
351
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
352
+ if (!state || state.ownerId !== input.ownerId) return false;
353
+ await writeJsonAtomic(fsImpl, paths.state, {
354
+ ...state,
355
+ pid: input.pid ?? state.pid,
356
+ heartbeatAt: (input.now ?? Date.now)(),
357
+ });
358
+ return true;
359
+ }
360
+
361
+ export async function releaseDaemonOwnership(input: {
362
+ settings: Settings;
363
+ ownerId: string;
364
+ fs?: TelegramDaemonFs;
365
+ now?: () => number;
366
+ }): Promise<void> {
367
+ const fsImpl = input.fs ?? nodeFs;
368
+ const paths = daemonPaths(input.settings.getAgentDir());
369
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
370
+ if (state?.ownerId !== input.ownerId) return;
371
+ await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
372
+ await fsImpl.unlink(paths.lock).catch(() => undefined);
373
+ }
374
+
375
+ /** Read the persisted daemon ownership state (or undefined when absent). */
376
+ export async function readDaemonState(
377
+ settings: Settings,
378
+ fs: TelegramDaemonFs = nodeFs,
379
+ ): Promise<DaemonState | undefined> {
380
+ return readJson<DaemonState>(fs, daemonPaths(settings.getAgentDir()).state);
381
+ }
382
+
383
+ /** Read the persisted notification roots list. */
384
+ export async function readDaemonRoots(settings: Settings, fs: TelegramDaemonFs = nodeFs): Promise<string[]> {
385
+ const roots = await readJson<{ roots?: string[] }>(fs, daemonPaths(settings.getAgentDir()).roots);
386
+ return roots?.roots ?? [];
387
+ }
388
+
389
+ function defaultPidAlive(pid: number): boolean {
390
+ try {
391
+ process.kill(pid, 0);
392
+ return true;
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
398
+ /** True for AbortError-shaped rejections raised when an in-flight fetch is aborted. */
399
+ function isAbortError(err: unknown): boolean {
400
+ return err instanceof Error && (err.name === "AbortError" || /\baborted\b/i.test(err.message));
401
+ }
402
+
403
+ function defaultDaemonSpawn(
404
+ command: string,
405
+ args: string[],
406
+ opts: { detached: boolean; stdio: "ignore"; logPath?: string },
407
+ ): SpawnResult {
408
+ // Redirect the detached daemon's stdout/stderr to a log file so failures
409
+ // (e.g. a rejected sendMessage) are diagnosable instead of vanishing.
410
+ let stdio: "ignore" | ["ignore", number, number] = opts.stdio;
411
+ if (opts.logPath) {
412
+ try {
413
+ fs.mkdirSync(path.dirname(opts.logPath), { recursive: true, mode: 0o700 });
414
+ const fd = fs.openSync(opts.logPath, "a", 0o600);
415
+ stdio = ["ignore", fd, fd];
416
+ } catch {
417
+ // Fall back to ignoring output if the log file cannot be opened.
418
+ }
419
+ }
420
+ const child = childProcessSpawn(command, args, { detached: opts.detached, stdio });
421
+ // Best-effort autostart: a spawn failure must never crash the host session.
422
+ child.on("error", () => undefined);
423
+ return { unref: () => child.unref() };
424
+ }
425
+
426
+ export interface TelegramSpawnOwnerInput {
427
+ settings: Settings;
428
+ roots?: string[];
429
+ tokenFingerprint: string;
430
+ chatId: string;
431
+ }
432
+
433
+ export interface TelegramSpawnOwnerResult {
434
+ result: EnsureDaemonResult;
435
+ ownerId?: string;
436
+ runtime: DaemonRuntimeInfo;
437
+ warnings: string[];
438
+ }
439
+
440
+ /**
441
+ * Build the detached spawn command/args for the daemon-internal entrypoint.
442
+ * Source mode prepends the entry script so the respawn loads edited source;
443
+ * a compiled binary self-spawns its own subcommand directly.
444
+ */
445
+ export function buildTelegramDaemonSpawnArgs(input: { execPath?: string; ownerId: string; agentDir: string }): {
446
+ command: string;
447
+ args: string[];
448
+ runtime: DaemonRuntimeInfo;
449
+ } {
450
+ const rt = resolveGjcRuntimeSpawnInfo(input.execPath ?? process.execPath);
451
+ const args = [
452
+ ...rt.argsPrefix,
453
+ "notify",
454
+ "daemon-internal",
455
+ "--owner-id",
456
+ input.ownerId,
457
+ "--agent-dir",
458
+ input.agentDir,
459
+ ];
460
+ const runtime: DaemonRuntimeInfo = {
461
+ mode: rt.mode,
462
+ execPath: rt.execPath,
463
+ reloadPicksUpSourceEdits: rt.reloadPicksUpSourceEdits,
464
+ warning: rt.warning,
465
+ };
466
+ return { command: rt.execPath, args, runtime };
467
+ }
468
+
469
+ /**
470
+ * Acquire ownership for the given Telegram identity and, if acquired, spawn a
471
+ * fresh detached daemon process. Does NOT register notification roots; callers
472
+ * that own a session (autostart) register roots separately, while reload reuses
473
+ * already-persisted roots.
474
+ */
475
+ export async function spawnTelegramDaemonOwner(
476
+ input: TelegramSpawnOwnerInput,
477
+ deps: TelegramDaemonDeps = {},
478
+ ): Promise<TelegramSpawnOwnerResult> {
479
+ const agentDir = input.settings.getAgentDir();
480
+ const execPath = deps.execPath ?? process.execPath;
481
+ const ownership = await acquireDaemonOwnership({
482
+ settings: input.settings,
483
+ roots: input.roots,
484
+ tokenFingerprint: input.tokenFingerprint,
485
+ chatId: input.chatId,
486
+ fs: deps.fs,
487
+ now: deps.now,
488
+ pid: deps.pid,
489
+ pidAlive: deps.pidAlive,
490
+ randomId: deps.randomId,
491
+ });
492
+ // One source of truth for runtime detection + spawn args (no duplicate resolve).
493
+ const { command, args, runtime } = buildTelegramDaemonSpawnArgs({
494
+ execPath,
495
+ ownerId: ownership.ownerId ?? "",
496
+ agentDir,
497
+ });
498
+ if (!ownership.acquired) return { result: "attached", runtime, warnings: [] };
499
+ const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
500
+ const child = spawnImpl(command, args, {
501
+ detached: true,
502
+ stdio: "ignore",
503
+ logPath: path.join(daemonPaths(agentDir).dir, "daemon.log"),
504
+ });
505
+ child?.unref?.();
506
+ return { result: "owner_spawned", ownerId: ownership.ownerId, runtime, warnings: [] };
507
+ }
508
+
509
+ export async function ensureTelegramDaemonRunning(
510
+ input: { settings: Settings; cwd: string; sessionId: string },
511
+ deps: TelegramDaemonDeps = {},
512
+ ): Promise<EnsureDaemonResult> {
513
+ const cfg = getNotificationConfig(input.settings);
514
+ if (!isGloballyConfigured(cfg) || !cfg.botToken || !cfg.chatId) return "disabled";
515
+ const root = await registerNotificationRoot({ ...input, fs: deps.fs });
516
+ const fp = tokenFingerprint(cfg.botToken);
517
+ const spawned = await spawnTelegramDaemonOwner(
518
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
519
+ deps,
520
+ );
521
+ return spawned.result;
522
+ }
523
+
524
+ export interface BotApi {
525
+ call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown>;
526
+ }
527
+
528
+ /**
529
+ * Cooperative control seam for the daemon run loop. Implemented by the
530
+ * daemon-internal CLI / controller against the owner-scoped control-request
531
+ * file so the daemon does not import the control module directly.
532
+ */
533
+ export interface DaemonControlHooks {
534
+ /** Returns true when a stop/reload has been requested for this owner. */
535
+ shouldStop(ownerId: string): Promise<boolean>;
536
+ /** Clear a consumed control request (best-effort). */
537
+ clear?(ownerId: string): Promise<void>;
538
+ }
539
+
540
+ export interface TelegramDaemonOptions {
541
+ settings: Settings;
542
+ ownerId: string;
543
+ botToken: string;
544
+ chatId: string;
545
+ apiBase?: string;
546
+ fetchImpl?: typeof fetch;
547
+ fs?: TelegramDaemonFs;
548
+ WebSocketImpl?: typeof WebSocket;
549
+ now?: () => number;
550
+ setTimeoutImpl?: typeof setTimeout;
551
+ clearTimeoutImpl?: typeof clearTimeout;
552
+ setIntervalImpl?: typeof setInterval;
553
+ clearIntervalImpl?: typeof clearInterval;
554
+ idleTimeoutMs?: number;
555
+ scanIntervalMs?: number;
556
+ pid?: number;
557
+ botApi?: BotApi;
558
+ control?: DaemonControlHooks;
559
+ }
560
+
561
+ interface SessionSocket {
562
+ sessionId: string;
563
+ token: string;
564
+ ws: WebSocket;
565
+ pending: Map<string, { sessionId: string; actionId: string }>;
566
+ /** True once the server advertised the `client_ping_pong` capability. */
567
+ capable: boolean;
568
+ /** Timestamp (via opts.now) of the last received pong; seeds the TTL window. */
569
+ lastPongAt: number;
570
+ /** Nonce of the most recent in-flight ping, if any. */
571
+ awaitingNonce: string | undefined;
572
+ /** Per-session liveness interval handle (only set for capable sessions). */
573
+ pingTimer: ReturnType<typeof setInterval> | undefined;
574
+ }
575
+
576
+ export class TelegramNotificationDaemon {
577
+ readonly aliasTable: AliasTable;
578
+ readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
579
+ readonly sessions = new Map<string, SessionSocket>();
580
+ private running = false;
581
+ private offset = 0;
582
+ private readonly fsImpl: TelegramDaemonFs;
583
+ private readonly botApi: BotApi;
584
+ 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;
591
+ /** Sessions whose agent loop is currently busy (drives the typing indicator). */
592
+ private readonly busy = new Set<string>();
593
+ /** 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;
601
+
602
+ /**
603
+ * Cooperatively stop the daemon: set the stop flag and abort the in-flight
604
+ * long poll so the run loop wakes immediately instead of waiting out the
605
+ * ~25s getUpdates timeout. Safe to call from a signal handler.
606
+ */
607
+ requestStop(_reason?: "reload" | "stop" | "signal"): void {
608
+ this.stopRequested = true;
609
+ this.running = false;
610
+ this.activePoll?.abort();
611
+ }
612
+
613
+ constructor(private readonly opts: TelegramDaemonOptions) {
614
+ this.fsImpl = opts.fs ?? nodeFs;
615
+ 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 });
664
+ }
665
+
666
+ async loadAliases(): Promise<void> {
667
+ const raw = await readJson<unknown>(this.fsImpl, daemonPaths(this.opts.settings.getAgentDir()).aliases);
668
+ if (raw) this.aliasTable.load(raw);
669
+ }
670
+
671
+ async persistAliases(): Promise<void> {
672
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
673
+ await ensureDir(this.fsImpl, paths.dir);
674
+ await writeJsonAtomic(this.fsImpl, paths.aliases, this.aliasTable.serialize());
675
+ }
676
+
677
+ async scanRoots(): Promise<void> {
678
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
679
+ const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
680
+ for (const root of rootState?.roots ?? []) {
681
+ const dir = path.join(root, "notifications");
682
+ let files: string[];
683
+ try {
684
+ files = await this.fsImpl.readdir(dir);
685
+ } catch {
686
+ continue;
687
+ }
688
+ for (const file of files.filter(item => item.endsWith(".json"))) {
689
+ const sessionId = path.basename(file, ".json");
690
+ if (this.sessions.has(sessionId)) continue;
691
+ try {
692
+ const endpoint = readEndpoint(path.join(dir, file));
693
+ this.connectSession(sessionId, endpoint.url, endpoint.token);
694
+ } catch {}
695
+ }
696
+ }
697
+ }
698
+
699
+ connectSession(sessionId: string, url: string, token: string): void {
700
+ const WS = this.opts.WebSocketImpl ?? WebSocket;
701
+ const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
702
+ const session: SessionSocket = {
703
+ sessionId,
704
+ token,
705
+ ws,
706
+ pending: new Map(),
707
+ capable: false,
708
+ lastPongAt: 0,
709
+ awaitingNonce: undefined,
710
+ pingTimer: undefined,
711
+ };
712
+ this.sessions.set(sessionId, session);
713
+ // Bidirectional capability advertisement: announce client_ping_pong once the
714
+ // socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send
715
+ // while CONNECTING — and liveness starts only after a capable ServerHello.
716
+ ws.addEventListener("open", () => {
717
+ if (session.ws.readyState === WebSocket.OPEN) {
718
+ try {
719
+ session.ws.send(
720
+ JSON.stringify({
721
+ type: "hello",
722
+ protocolVersion: NOTIFICATION_PROTOCOL_VERSION,
723
+ capabilities: [CLIENT_PING_PONG_CAPABILITY],
724
+ }),
725
+ );
726
+ } catch {}
727
+ }
728
+ });
729
+ ws.addEventListener("message", ev => {
730
+ // Identity guard: a delayed frame from a superseded socket must not act
731
+ // through the replacement session.
732
+ if (this.sessions.get(sessionId) !== session) return;
733
+ void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(err => {
734
+ // Surface frame-handling failures (e.g. a rejected ask sendMessage) to
735
+ // the daemon log instead of an invisible unhandled rejection.
736
+ console.error("notifications daemon: handleSessionMessage failed:", err);
737
+ });
738
+ });
739
+ ws.addEventListener("close", () => {
740
+ this.dropSession(session, "socket_closed");
741
+ });
742
+ }
743
+
744
+ /**
745
+ * Start ack-based liveness for a session whose server advertised the
746
+ * `client_ping_pong` capability. Each interval drops the session when no pong
747
+ * has arrived within the TTL (the half-open case the socket never signals via
748
+ * `close`), otherwise sends a fresh application-level ping. The timer is bound
749
+ * to this exact session object.
750
+ */
751
+ private startLiveness(session: SessionSocket): void {
752
+ if (session.pingTimer) return;
753
+ const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
754
+ const now = () => (this.opts.now ?? Date.now)();
755
+ session.lastPongAt = now();
756
+ session.pingTimer = setIntervalImpl(() => {
757
+ if (this.sessions.get(session.sessionId) !== session) return;
758
+ const t = now();
759
+ if (t - session.lastPongAt >= HEARTBEAT_TTL_MS) {
760
+ this.dropSession(session, "liveness_timeout");
761
+ return;
762
+ }
763
+ if (session.ws.readyState === WebSocket.OPEN) {
764
+ const nonce = `${session.sessionId}:${t}:${Math.random().toString(36).slice(2)}`;
765
+ session.awaitingNonce = nonce;
766
+ try {
767
+ session.ws.send(JSON.stringify({ type: "ping", nonce }));
768
+ } catch {}
769
+ }
770
+ }, HEARTBEAT_INTERVAL_MS);
771
+ }
772
+
773
+ /**
774
+ * Idempotent, identity-guarded session teardown. Clears the liveness timer,
775
+ * removes the map entry only when it still points at this exact session object
776
+ * (so a delayed old close cannot delete a replacement), and best-effort closes
777
+ * the socket. `scanRoots()` then reconnects the session.
778
+ */
779
+ private dropSession(session: SessionSocket, _reason: string): void {
780
+ const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
781
+ if (session.pingTimer) {
782
+ clearIntervalImpl(session.pingTimer);
783
+ session.pingTimer = undefined;
784
+ }
785
+ if (this.sessions.get(session.sessionId) === session) {
786
+ this.sessions.delete(session.sessionId);
787
+ }
788
+ if (session.ws.readyState !== WebSocket.CLOSED) {
789
+ try {
790
+ session.ws.close();
791
+ } catch {}
792
+ }
793
+ }
794
+
795
+ private static readonly THREADED_FRAMES = new Set([
796
+ "identity_header",
797
+ "context_update",
798
+ "turn_stream",
799
+ "image_attachment",
800
+ "config_update",
801
+ ]);
802
+
803
+ private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
804
+ const repo = typeof msg?.repo === "string" && msg.repo ? msg.repo : undefined;
805
+ const branch = typeof msg?.branch === "string" && msg.branch ? msg.branch : undefined;
806
+ const title = typeof msg?.title === "string" && msg.title ? msg.title : undefined;
807
+ // Name the topic "{repo}/{branch}" before a session title exists, then
808
+ // "{repo}/{branch} - {title}" once it does. Fall back to the session id
809
+ // only when no repo identity is available.
810
+ const base = repo ? (branch ? `${repo}/${branch}` : repo) : undefined;
811
+ if (base) return title ? `${base} - ${title}` : base;
812
+ if (title) return title;
813
+ return `GJC ${sessionId.slice(-6)}`;
814
+ }
815
+
816
+ /**
817
+ * 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).
820
+ */
821
+ private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
822
+ const existing = this.topics.get(sessionId);
823
+ if (existing) return existing.topicId;
824
+ try {
825
+ const rec = await this.topics.getOrCreateTopic(
826
+ sessionId,
827
+ async () => {
828
+ const res = (await this.botApi.call("createForumTopic", {
829
+ chat_id: this.opts.chatId,
830
+ name,
831
+ })) as { result?: { message_thread_id?: number } };
832
+ const tid = res.result?.message_thread_id;
833
+ if (tid === undefined || tid === null) throw new Error("createForumTopic: no message_thread_id");
834
+ return String(tid);
835
+ },
836
+ this.opts.now,
837
+ );
838
+ this.topics.applyName(sessionId, name);
839
+ await this.persistTopics();
840
+ return rec.topicId;
841
+ } catch {
842
+ return undefined;
843
+ }
844
+ }
845
+
846
+ private async persistTopics(): Promise<void> {
847
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
848
+ await ensureDir(this.fsImpl, paths.dir);
849
+ await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), this.topics.serialize());
850
+ }
851
+
852
+ async loadTopics(): Promise<void> {
853
+ const paths = daemonPaths(this.opts.settings.getAgentDir());
854
+ const raw = await readJson<TopicRegistryState>(this.fsImpl, path.join(paths.dir, "telegram-topics.json"));
855
+ // Restore the full serialized registry (topicId + identitySent + name) so a
856
+ // fresh daemon after reload does not resend identity headers or lose renames.
857
+ if (raw && typeof raw === "object") this.topics.load(raw);
858
+ }
859
+
860
+ /** Drain the shared rate-limit pool and deliver each granted send to its topic. */
861
+ private async flushPool(): Promise<void> {
862
+ for (const item of this.pool.drain()) {
863
+ const { send, topicId } = item.payload;
864
+ const thread = Number(topicId);
865
+ try {
866
+ if (send.method === "sendPhoto" && send.photoBase64) {
867
+ // Real photo upload (the default botApi multiparts base64 -> file).
868
+ await this.botApi.call("sendPhoto", {
869
+ chat_id: this.opts.chatId,
870
+ message_thread_id: thread,
871
+ photo: send.photoBase64,
872
+ mime: send.mime,
873
+ caption: send.text,
874
+ parse_mode: TELEGRAM_PARSE_MODE,
875
+ });
876
+ } else if (send.text) {
877
+ await this.botApi.call("sendMessage", {
878
+ chat_id: this.opts.chatId,
879
+ message_thread_id: thread,
880
+ text: send.text,
881
+ parse_mode: TELEGRAM_PARSE_MODE,
882
+ });
883
+ }
884
+ } catch {
885
+ // Best-effort: a failed send must never stop the daemon.
886
+ }
887
+ }
888
+ }
889
+
890
+ private startFlushTimer(): void {
891
+ if (this.flushTimer) return;
892
+ const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
893
+ this.flushTimer = setIntervalImpl(() => {
894
+ if (!this.running || this.pool.pending === 0) return;
895
+ void this.flushPool();
896
+ }, RATE_LIMIT_FLUSH_INTERVAL_MS);
897
+ }
898
+
899
+ private stopFlushTimer(): void {
900
+ if (!this.flushTimer) return;
901
+ const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
902
+ clearIntervalImpl(this.flushTimer);
903
+ this.flushTimer = undefined;
904
+ }
905
+
906
+ /** Run a root scan, guarding against overlapping scans from the timer + loop. */
907
+ private async runScan(): Promise<void> {
908
+ if (this.scanning) return;
909
+ this.scanning = true;
910
+ try {
911
+ await this.scanRoots();
912
+ } finally {
913
+ this.scanning = false;
914
+ }
915
+ }
916
+
917
+ private startScanTimer(): void {
918
+ if (this.scanTimer) return;
919
+ const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
920
+ this.scanTimer = setIntervalImpl(() => {
921
+ if (!this.running) return;
922
+ void this.runScan();
923
+ }, this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS);
924
+ }
925
+
926
+ private stopScanTimer(): void {
927
+ if (!this.scanTimer) return;
928
+ const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
929
+ clearIntervalImpl(this.scanTimer);
930
+ this.scanTimer = undefined;
931
+ }
932
+
933
+ /** Send a single `typing` chat action into a busy session's topic (best-effort). */
934
+ private async sendTyping(sessionId: string): Promise<void> {
935
+ const topicId = this.topics.get(sessionId)?.topicId;
936
+ if (!topicId) return;
937
+ try {
938
+ await this.botApi.call("sendChatAction", {
939
+ chat_id: this.opts.chatId,
940
+ message_thread_id: Number(topicId),
941
+ action: "typing",
942
+ });
943
+ } catch {
944
+ // Best-effort: a failed chat action must never stop the daemon.
945
+ }
946
+ }
947
+
948
+ /** Set a native reaction on an inbound thread message (best-effort). */
949
+ private async setReaction(messageId: number, emoji: string): Promise<void> {
950
+ try {
951
+ await this.botApi.call("setMessageReaction", {
952
+ chat_id: this.opts.chatId,
953
+ message_id: messageId,
954
+ reaction: [{ type: "emoji", emoji }],
955
+ });
956
+ } catch {
957
+ // Best-effort: reactions may be disallowed in the chat; never throw.
958
+ }
959
+ }
960
+
961
+ private startTypingTimer(): void {
962
+ if (this.typingTimer) return;
963
+ const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
964
+ this.typingTimer = setIntervalImpl(() => {
965
+ if (!this.running || this.busy.size === 0) return;
966
+ for (const sessionId of this.busy) void this.sendTyping(sessionId);
967
+ }, TYPING_REFRESH_INTERVAL_MS);
968
+ }
969
+
970
+ private stopTypingTimer(): void {
971
+ if (!this.typingTimer) return;
972
+ const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
973
+ clearIntervalImpl(this.typingTimer);
974
+ this.typingTimer = undefined;
975
+ }
976
+
977
+ 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
+ }
1014
+ if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
1015
+ const send = renderThreadedFrame(msg);
1016
+ if (!send) return;
1017
+ const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
1018
+ if (!topicId) return;
1019
+ if (send.identity) {
1020
+ // Rename the topic if the title changed (e.g. the session title was
1021
+ // auto-generated after the topic was first created). This runs on
1022
+ // every identity frame, but does NOT re-send the bulleted message.
1023
+ const name = this.topicNameFor(session.sessionId, msg);
1024
+ if (this.topics.applyName(session.sessionId, name)) {
1025
+ try {
1026
+ await this.botApi.call("editForumTopic", {
1027
+ chat_id: this.opts.chatId,
1028
+ message_thread_id: Number(topicId),
1029
+ name,
1030
+ });
1031
+ } catch {
1032
+ // Best-effort rename; never block delivery.
1033
+ }
1034
+ }
1035
+ // Send the full bulleted identity header EXACTLY ONCE per topic.
1036
+ 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();
1044
+ this.topics.markIdentitySent(session.sessionId);
1045
+ }
1046
+ await this.persistTopics();
1047
+ return;
1048
+ }
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();
1056
+ return;
1057
+ }
1058
+ if (msg.type === "action_needed" && msg.id) {
1059
+ if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
1060
+ const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
1061
+ if (!topicId) return;
1062
+ const rendered = buildActionMessage({
1063
+ kind: msg.kind ?? "ask",
1064
+ id: msg.id,
1065
+ question: msg.question,
1066
+ options: msg.options,
1067
+ summary: msg.summary,
1068
+ });
1069
+ 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) =>
1073
+ this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
1074
+ );
1075
+ const result = (await this.botApi.call("sendMessage", {
1076
+ chat_id: this.opts.chatId,
1077
+ message_thread_id: Number(topicId),
1078
+ text: rendered.text,
1079
+ parse_mode: TELEGRAM_PARSE_MODE,
1080
+ ...(inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
1081
+ })) as { result?: { message_id?: number } };
1082
+ const messageId = result.result?.message_id;
1083
+ if (messageId !== undefined)
1084
+ this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
1085
+ await this.persistAliases();
1086
+ } else if (msg.type === "action_resolved" && msg.id) {
1087
+ session.pending.delete(msg.id);
1088
+ for (const [alias, route] of this.aliasTable.entries()) {
1089
+ if (route.sessionId === session.sessionId && route.actionId === msg.id) this.aliasTable.delete(alias);
1090
+ }
1091
+ await this.persistAliases();
1092
+ }
1093
+ }
1094
+
1095
+ pendingBySession = (sessionId?: string): PendingAsk[] => {
1096
+ const result: PendingAsk[] = [];
1097
+ for (const session of this.sessions.values()) {
1098
+ if (sessionId && session.sessionId !== sessionId) continue;
1099
+ result.push(...session.pending.values());
1100
+ }
1101
+ return result;
1102
+ };
1103
+
1104
+ private async sendStaleGuidance(callbackId: unknown): Promise<void> {
1105
+ if (typeof callbackId === "string") {
1106
+ await this.botApi.call("answerCallbackQuery", { callback_query_id: callbackId, text: "Button is stale" });
1107
+ }
1108
+ await this.botApi.call("sendMessage", {
1109
+ chat_id: this.opts.chatId,
1110
+ text: "This button is stale after notification daemon restart. Please answer locally in the GJC session or wait for a fresh notification.",
1111
+ parse_mode: TELEGRAM_PARSE_MODE,
1112
+ });
1113
+ }
1114
+
1115
+ async handleTelegramUpdate(update: unknown): Promise<void> {
1116
+ // Threaded injection: a free-text message in a known topic (not a button
1117
+ // tap and not a reply to a specific ask message) injects a user turn or an
1118
+ // in-thread config command. Fail-closed: paired chat + known topic +
1119
+ // update_id dedupe are all enforced by decideThreadedInbound.
1120
+ const raw = update as {
1121
+ callback_query?: unknown;
1122
+ message?: { reply_to_message?: { message_id?: unknown } };
1123
+ };
1124
+ // A reply to a known ask message routes to that ask (below). Any OTHER
1125
+ // message in a topic (plain text, or a reply to a non-ask message) is a
1126
+ // free-text injection. Previously replies bypassed injection entirely.
1127
+ const replyTo = raw.message?.reply_to_message?.message_id;
1128
+ const isAskReply =
1129
+ replyTo !== undefined && (this.messageRoutes.has(String(replyTo)) || this.messageRoutes.has(Number(replyTo)));
1130
+ if (!raw.callback_query && !isAskReply) {
1131
+ const inbound = decideThreadedInbound(update as never, {
1132
+ pairedChatId: this.opts.chatId,
1133
+ topicToSession: t => this.topics.sessionForTopic(t),
1134
+ isDuplicate: id => this.seenUpdateIds.has(id),
1135
+ });
1136
+ if (inbound.kind === "duplicate") return;
1137
+ if (inbound.kind === "inject") {
1138
+ this.seenUpdateIds.add(inbound.updateId);
1139
+ const session = this.sessions.get(inbound.sessionId);
1140
+ if (session?.ws.readyState === WebSocket.OPEN) {
1141
+ const cfg = parseInThreadConfigCommand(inbound.text);
1142
+ // A plain (non-config) message while an ask is pending for this session
1143
+ // answers that ask as free-input — instead of starting a new user turn.
1144
+ // Telegram asks always accept custom text (the SDK maps a string answer
1145
+ // to the ask's custom-input slot), so route the latest pending ask here.
1146
+ const pendingAsk = cfg ? undefined : [...session.pending.values()].at(-1);
1147
+ if (pendingAsk) {
1148
+ session.ws.send(
1149
+ JSON.stringify({
1150
+ type: "reply",
1151
+ id: pendingAsk.actionId,
1152
+ answer: inbound.text,
1153
+ token: session.token,
1154
+ }),
1155
+ );
1156
+ if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
1157
+ return;
1158
+ }
1159
+ session.ws.send(
1160
+ JSON.stringify(
1161
+ cfg
1162
+ ? { type: "config_command", sessionId: inbound.sessionId, token: session.token, ...cfg }
1163
+ : {
1164
+ type: "user_message",
1165
+ sessionId: inbound.sessionId,
1166
+ text: inbound.text,
1167
+ token: session.token,
1168
+ updateId: inbound.updateId,
1169
+ threadId: inbound.threadId,
1170
+ },
1171
+ ),
1172
+ );
1173
+ // User turns get a native delivery double-check: queued on receipt,
1174
+ // flipped to consumed when the session acks the turn that picks it
1175
+ // up. Config commands are not user turns and get no reaction.
1176
+ if (!cfg && inbound.messageId !== undefined) {
1177
+ this.inboundReactions.set(inbound.updateId, { messageId: inbound.messageId });
1178
+ await this.setReaction(inbound.messageId, QUEUED_REACTION);
1179
+ }
1180
+ }
1181
+ return;
1182
+ }
1183
+ }
1184
+ const callbackId = (update as { callback_query?: { id?: unknown } }).callback_query?.id;
1185
+ const decision = routeInboundUpdate(update, {
1186
+ aliasTable: this.aliasTable,
1187
+ messageRoutes: this.messageRoutes,
1188
+ pendingBySession: this.pendingBySession,
1189
+ pairedChatId: this.opts.chatId,
1190
+ });
1191
+ if (decision.kind === "reply") {
1192
+ const session = this.sessions.get(decision.sessionId);
1193
+ if (session?.ws.readyState !== WebSocket.OPEN || !session.pending.has(decision.actionId)) {
1194
+ await this.sendStaleGuidance(callbackId);
1195
+ return;
1196
+ }
1197
+ if (typeof callbackId === "string")
1198
+ await this.botApi.call("answerCallbackQuery", { callback_query_id: callbackId });
1199
+ session.ws.send(
1200
+ JSON.stringify({ type: "reply", id: decision.actionId, answer: decision.answer, token: session.token }),
1201
+ );
1202
+ } else if (decision.kind === "stale") {
1203
+ await this.sendStaleGuidance(callbackId);
1204
+ }
1205
+ }
1206
+
1207
+ 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
+ });
1269
+ }
1270
+
1271
+ /** Sync the bot's Telegram command menu to what the daemon actually handles. */
1272
+ async registerBotCommands(): Promise<void> {
1273
+ try {
1274
+ await this.botApi.call("setMyCommands", {
1275
+ commands: [
1276
+ { command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
1277
+ { command: "lean", description: "Mirror assistant text + tool names only (default)" },
1278
+ { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
1279
+ ],
1280
+ });
1281
+ } catch {
1282
+ // Best-effort: a failed command-menu sync must never stop the daemon.
1283
+ }
1284
+ }
1285
+
1286
+ async run(): Promise<void> {
1287
+ this.running = await renewDaemonHeartbeat({
1288
+ settings: this.opts.settings,
1289
+ ownerId: this.opts.ownerId,
1290
+ fs: this.fsImpl,
1291
+ now: this.opts.now,
1292
+ pid: this.opts.pid ?? process.pid,
1293
+ });
1294
+ if (!this.running) return;
1295
+ this.startFlushTimer();
1296
+ this.startScanTimer();
1297
+ this.startTypingTimer();
1298
+ try {
1299
+ await this.registerBotCommands();
1300
+ await this.loadAliases();
1301
+ await this.loadTopics();
1302
+ await this.runScan();
1303
+ let idleSince = (this.opts.now ?? Date.now)();
1304
+ let pollBackoffMs = 0;
1305
+ while (this.running) {
1306
+ if (await this.controlStopRequested()) break;
1307
+ if (
1308
+ !(await renewDaemonHeartbeat({
1309
+ settings: this.opts.settings,
1310
+ ownerId: this.opts.ownerId,
1311
+ fs: this.fsImpl,
1312
+ now: this.opts.now,
1313
+ pid: this.opts.pid ?? process.pid,
1314
+ }))
1315
+ )
1316
+ break;
1317
+ await this.runScan();
1318
+ 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;
1321
+ } else {
1322
+ idleSince = (this.opts.now ?? Date.now)();
1323
+ this.activePoll = new AbortController();
1324
+ try {
1325
+ await this.pollOnce(this.activePoll.signal);
1326
+ pollBackoffMs = 0;
1327
+ } catch (e) {
1328
+ // A transient getUpdates/network failure must not kill the
1329
+ // daemon. Back off (bounded, below the heartbeat TTL) and keep
1330
+ // 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));
1334
+ continue;
1335
+ } finally {
1336
+ this.activePoll = undefined;
1337
+ }
1338
+ }
1339
+ if (await this.controlStopRequested()) break;
1340
+ await new Promise(resolve => (this.opts.setTimeoutImpl ?? setTimeout)(resolve, 10));
1341
+ }
1342
+ } finally {
1343
+ this.stopFlushTimer();
1344
+ this.stopScanTimer();
1345
+ this.stopTypingTimer();
1346
+ // Persist durable state before releasing ownership so a fresh daemon
1347
+ // (e.g. after reload) reloads aliases/topics seamlessly.
1348
+ await this.persistAliases().catch(() => undefined);
1349
+ await this.persistTopics().catch(() => undefined);
1350
+ await this.opts.control?.clear?.(this.opts.ownerId).catch(() => undefined);
1351
+ await releaseDaemonOwnership({
1352
+ settings: this.opts.settings,
1353
+ ownerId: this.opts.ownerId,
1354
+ fs: this.fsImpl,
1355
+ now: this.opts.now,
1356
+ });
1357
+ }
1358
+ }
1359
+
1360
+ /** True when a signal-driven stop or an owner-scoped control request asks the loop to exit. */
1361
+ private async controlStopRequested(): Promise<boolean> {
1362
+ if (this.stopRequested) return true;
1363
+ if (!this.opts.control) return false;
1364
+ try {
1365
+ return await this.opts.control.shouldStop(this.opts.ownerId);
1366
+ } catch {
1367
+ return false;
1368
+ }
1369
+ }
1370
+ }