tau-all-agent 0.1.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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/extensions/answer.ts +601 -0
  4. package/extensions/branch-term.ts +405 -0
  5. package/extensions/btw.ts +444 -0
  6. package/extensions/ghostty.ts +301 -0
  7. package/extensions/git-diff-stats.ts +277 -0
  8. package/extensions/git-pr-status.ts +286 -0
  9. package/extensions/insights.ts +2367 -0
  10. package/extensions/interlude.ts +144 -0
  11. package/extensions/loop.ts +529 -0
  12. package/extensions/memory.ts +1889 -0
  13. package/extensions/notify.ts +161 -0
  14. package/extensions/openai-fast.ts +227 -0
  15. package/extensions/openai-verbosity.ts +223 -0
  16. package/extensions/review.ts +4347 -0
  17. package/extensions/sandbox/index.ts +2578 -0
  18. package/extensions/telegram/README.md +81 -0
  19. package/extensions/telegram/daemon.mjs +1942 -0
  20. package/extensions/telegram/index.ts +1073 -0
  21. package/extensions/telegram/message-text.d.mts +1 -0
  22. package/extensions/telegram/message-text.mjs +16 -0
  23. package/extensions/usage/anthropic.ts +198 -0
  24. package/extensions/usage/github-copilot.ts +204 -0
  25. package/extensions/usage/google-gemini-cli.ts +232 -0
  26. package/extensions/usage/index.ts +2310 -0
  27. package/extensions/usage/minimax.ts +208 -0
  28. package/extensions/usage/openai-codex.ts +180 -0
  29. package/extensions/usage/openrouter.ts +168 -0
  30. package/extensions/usage/providers.ts +26 -0
  31. package/extensions/usage/shared.ts +156 -0
  32. package/extensions/usage/types.ts +53 -0
  33. package/extensions/usage/zai.ts +186 -0
  34. package/extensions/websearch/README.md +68 -0
  35. package/extensions/websearch/browser/chromium.ts +330 -0
  36. package/extensions/websearch/browser/discovery.ts +75 -0
  37. package/extensions/websearch/browser/firefox.ts +150 -0
  38. package/extensions/websearch/browser/sqlite.ts +38 -0
  39. package/extensions/websearch/config.ts +76 -0
  40. package/extensions/websearch/index.ts +312 -0
  41. package/extensions/websearch/normalize.ts +155 -0
  42. package/extensions/websearch/providers/anthropic.pi.ts +139 -0
  43. package/extensions/websearch/providers/gemini.browser.ts +185 -0
  44. package/extensions/websearch/providers/gemini.pi.ts +108 -0
  45. package/extensions/websearch/providers/openai-codex.browser.ts +77 -0
  46. package/extensions/websearch/providers/openai-codex.pi.ts +49 -0
  47. package/extensions/websearch/providers/openai-codex.shared.ts +123 -0
  48. package/extensions/websearch/providers/pi-model.shared.ts +86 -0
  49. package/extensions/websearch/providers/search-prompt.shared.ts +17 -0
  50. package/extensions/websearch/providers/shared.ts +76 -0
  51. package/extensions/websearch/types.ts +52 -0
  52. package/extensions/worktree.ts +2223 -0
  53. package/package.json +76 -0
  54. package/skills/browser-tools/SKILL.md +253 -0
  55. package/skills/browser-tools/scripts/browser-content.js +100 -0
  56. package/skills/browser-tools/scripts/browser-cookies.js +33 -0
  57. package/skills/browser-tools/scripts/browser-dismiss-cookies.js +455 -0
  58. package/skills/browser-tools/scripts/browser-eval.js +75 -0
  59. package/skills/browser-tools/scripts/browser-logs-tail.js +89 -0
  60. package/skills/browser-tools/scripts/browser-nav.js +28 -0
  61. package/skills/browser-tools/scripts/browser-net-summary.js +115 -0
  62. package/skills/browser-tools/scripts/browser-pick.js +133 -0
  63. package/skills/browser-tools/scripts/browser-screenshot.js +18 -0
  64. package/skills/browser-tools/scripts/browser-start.js +281 -0
  65. package/skills/browser-tools/scripts/browser-watch.js +321 -0
  66. package/skills/browser-tools/scripts/utils.js +60 -0
  67. package/skills/git-clean-history/SKILL.md +45 -0
  68. package/skills/git-commit/SKILL.md +55 -0
  69. package/skills/homeassistant-ops/SKILL.md +184 -0
  70. package/skills/homeassistant-ops/references/api.md +64 -0
  71. package/skills/homeassistant-ops/references/id_conventions.md +71 -0
  72. package/skills/homeassistant-ops/references/playbook.md +150 -0
  73. package/skills/homeassistant-ops/scripts/ha_ops.js +2942 -0
  74. package/skills/openscad/SKILL.md +149 -0
  75. package/skills/openscad/examples/parametric_box.scad +92 -0
  76. package/skills/openscad/examples/phone_stand.scad +95 -0
  77. package/skills/openscad/scripts/common.sh +49 -0
  78. package/skills/openscad/scripts/export-stl.sh +56 -0
  79. package/skills/openscad/scripts/extract-params.sh +147 -0
  80. package/skills/openscad/scripts/multi-preview.sh +68 -0
  81. package/skills/openscad/scripts/preview.sh +74 -0
  82. package/skills/openscad/scripts/render-with-params.sh +91 -0
  83. package/skills/openscad/scripts/validate.sh +46 -0
  84. package/skills/oracle/SKILL.md +64 -0
  85. package/skills/oracle/scripts/oracle +249 -0
  86. package/skills/oracle/scripts/oracle-bundle +227 -0
  87. package/skills/sentry/SKILL.md +198 -0
  88. package/skills/sentry/lib/auth.js +99 -0
  89. package/skills/sentry/scripts/fetch-event.js +325 -0
  90. package/skills/sentry/scripts/fetch-issue.js +368 -0
  91. package/skills/sentry/scripts/list-issues.js +245 -0
  92. package/skills/sentry/scripts/search-events.js +291 -0
  93. package/skills/sentry/scripts/search-logs.js +234 -0
  94. package/skills/update-changelog/SKILL.md +135 -0
  95. package/skills/web-design/SKILL.md +117 -0
@@ -0,0 +1,1942 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import net from "node:net";
6
+ import process from "node:process";
7
+ import { execFile, spawn } from "node:child_process";
8
+ import { randomUUID } from "node:crypto";
9
+ import TelegramBot from "node-telegram-bot-api";
10
+ import { extractTextFromMessage } from "./message-text.mjs";
11
+
12
+ const AGENT_DIR = path.join(os.homedir(), ".pi", "agent");
13
+ const RUN_DIR = path.join(AGENT_DIR, "run");
14
+ const SOCKET_PATH = path.join(RUN_DIR, "telegram.sock");
15
+ const CONFIG_DIR = path.join(AGENT_DIR, "telegram");
16
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
17
+ const TELEGRAM_BOT_TOKEN_ENV = "PI_TELEGRAM_BOT_TOKEN";
18
+ const TELEGRAM_KEYCHAIN_SERVICE = "pi.telegram";
19
+ const TELEGRAM_KEYCHAIN_ACCOUNT = "bot-token";
20
+
21
+ const TELEGRAM_COMMANDS = [
22
+ { command: "pin", description: "Pair this chat with pi using a 6-digit PIN" },
23
+ { command: "session", description: "List, switch, create, or quit sessions" },
24
+ { command: "esc", description: "Abort current run in active session" },
25
+ { command: "unpair", description: "Unpair Telegram and terminate headless sessions" },
26
+ { command: "help", description: "Show available commands" },
27
+ ];
28
+
29
+ const TELEGRAM_POLL_TIMEOUT_SECONDS = 30;
30
+ const TELEGRAM_HTTP_TIMEOUT_MS = 35_000;
31
+ const POLLING_STOP_TIMEOUT_MS = 4_000;
32
+ const ACTIVITY_NOTICE_COOLDOWN_MS = 60 * 60 * 1000;
33
+ const UNPAIRED_IDLE_SHUTDOWN_MS = 60_000;
34
+ const HEADLESS_START_TIMEOUT_MS = 10_000;
35
+ const HEADLESS_STOP_TIMEOUT_MS = 5_000;
36
+ const HEADLESS_ABORT_TIMEOUT_MS = 60_000;
37
+ const POST_COMPACTION_RETRY_GRACE_MS = 500;
38
+ const MAX_UNREAD_TURNS_PER_SESSION = 20;
39
+ const MAX_QUEUED_HEADLESS_PROMPTS = 20;
40
+ const RECENT_UPDATE_TTL_MS = 6 * 60 * 60 * 1000;
41
+ const MAX_RECENT_UPDATES = 5_000;
42
+ const PI_EXECUTABLE = process.env.PI_TELEGRAM_PI_EXECUTABLE || "pi";
43
+ const PI_ENTRYPOINT = process.env.PI_TELEGRAM_PI_ENTRYPOINT?.trim() || undefined;
44
+ const RESOLVED_TMPDIR = await fsp.realpath(os.tmpdir()).catch(() => os.tmpdir());
45
+ const HEADLESS_SESSION_PATH_ERROR = "Path must start with / or ~ and refer to a directory.";
46
+
47
+ async function loadConfig() {
48
+ try {
49
+ const raw = await fsp.readFile(CONFIG_PATH, "utf8");
50
+ return JSON.parse(raw);
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ async function removeDirectoryIfEmpty(dirPath) {
57
+ try {
58
+ const entries = await fsp.readdir(dirPath);
59
+ if (!entries.length) {
60
+ await fsp.rmdir(dirPath);
61
+ }
62
+ } catch {
63
+ // ignore
64
+ }
65
+ }
66
+
67
+ async function saveConfig(cfg) {
68
+ const nextConfig = {};
69
+ const botToken = typeof cfg?.botToken === "string" ? cfg.botToken.trim() : "";
70
+ if (botToken) nextConfig.botToken = botToken;
71
+ if (typeof cfg?.pairedChatId === "number") nextConfig.pairedChatId = cfg.pairedChatId;
72
+
73
+ if (nextConfig.botToken === undefined && nextConfig.pairedChatId === undefined) {
74
+ await fsp.unlink(CONFIG_PATH).catch(() => undefined);
75
+ await removeDirectoryIfEmpty(CONFIG_DIR);
76
+ return;
77
+ }
78
+
79
+ await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
80
+ const tmp = CONFIG_PATH + ".tmp";
81
+ await fsp.writeFile(tmp, JSON.stringify(nextConfig, null, 2) + "\n", { mode: 0o600 });
82
+ await fsp.rename(tmp, CONFIG_PATH);
83
+ }
84
+
85
+ function canUseTelegramKeychain() {
86
+ return process.platform === "darwin";
87
+ }
88
+
89
+ async function runTelegramKeychainCommand(args) {
90
+ return await new Promise((resolve, reject) => {
91
+ execFile("security", args, { encoding: "utf8" }, (error, stdout) => {
92
+ if (error) {
93
+ reject(error);
94
+ return;
95
+ }
96
+ resolve(stdout.trim());
97
+ });
98
+ });
99
+ }
100
+
101
+ async function readTelegramBotTokenFromKeychain() {
102
+ if (!canUseTelegramKeychain()) return null;
103
+
104
+ try {
105
+ return (
106
+ (await runTelegramKeychainCommand([
107
+ "find-generic-password",
108
+ "-w",
109
+ "-a",
110
+ TELEGRAM_KEYCHAIN_ACCOUNT,
111
+ "-s",
112
+ TELEGRAM_KEYCHAIN_SERVICE,
113
+ ])) || null
114
+ );
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function buildPersistedConfig(config, tokenSource) {
121
+ const nextConfig = {};
122
+ if (tokenSource === "config" && typeof config?.botToken === "string" && config.botToken.trim()) {
123
+ nextConfig.botToken = config.botToken.trim();
124
+ }
125
+ if (typeof config?.pairedChatId === "number") nextConfig.pairedChatId = config.pairedChatId;
126
+ return nextConfig;
127
+ }
128
+
129
+ async function resolveTelegramBotToken(config = undefined) {
130
+ const envToken = process.env[TELEGRAM_BOT_TOKEN_ENV]?.trim();
131
+ if (envToken) return { token: envToken, source: "env" };
132
+
133
+ const keychainToken = await readTelegramBotTokenFromKeychain();
134
+ if (keychainToken) return { token: keychainToken, source: "keychain" };
135
+
136
+ const loadedConfig = config ?? (await loadConfig());
137
+ const configuredToken =
138
+ typeof loadedConfig?.botToken === "string" ? loadedConfig.botToken.trim() : "";
139
+ if (configuredToken) return { token: configuredToken, source: "config" };
140
+
141
+ return { token: null, source: "missing" };
142
+ }
143
+
144
+ function describeTelegramBotTokenSetup() {
145
+ if (canUseTelegramKeychain()) {
146
+ return `Set ${TELEGRAM_BOT_TOKEN_ENV}, store it in macOS Keychain, or create ${CONFIG_PATH} with {"botToken": "..."}.`;
147
+ }
148
+
149
+ return `Set ${TELEGRAM_BOT_TOKEN_ENV} or create ${CONFIG_PATH} with {"botToken": "..."}.`;
150
+ }
151
+
152
+ function safeJsonParse(line) {
153
+ try {
154
+ return JSON.parse(line);
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ function makeJsonlWriter(socket) {
161
+ return (obj) => {
162
+ try {
163
+ socket.write(JSON.stringify(obj) + "\n");
164
+ } catch {
165
+ // ignore
166
+ }
167
+ };
168
+ }
169
+
170
+ function attachJsonlReader(stream, onMessage) {
171
+ stream.setEncoding("utf8");
172
+ let buffer = "";
173
+
174
+ stream.on("data", (chunk) => {
175
+ buffer += chunk;
176
+
177
+ while (true) {
178
+ const newlineIndex = buffer.indexOf("\n");
179
+ if (newlineIndex === -1) break;
180
+
181
+ let line = buffer.slice(0, newlineIndex);
182
+ buffer = buffer.slice(newlineIndex + 1);
183
+ if (line.endsWith("\r")) line = line.slice(0, -1);
184
+ if (line.trim().length === 0) continue;
185
+ onMessage(line);
186
+ }
187
+ });
188
+ }
189
+
190
+ function chunkText(text, max = 3500) {
191
+ const chunks = [];
192
+ let i = 0;
193
+ while (i < text.length) {
194
+ chunks.push(text.slice(i, i + max));
195
+ i += max;
196
+ }
197
+ return chunks;
198
+ }
199
+
200
+ function sleep(ms) {
201
+ return new Promise((resolve) => setTimeout(resolve, ms));
202
+ }
203
+
204
+ function errorMessage(error) {
205
+ if (error instanceof Error) return error.message;
206
+ return String(error);
207
+ }
208
+
209
+ function isLikelyNetworkPollingError(error) {
210
+ const message = errorMessage(error).toUpperCase();
211
+ const networkCodes = [
212
+ "ETIMEDOUT",
213
+ "ECONNRESET",
214
+ "ECONNREFUSED",
215
+ "EAI_AGAIN",
216
+ "ENOTFOUND",
217
+ "EHOSTUNREACH",
218
+ "ENETUNREACH",
219
+ "ESOCKETTIMEDOUT",
220
+ ];
221
+
222
+ return networkCodes.some((code) => message.includes(code));
223
+ }
224
+
225
+ function getCommandError(response, fallback) {
226
+ if (response && typeof response.error === "string" && response.error.trim())
227
+ return response.error.trim();
228
+ return fallback;
229
+ }
230
+
231
+ function autoCancelExtensionUiRequest(request, write) {
232
+ if (!request || request.type !== "extension_ui_request" || typeof request.id !== "string")
233
+ return false;
234
+
235
+ write({ type: "extension_ui_response", id: request.id, cancelled: true });
236
+ return true;
237
+ }
238
+
239
+ function createHeadlessRpcClient(cwd) {
240
+ const childArgs = PI_ENTRYPOINT ? [PI_ENTRYPOINT, "--mode", "rpc"] : ["--mode", "rpc"];
241
+ const child = spawn(PI_EXECUTABLE, childArgs, {
242
+ cwd,
243
+ env: { ...process.env, PI_TELEGRAM_DISABLE: "1" },
244
+ stdio: ["pipe", "pipe", "pipe"],
245
+ });
246
+
247
+ const write = child.stdin
248
+ ? makeJsonlWriter(child.stdin)
249
+ : () => {
250
+ throw new Error("Headless pi session transport is unavailable.");
251
+ };
252
+ const eventHandlers = new Set();
253
+ const responseHandlers = new Set();
254
+ const closeHandlers = new Set();
255
+ const pending = new Map();
256
+ const closed = Promise.withResolvers();
257
+ let nextRequestId = 1;
258
+ let stopPromise = null;
259
+ let transportClosed = false;
260
+
261
+ function createExitError(code, signal) {
262
+ return new Error(
263
+ `Headless pi session exited${code !== null ? ` with code ${code}` : ""}${signal ? ` (signal ${signal})` : ""}`,
264
+ );
265
+ }
266
+
267
+ function finishTransportClose(error, meta = { code: null, signal: null }) {
268
+ if (transportClosed) return;
269
+ transportClosed = true;
270
+
271
+ const message = errorMessage(error);
272
+ for (const { resolve, timeout } of pending.values()) {
273
+ clearTimeout(timeout);
274
+ resolve({ type: "response", success: false, error: message });
275
+ }
276
+ pending.clear();
277
+
278
+ closed.resolve(meta);
279
+
280
+ for (const handler of Array.from(closeHandlers)) {
281
+ try {
282
+ handler({ ...meta, error: message });
283
+ } catch {
284
+ // ignore
285
+ }
286
+ }
287
+ }
288
+
289
+ child.stdin?.on("error", () => {
290
+ // handled by transport close
291
+ });
292
+ child.on("error", (error) => {
293
+ finishTransportClose(error, { code: null, signal: null });
294
+ });
295
+
296
+ if (child.stdout) {
297
+ attachJsonlReader(child.stdout, (line) => {
298
+ const msg = safeJsonParse(line);
299
+ if (!msg || typeof msg.type !== "string") return;
300
+
301
+ if (msg.type === "response") {
302
+ for (const handler of Array.from(responseHandlers)) {
303
+ try {
304
+ handler(msg);
305
+ } catch {
306
+ // ignore
307
+ }
308
+ }
309
+
310
+ const id = typeof msg.id === "string" ? msg.id : undefined;
311
+ if (!id) return;
312
+ const pendingRequest = pending.get(id);
313
+ if (!pendingRequest) return;
314
+ pending.delete(id);
315
+ clearTimeout(pendingRequest.timeout);
316
+ pendingRequest.resolve(msg);
317
+ return;
318
+ }
319
+
320
+ if (msg.type === "extension_ui_request") {
321
+ autoCancelExtensionUiRequest(msg, write);
322
+ return;
323
+ }
324
+
325
+ for (const handler of Array.from(eventHandlers)) {
326
+ try {
327
+ handler(msg);
328
+ } catch {
329
+ // ignore
330
+ }
331
+ }
332
+ });
333
+ }
334
+
335
+ if (child.stderr) {
336
+ child.stderr.setEncoding("utf8");
337
+ child.stderr.on("data", (chunk) => {
338
+ const text = String(chunk).trimEnd();
339
+ if (!text) return;
340
+ console.error(`[telegram/headless ${path.basename(cwd) || cwd}] ${text}`);
341
+ });
342
+ }
343
+
344
+ child.once("close", (code, signal) => {
345
+ finishTransportClose(createExitError(code, signal), { code, signal });
346
+ });
347
+
348
+ async function call(command, { timeoutMs = HEADLESS_START_TIMEOUT_MS } = {}) {
349
+ if (child.exitCode !== null) {
350
+ return {
351
+ type: "response",
352
+ success: false,
353
+ error: `Headless pi session already exited with code ${child.exitCode}`,
354
+ };
355
+ }
356
+
357
+ const id = `telegram-rpc-${nextRequestId++}`;
358
+ const payload = { ...command, id };
359
+
360
+ return await new Promise((resolve) => {
361
+ const timeout = setTimeout(() => {
362
+ pending.delete(id);
363
+ resolve({
364
+ type: "response",
365
+ success: false,
366
+ error: `Timed out waiting for ${command.type} response`,
367
+ });
368
+ }, timeoutMs);
369
+
370
+ pending.set(id, { resolve, timeout });
371
+
372
+ try {
373
+ write(payload);
374
+ } catch (error) {
375
+ pending.delete(id);
376
+ clearTimeout(timeout);
377
+ resolve({ type: "response", success: false, error: errorMessage(error) });
378
+ }
379
+ });
380
+ }
381
+
382
+ async function prompt(
383
+ message,
384
+ {
385
+ streamingBehavior = "followUp",
386
+ waitForStart = false,
387
+ timeoutMs = HEADLESS_START_TIMEOUT_MS,
388
+ onAccepted,
389
+ onStarted,
390
+ onFailed,
391
+ } = {},
392
+ ) {
393
+ if (child.exitCode !== null) {
394
+ throw new Error(`Headless pi session already exited with code ${child.exitCode}`);
395
+ }
396
+
397
+ const id = `telegram-rpc-${nextRequestId++}`;
398
+ const payload = { type: "prompt", message, streamingBehavior, id };
399
+
400
+ return await new Promise((resolve, reject) => {
401
+ let accepted = false;
402
+ let resolved = false;
403
+ let trackingActive = true;
404
+ const timeout = setTimeout(() => {
405
+ cleanup();
406
+ onFailed?.({
407
+ type: "response",
408
+ id,
409
+ success: false,
410
+ error: "Timed out waiting for prompt response",
411
+ });
412
+ reject(
413
+ new Error(
414
+ waitForStart
415
+ ? "Timed out waiting for prompt to start"
416
+ : "Timed out waiting for prompt response",
417
+ ),
418
+ );
419
+ }, timeoutMs);
420
+
421
+ const cleanup = () => {
422
+ if (!trackingActive) return;
423
+ trackingActive = false;
424
+ clearTimeout(timeout);
425
+ responseHandlers.delete(handleResponse);
426
+ eventHandlers.delete(handleEvent);
427
+ closeHandlers.delete(handleClose);
428
+ };
429
+
430
+ const resolveOnce = () => {
431
+ if (resolved) return;
432
+ resolved = true;
433
+ resolve({ id, success: true });
434
+ };
435
+
436
+ const rejectOnce = (error) => {
437
+ if (resolved) return;
438
+ resolved = true;
439
+ reject(error instanceof Error ? error : new Error(String(error)));
440
+ };
441
+
442
+ const handleResponse = (response) => {
443
+ if (response.id !== id) return;
444
+
445
+ if (response.success === false) {
446
+ cleanup();
447
+ onFailed?.(response);
448
+ rejectOnce(
449
+ new Error(getCommandError(response, "Failed to send prompt to headless session")),
450
+ );
451
+ return;
452
+ }
453
+
454
+ if (accepted) return;
455
+ accepted = true;
456
+ onAccepted?.(id);
457
+
458
+ if (!waitForStart) {
459
+ cleanup();
460
+ resolveOnce();
461
+ }
462
+ };
463
+
464
+ const handleEvent = (event) => {
465
+ if (!accepted || event.type !== "agent_start") return;
466
+ onStarted?.(id);
467
+ cleanup();
468
+ resolveOnce();
469
+ };
470
+
471
+ const handleClose = ({ error }) => {
472
+ cleanup();
473
+ onFailed?.({ type: "response", id, success: false, error });
474
+ rejectOnce(new Error(error));
475
+ };
476
+
477
+ responseHandlers.add(handleResponse);
478
+ eventHandlers.add(handleEvent);
479
+ closeHandlers.add(handleClose);
480
+
481
+ try {
482
+ write(payload);
483
+ } catch (error) {
484
+ cleanup();
485
+ onFailed?.({ type: "response", id, success: false, error: errorMessage(error) });
486
+ rejectOnce(error);
487
+ }
488
+ });
489
+ }
490
+
491
+ async function stop() {
492
+ if (stopPromise) return await stopPromise;
493
+
494
+ stopPromise = (async () => {
495
+ if (child.exitCode !== null) return;
496
+
497
+ try {
498
+ child.kill("SIGTERM");
499
+ } catch {
500
+ // ignore
501
+ }
502
+
503
+ const result = await Promise.race([
504
+ closed.promise,
505
+ sleep(HEADLESS_STOP_TIMEOUT_MS).then(() => null),
506
+ ]);
507
+
508
+ if (result !== null) return;
509
+ if (child.exitCode !== null) return;
510
+
511
+ try {
512
+ child.kill("SIGKILL");
513
+ } catch {
514
+ // ignore
515
+ }
516
+
517
+ await Promise.race([closed.promise, sleep(1_000)]);
518
+ })();
519
+
520
+ return await stopPromise;
521
+ }
522
+
523
+ return {
524
+ child,
525
+ call,
526
+ prompt,
527
+ stop,
528
+ onEvent(handler) {
529
+ eventHandlers.add(handler);
530
+ return () => eventHandlers.delete(handler);
531
+ },
532
+ onResponse(handler) {
533
+ responseHandlers.add(handler);
534
+ return () => responseHandlers.delete(handler);
535
+ },
536
+ onClose(handler) {
537
+ closeHandlers.add(handler);
538
+ return () => closeHandlers.delete(handler);
539
+ },
540
+ };
541
+ }
542
+
543
+ function normalizeHeadlessSessionPath(rawPath) {
544
+ const trimmed = rawPath.trim();
545
+ let expanded = trimmed;
546
+
547
+ if (!expanded) {
548
+ expanded = os.tmpdir();
549
+ } else if (expanded === "~") {
550
+ expanded = os.homedir();
551
+ } else if (expanded.startsWith("~/")) {
552
+ expanded = path.join(os.homedir(), expanded.slice(2));
553
+ } else if (!expanded.startsWith("/")) {
554
+ throw new Error(HEADLESS_SESSION_PATH_ERROR);
555
+ }
556
+
557
+ return path.resolve(expanded);
558
+ }
559
+
560
+ async function inspectHeadlessSessionPath(rawPath) {
561
+ const resolved = normalizeHeadlessSessionPath(rawPath);
562
+
563
+ try {
564
+ const stats = await fsp.stat(resolved);
565
+ if (!stats.isDirectory()) {
566
+ throw new Error(HEADLESS_SESSION_PATH_ERROR);
567
+ }
568
+
569
+ const realPath = await fsp.realpath(resolved).catch(() => resolved);
570
+ return { cwd: realPath, exists: true };
571
+ } catch (error) {
572
+ if (error?.code === "ENOENT") {
573
+ return { cwd: resolved, exists: false };
574
+ }
575
+ if (error?.code === "ENOTDIR") {
576
+ throw new Error(HEADLESS_SESSION_PATH_ERROR);
577
+ }
578
+ throw error;
579
+ }
580
+ }
581
+
582
+ async function ensureHeadlessSessionPathExists(cwd) {
583
+ await fsp.mkdir(cwd, { recursive: true, mode: 0o700 });
584
+ return await fsp.realpath(cwd).catch(() => cwd);
585
+ }
586
+
587
+ let config = await loadConfig();
588
+ const botTokenInfo = await resolveTelegramBotToken(config);
589
+ if (!botTokenInfo.token) {
590
+ console.error(`[telegram] Missing bot token. ${describeTelegramBotTokenSetup()}`);
591
+ process.exit(1);
592
+ }
593
+ config = buildPersistedConfig(config, botTokenInfo.source);
594
+
595
+ let bot = null;
596
+ const sessions = new Map();
597
+ let nextSessionNo = 1;
598
+ let pairedChatId = config.pairedChatId;
599
+
600
+ const chatState = {
601
+ activeSessionKey: undefined,
602
+ lastSeenSeqBySessionKey: {},
603
+ lastActivityNotice: undefined,
604
+ };
605
+
606
+ const pendingPins = new Map();
607
+ const recentProcessedUpdates = new Map();
608
+ let pendingDirectoryCreation = null;
609
+
610
+ let shutdownTimer = null;
611
+ let typingTimer = null;
612
+ let server = null;
613
+ let shuttingDown = false;
614
+
615
+ function isAuthorizedChat(chatId) {
616
+ return pairedChatId !== undefined && chatId === pairedChatId;
617
+ }
618
+
619
+ function getActiveSession() {
620
+ if (!chatState.activeSessionKey) return null;
621
+ return sessions.get(chatState.activeSessionKey) ?? null;
622
+ }
623
+
624
+ function getSessionByNo(sessionNo) {
625
+ return [...sessions.values()].find((session) => session.sessionNo === sessionNo) ?? null;
626
+ }
627
+
628
+ function getSessionCallbackData(session) {
629
+ return `session:${session.key}`;
630
+ }
631
+
632
+ function getSessionKeyFromCallbackData(callbackData) {
633
+ const match = typeof callbackData === "string" ? callbackData.match(/^session:(.+)$/) : null;
634
+ return match?.[1] ?? null;
635
+ }
636
+
637
+ function getDisplaySessionName(session) {
638
+ if (typeof session.sessionName === "string" && session.sessionName.trim())
639
+ return session.sessionName.trim();
640
+ if (session.kind === "headless" && session.cwd === RESOLVED_TMPDIR) return "tmp";
641
+ return path.basename(session.cwd || "") || session.cwd || "(unknown)";
642
+ }
643
+
644
+ function getUpdateDedupeKeyForMessage(msg) {
645
+ if (!msg || typeof msg !== "object") return null;
646
+ const chatId = msg.chat?.id;
647
+ const messageId = msg.message_id;
648
+ if (chatId === undefined || !Number.isInteger(messageId)) return null;
649
+ return `message:${chatId}:${messageId}`;
650
+ }
651
+
652
+ function getUpdateDedupeKeyForCallbackQuery(query) {
653
+ if (!query || typeof query !== "object") return null;
654
+ if (typeof query.id !== "string" || !query.id) return null;
655
+ return `callback:${query.id}`;
656
+ }
657
+
658
+ function markUpdateProcessed(updateKey, now = Date.now()) {
659
+ recentProcessedUpdates.set(updateKey, now);
660
+
661
+ for (const [key, timestamp] of recentProcessedUpdates) {
662
+ if (
663
+ now - timestamp <= RECENT_UPDATE_TTL_MS &&
664
+ recentProcessedUpdates.size <= MAX_RECENT_UPDATES
665
+ ) {
666
+ break;
667
+ }
668
+ recentProcessedUpdates.delete(key);
669
+ }
670
+ }
671
+
672
+ function wasUpdateProcessed(updateKey, now = Date.now()) {
673
+ if (!updateKey) return false;
674
+
675
+ const previous = recentProcessedUpdates.get(updateKey);
676
+ if (previous !== undefined) {
677
+ if (now - previous <= RECENT_UPDATE_TTL_MS) {
678
+ return true;
679
+ }
680
+ recentProcessedUpdates.delete(updateKey);
681
+ }
682
+
683
+ markUpdateProcessed(updateKey, now);
684
+ return false;
685
+ }
686
+
687
+ function getWindowSessionRef(update) {
688
+ if (!update || typeof update !== "object") return null;
689
+ if (typeof update.sessionId !== "string" || !update.sessionId) return null;
690
+
691
+ return {
692
+ sessionId: update.sessionId,
693
+ sessionFile:
694
+ typeof update.sessionFile === "string" && update.sessionFile.trim()
695
+ ? update.sessionFile.trim()
696
+ : undefined,
697
+ };
698
+ }
699
+
700
+ function resetWindowSessionTurns(session) {
701
+ session.lastTurnText = undefined;
702
+ session.lastTurnSeq = 0;
703
+ session.unreadTurns = [];
704
+ session.droppedUnreadTurns = 0;
705
+ chatState.lastSeenSeqBySessionKey[session.key] = 0;
706
+ clearActivityNotice(session.key);
707
+ }
708
+
709
+ function updateWindowSessionRef(session, update) {
710
+ const nextRef = getWindowSessionRef(update);
711
+ if (!nextRef) return;
712
+
713
+ const changed =
714
+ session.piSessionId !== nextRef.sessionId || session.piSessionFile !== nextRef.sessionFile;
715
+
716
+ session.piSessionId = nextRef.sessionId;
717
+ session.piSessionFile = nextRef.sessionFile;
718
+
719
+ if (changed) {
720
+ resetWindowSessionTurns(session);
721
+ }
722
+ }
723
+
724
+ function getUnreadCount(session) {
725
+ const lastSeen = chatState.lastSeenSeqBySessionKey[session.key] ?? 0;
726
+ return Math.max(0, session.lastTurnSeq - lastSeen);
727
+ }
728
+
729
+ function shouldSendActivityNotice(sessionKey, now = Date.now()) {
730
+ const lastNotice = chatState.lastActivityNotice;
731
+ if (!lastNotice) return true;
732
+ if (lastNotice.sessionKey !== sessionKey) return true;
733
+ return now - lastNotice.sentAt >= ACTIVITY_NOTICE_COOLDOWN_MS;
734
+ }
735
+
736
+ function recordActivityNotice(sessionKey, now = Date.now()) {
737
+ chatState.lastActivityNotice = { sessionKey, sentAt: now };
738
+ }
739
+
740
+ function clearActivityNotice(sessionKey) {
741
+ if (!sessionKey) return;
742
+ if (chatState.lastActivityNotice?.sessionKey === sessionKey) {
743
+ chatState.lastActivityNotice = undefined;
744
+ }
745
+ }
746
+
747
+ function resolveSessionCompactionWaiters(session) {
748
+ if (!session.compactionWaiters?.size) return;
749
+ for (const waiter of session.compactionWaiters) {
750
+ waiter.resolve();
751
+ }
752
+ session.compactionWaiters.clear();
753
+ }
754
+
755
+ function rejectSessionCompactionWaiters(session, error) {
756
+ if (!session.compactionWaiters?.size) return;
757
+ for (const waiter of session.compactionWaiters) {
758
+ waiter.reject(error);
759
+ }
760
+ session.compactionWaiters.clear();
761
+ }
762
+
763
+ function waitForSessionCompactionToFinish(session) {
764
+ if (!session.compacting) return Promise.resolve();
765
+
766
+ return new Promise((resolve, reject) => {
767
+ if (!session.compactionWaiters) {
768
+ session.compactionWaiters = new Set();
769
+ }
770
+
771
+ session.compactionWaiters.add({ resolve, reject });
772
+ });
773
+ }
774
+
775
+ function setSessionCompacting(session, compacting) {
776
+ const next = Boolean(compacting);
777
+ const previous = Boolean(session.compacting);
778
+ if (previous === next) return;
779
+
780
+ session.compacting = next;
781
+
782
+ if (next) {
783
+ if (pairedChatId) {
784
+ botSendSystem(pairedChatId, `[session ${session.sessionNo}] compacting`).catch(() => {});
785
+ }
786
+ return;
787
+ }
788
+
789
+ resolveSessionCompactionWaiters(session);
790
+ }
791
+
792
+ async function waitForHeadlessSessionPromptWindow(session) {
793
+ while (sessions.has(session.key)) {
794
+ await refreshHeadlessSessionState(session).catch(() => {});
795
+
796
+ const pendingMessageCount =
797
+ typeof session.pendingMessageCount === "number" ? session.pendingMessageCount : 0;
798
+ const waitingForRetryStart = !session.busy && session.retryAfterCompactionUntil > Date.now();
799
+ if (
800
+ !session.compacting &&
801
+ !waitingForRetryStart &&
802
+ (session.busy || pendingMessageCount === 0)
803
+ ) {
804
+ return;
805
+ }
806
+
807
+ await sleep(100);
808
+ }
809
+
810
+ throw new Error("Session is no longer available.");
811
+ }
812
+
813
+ function markSessionSeen(session) {
814
+ chatState.lastSeenSeqBySessionKey[session.key] = session.lastTurnSeq;
815
+ session.unreadTurns = [];
816
+ session.droppedUnreadTurns = 0;
817
+ }
818
+
819
+ function removeSession(sessionKey) {
820
+ const session = sessions.get(sessionKey);
821
+ if (!session) return null;
822
+
823
+ sessions.delete(sessionKey);
824
+ delete chatState.lastSeenSeqBySessionKey[sessionKey];
825
+
826
+ if (chatState.activeSessionKey === sessionKey) {
827
+ chatState.activeSessionKey = undefined;
828
+ }
829
+
830
+ clearActivityNotice(sessionKey);
831
+ updateTypingIndicator();
832
+ void maybeShutdownSoon();
833
+ return session;
834
+ }
835
+
836
+ function stopTypingIndicator() {
837
+ if (typingTimer) {
838
+ clearInterval(typingTimer);
839
+ typingTimer = null;
840
+ }
841
+ }
842
+
843
+ function startTypingIndicator() {
844
+ if (typingTimer) return;
845
+
846
+ const tick = async () => {
847
+ if (!bot || !pairedChatId) return;
848
+ try {
849
+ await bot.sendChatAction(pairedChatId, "typing");
850
+ } catch {
851
+ // ignore
852
+ }
853
+ };
854
+
855
+ void tick();
856
+ typingTimer = setInterval(() => {
857
+ void tick();
858
+ }, 4_000);
859
+ }
860
+
861
+ function updateTypingIndicator() {
862
+ const session = getActiveSession();
863
+ if (!pairedChatId || !session || !session.busy) {
864
+ stopTypingIndicator();
865
+ return;
866
+ }
867
+ startTypingIndicator();
868
+ }
869
+
870
+ async function setPairedChatId(chatId) {
871
+ pairedChatId = chatId;
872
+ config = buildPersistedConfig({ ...config, pairedChatId: chatId }, botTokenInfo.source);
873
+ await saveConfig(config);
874
+ updateTypingIndicator();
875
+ }
876
+
877
+ async function clearPairing() {
878
+ pairedChatId = undefined;
879
+ const nextConfig = { ...config };
880
+ delete nextConfig.pairedChatId;
881
+ config = buildPersistedConfig(nextConfig, botTokenInfo.source);
882
+ await saveConfig(config);
883
+ chatState.activeSessionKey = undefined;
884
+ chatState.lastSeenSeqBySessionKey = {};
885
+ chatState.lastActivityNotice = undefined;
886
+ updateTypingIndicator();
887
+ }
888
+
889
+ function disconnectAllWindowSessions() {
890
+ for (const session of sessions.values()) {
891
+ if (session.kind !== "window") continue;
892
+ void session.quit();
893
+ }
894
+ }
895
+
896
+ async function stopAllHeadlessSessions() {
897
+ const headlessSessions = [...sessions.values()].filter((session) => session.kind === "headless");
898
+ await Promise.all(
899
+ headlessSessions.map(async (session) => {
900
+ try {
901
+ await session.quit();
902
+ } catch {
903
+ // ignore
904
+ }
905
+ }),
906
+ );
907
+ }
908
+
909
+ async function stopPollingWithTimeout(reason) {
910
+ if (!bot) return;
911
+
912
+ const stopPromise = bot.stopPolling({ cancel: true, reason }).catch(() => {});
913
+ await Promise.race([stopPromise, sleep(POLLING_STOP_TIMEOUT_MS)]);
914
+ }
915
+
916
+ async function shutdownDaemon({ clearPairingState = false } = {}) {
917
+ if (shuttingDown) return;
918
+ shuttingDown = true;
919
+ cancelShutdown();
920
+
921
+ if (clearPairingState) {
922
+ try {
923
+ await clearPairing();
924
+ } catch {
925
+ // ignore
926
+ }
927
+ }
928
+
929
+ stopTypingIndicator();
930
+
931
+ try {
932
+ await stopAllHeadlessSessions();
933
+ } catch {
934
+ // ignore
935
+ }
936
+
937
+ disconnectAllWindowSessions();
938
+ sessions.clear();
939
+ chatState.activeSessionKey = undefined;
940
+ chatState.lastSeenSeqBySessionKey = {};
941
+ chatState.lastActivityNotice = undefined;
942
+
943
+ try {
944
+ await stopPollingWithTimeout("Telegram daemon shutdown");
945
+ } catch {
946
+ // ignore
947
+ }
948
+
949
+ if (server) {
950
+ await new Promise((resolve) => {
951
+ let done = false;
952
+ const finish = () => {
953
+ if (done) return;
954
+ done = true;
955
+ resolve();
956
+ };
957
+
958
+ try {
959
+ server.close(() => finish());
960
+ } catch {
961
+ finish();
962
+ }
963
+
964
+ setTimeout(finish, 200);
965
+ });
966
+ }
967
+
968
+ try {
969
+ fs.unlinkSync(SOCKET_PATH);
970
+ } catch {
971
+ // ignore
972
+ }
973
+
974
+ process.exit(0);
975
+ }
976
+
977
+ function escapeHtml(text) {
978
+ return String(text).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
979
+ }
980
+
981
+ async function botSend(chatId, text, opts = {}) {
982
+ if (!bot) return;
983
+ const chunks = chunkText(text);
984
+ for (const chunk of chunks) {
985
+ await bot.sendMessage(chatId, chunk, opts);
986
+ }
987
+ }
988
+
989
+ async function botSendAssistant(chatId, text) {
990
+ if (!bot) return;
991
+
992
+ if (text.length <= 3500) {
993
+ try {
994
+ await bot.sendMessage(chatId, text, { parse_mode: "Markdown" });
995
+ return;
996
+ } catch {
997
+ // fall back to plain text
998
+ }
999
+ }
1000
+
1001
+ await botSend(chatId, text);
1002
+ }
1003
+
1004
+ async function botSendSystem(chatId, text) {
1005
+ if (!bot) return;
1006
+ const safe = escapeHtml(text);
1007
+ await bot.sendMessage(chatId, `<i>${safe}</i>`, { parse_mode: "HTML" });
1008
+ }
1009
+
1010
+ async function syncBotCommands() {
1011
+ if (!bot) return;
1012
+ try {
1013
+ await bot.setMyCommands(TELEGRAM_COMMANDS);
1014
+ } catch (error) {
1015
+ console.error(`[telegram] Failed to sync bot commands: ${errorMessage(error)}`);
1016
+ }
1017
+ }
1018
+
1019
+ async function sendSessionList(chatId) {
1020
+ await refreshHeadlessSessionStates([...sessions.values()]);
1021
+
1022
+ const list = [...sessions.values()].sort((a, b) => a.sessionNo - b.sessionNo);
1023
+ if (list.length === 0) {
1024
+ await botSend(chatId, "No sessions. Use /session new [path] to start one.");
1025
+ return;
1026
+ }
1027
+
1028
+ const lines = [];
1029
+ for (const session of list) {
1030
+ const active = chatState.activeSessionKey === session.key ? " *" : "";
1031
+ const unread = getUnreadCount(session);
1032
+ const unreadStr = unread > 0 ? ` [${unread} unread]` : "";
1033
+ lines.push(
1034
+ `${session.sessionNo}) ${getDisplaySessionName(session)} [${session.kind}]${active}${unreadStr}`,
1035
+ );
1036
+ }
1037
+
1038
+ const buttons = list.map((session) => {
1039
+ const name = getDisplaySessionName(session);
1040
+ const label =
1041
+ name.length > 15
1042
+ ? `${session.sessionNo}: ${name.slice(0, 13)}…`
1043
+ : `${session.sessionNo}: ${name}`;
1044
+ return { text: label, callback_data: getSessionCallbackData(session) };
1045
+ });
1046
+
1047
+ const rows = [];
1048
+ for (let i = 0; i < buttons.length; i += 2) {
1049
+ rows.push(buttons.slice(i, i + 2));
1050
+ }
1051
+
1052
+ await botSend(chatId, lines.join("\n"), {
1053
+ reply_markup: { inline_keyboard: rows },
1054
+ });
1055
+ }
1056
+
1057
+ async function replayUnreadOrLatest(session, chatId) {
1058
+ if (session.droppedUnreadTurns > 0) {
1059
+ await botSendSystem(chatId, "Some older unread replies were omitted.");
1060
+ }
1061
+
1062
+ if (session.unreadTurns.length > 0) {
1063
+ for (const turn of session.unreadTurns) {
1064
+ await botSendAssistant(chatId, turn.text);
1065
+ }
1066
+ markSessionSeen(session);
1067
+ return;
1068
+ }
1069
+
1070
+ if (session.lastTurnText) {
1071
+ markSessionSeen(session);
1072
+ await botSendAssistant(chatId, session.lastTurnText);
1073
+ return;
1074
+ }
1075
+
1076
+ markSessionSeen(session);
1077
+ await botSendSystem(chatId, "(No completed turns yet in this session.)");
1078
+ }
1079
+
1080
+ async function activateSession(chatId, session) {
1081
+ await refreshHeadlessSessionState(session);
1082
+
1083
+ chatState.activeSessionKey = session.key;
1084
+ chatState.lastActivityNotice = undefined;
1085
+ updateTypingIndicator();
1086
+
1087
+ await botSendSystem(
1088
+ chatId,
1089
+ `Switched to session ${session.sessionNo}: ${getDisplaySessionName(session)} [${session.kind}]`,
1090
+ );
1091
+ await replayUnreadOrLatest(session, chatId);
1092
+ }
1093
+
1094
+ async function switchSession(chatId, sessionNo) {
1095
+ const target = getSessionByNo(sessionNo);
1096
+ if (!target) {
1097
+ await botSend(chatId, `No such session: ${sessionNo}. Use /session to list.`);
1098
+ return;
1099
+ }
1100
+
1101
+ await activateSession(chatId, target);
1102
+ }
1103
+
1104
+ async function refreshHeadlessSessionState(session) {
1105
+ if (!session || session.kind !== "headless") return;
1106
+ const response = await session.rpc.call(
1107
+ { type: "get_state" },
1108
+ { timeoutMs: HEADLESS_START_TIMEOUT_MS },
1109
+ );
1110
+ if (!response.success) return;
1111
+
1112
+ const data = response.data ?? {};
1113
+ if (typeof data.sessionName === "string" && data.sessionName.trim()) {
1114
+ session.sessionName = data.sessionName.trim();
1115
+ } else if (session.sessionName) {
1116
+ session.sessionName = undefined;
1117
+ }
1118
+
1119
+ if (typeof data.isStreaming === "boolean") {
1120
+ session.busy = data.isStreaming;
1121
+ updateTypingIndicator();
1122
+ }
1123
+
1124
+ if (typeof data.pendingMessageCount === "number") {
1125
+ session.pendingMessageCount = data.pendingMessageCount;
1126
+ }
1127
+
1128
+ if (typeof data.isCompacting === "boolean") {
1129
+ setSessionCompacting(session, data.isCompacting);
1130
+ }
1131
+ }
1132
+
1133
+ async function refreshHeadlessSessionStates(targets) {
1134
+ await Promise.all(
1135
+ targets
1136
+ .filter((session) => session.kind === "headless")
1137
+ .map((session) => refreshHeadlessSessionState(session).catch(() => {})),
1138
+ );
1139
+ }
1140
+
1141
+ async function recordCompletedTurn(session, text) {
1142
+ if (!text.trim()) return;
1143
+
1144
+ session.lastTurnText = text;
1145
+ session.lastTurnSeq += 1;
1146
+
1147
+ if (!pairedChatId) return;
1148
+
1149
+ if (chatState.activeSessionKey === session.key) {
1150
+ markSessionSeen(session);
1151
+ updateTypingIndicator();
1152
+ await botSendAssistant(pairedChatId, text);
1153
+ return;
1154
+ }
1155
+
1156
+ session.unreadTurns.push({ seq: session.lastTurnSeq, text });
1157
+ while (session.unreadTurns.length > MAX_UNREAD_TURNS_PER_SESSION) {
1158
+ session.unreadTurns.shift();
1159
+ session.droppedUnreadTurns += 1;
1160
+ }
1161
+
1162
+ const now = Date.now();
1163
+ if (shouldSendActivityNotice(session.key, now)) {
1164
+ const notice = escapeHtml(`[session ${session.sessionNo}] new reply available`);
1165
+ await botSend(pairedChatId, `<i>${notice}</i>`, {
1166
+ parse_mode: "HTML",
1167
+ reply_markup: {
1168
+ inline_keyboard: [
1169
+ [
1170
+ {
1171
+ text: `Switch to session ${session.sessionNo}`,
1172
+ callback_data: getSessionCallbackData(session),
1173
+ },
1174
+ ],
1175
+ ],
1176
+ },
1177
+ });
1178
+ recordActivityNotice(session.key, now);
1179
+ }
1180
+ }
1181
+
1182
+ async function createHeadlessSession(cwd) {
1183
+ const rpc = createHeadlessRpcClient(cwd);
1184
+
1185
+ let stateResponse;
1186
+ try {
1187
+ const newSessionResponse = await rpc.call(
1188
+ { type: "new_session" },
1189
+ { timeoutMs: HEADLESS_START_TIMEOUT_MS },
1190
+ );
1191
+ if (!newSessionResponse.success) {
1192
+ throw new Error(getCommandError(newSessionResponse, "Failed to create headless session"));
1193
+ }
1194
+ if (newSessionResponse.data?.cancelled) {
1195
+ throw new Error("Headless session creation was cancelled.");
1196
+ }
1197
+
1198
+ stateResponse = await rpc.call({ type: "get_state" }, { timeoutMs: HEADLESS_START_TIMEOUT_MS });
1199
+ if (!stateResponse.success) {
1200
+ throw new Error(getCommandError(stateResponse, "Failed to inspect headless session state"));
1201
+ }
1202
+ } catch (error) {
1203
+ await rpc.stop().catch(() => {});
1204
+ throw error;
1205
+ }
1206
+
1207
+ const session = {
1208
+ key: `headless:${randomUUID()}`,
1209
+ sessionNo: nextSessionNo++,
1210
+ kind: "headless",
1211
+ cwd,
1212
+ sessionName:
1213
+ typeof stateResponse.data?.sessionName === "string" && stateResponse.data.sessionName.trim()
1214
+ ? stateResponse.data.sessionName.trim()
1215
+ : undefined,
1216
+ busy: Boolean(stateResponse.data?.isStreaming),
1217
+ compacting: Boolean(stateResponse.data?.isCompacting),
1218
+ pendingMessageCount: Number.isFinite(stateResponse.data?.pendingMessageCount)
1219
+ ? stateResponse.data.pendingMessageCount
1220
+ : 0,
1221
+ retryAfterCompactionUntil: 0,
1222
+ lastTurnText: undefined,
1223
+ lastTurnSeq: 0,
1224
+ unreadTurns: [],
1225
+ droppedUnreadTurns: 0,
1226
+ child: rpc.child,
1227
+ rpc,
1228
+ closing: false,
1229
+ queuedPromptCount: 0,
1230
+ pendingSendCount: 0,
1231
+ compactionWaiters: new Set(),
1232
+ sendQueue: Promise.resolve(),
1233
+ async sendText(text) {
1234
+ const backlogSize = session.pendingSendCount + session.queuedPromptCount;
1235
+ if (backlogSize >= MAX_QUEUED_HEADLESS_PROMPTS) {
1236
+ throw new Error("Session is busy. Too many queued prompts. Wait for it to catch up.");
1237
+ }
1238
+
1239
+ session.pendingSendCount += 1;
1240
+ const sendOperation = session.sendQueue
1241
+ .then(async () => {
1242
+ await waitForHeadlessSessionPromptWindow(session);
1243
+
1244
+ const waitForStart = !session.busy;
1245
+ session.queuedPromptCount += 1;
1246
+
1247
+ await rpc.prompt(text, {
1248
+ streamingBehavior: "followUp",
1249
+ waitForStart,
1250
+ timeoutMs: HEADLESS_START_TIMEOUT_MS,
1251
+ onFailed: () => {
1252
+ session.queuedPromptCount = Math.max(0, session.queuedPromptCount - 1);
1253
+ },
1254
+ });
1255
+ })
1256
+ .finally(() => {
1257
+ session.pendingSendCount = Math.max(0, session.pendingSendCount - 1);
1258
+ });
1259
+
1260
+ session.sendQueue = sendOperation.catch(() => {});
1261
+ await sendOperation;
1262
+ },
1263
+ async abort() {
1264
+ const response = await rpc.call({ type: "abort" }, { timeoutMs: HEADLESS_ABORT_TIMEOUT_MS });
1265
+ if (!response.success) {
1266
+ throw new Error(getCommandError(response, "Failed to abort headless session"));
1267
+ }
1268
+ },
1269
+ async quit() {
1270
+ if (session.closing) return;
1271
+ session.closing = true;
1272
+ try {
1273
+ await session.abort();
1274
+ } catch {
1275
+ // ignore
1276
+ }
1277
+ await rpc.stop();
1278
+ },
1279
+ };
1280
+
1281
+ sessions.set(session.key, session);
1282
+
1283
+ rpc.onEvent((event) => {
1284
+ if (!sessions.has(session.key)) return;
1285
+
1286
+ if (event.type === "compaction_start") {
1287
+ session.retryAfterCompactionUntil = 0;
1288
+ setSessionCompacting(session, true);
1289
+ return;
1290
+ }
1291
+
1292
+ if (event.type === "compaction_end") {
1293
+ session.retryAfterCompactionUntil = event.willRetry
1294
+ ? Date.now() + POST_COMPACTION_RETRY_GRACE_MS
1295
+ : 0;
1296
+ setSessionCompacting(session, false);
1297
+ return;
1298
+ }
1299
+
1300
+ if (event.type === "agent_start") {
1301
+ session.retryAfterCompactionUntil = 0;
1302
+ session.queuedPromptCount = Math.max(0, session.queuedPromptCount - 1);
1303
+ session.busy = true;
1304
+ updateTypingIndicator();
1305
+ return;
1306
+ }
1307
+
1308
+ if (event.type === "agent_end") {
1309
+ session.busy = false;
1310
+ updateTypingIndicator();
1311
+ return;
1312
+ }
1313
+
1314
+ if (event.type === "turn_end") {
1315
+ const text = extractTextFromMessage(event.message);
1316
+ if (!text) return;
1317
+ void recordCompletedTurn(session, text).catch(() => {});
1318
+ }
1319
+ });
1320
+
1321
+ rpc.onClose(() => {
1322
+ rejectSessionCompactionWaiters(session, new Error("Session ended while compacting."));
1323
+ const removed = removeSession(session.key);
1324
+ if (!removed || shuttingDown || session.closing) return;
1325
+ if (!pairedChatId) return;
1326
+ botSendSystem(
1327
+ pairedChatId,
1328
+ `Session ${session.sessionNo} ended: ${getDisplaySessionName(session)}`,
1329
+ ).catch(() => {});
1330
+ });
1331
+
1332
+ updateTypingIndicator();
1333
+ return session;
1334
+ }
1335
+
1336
+ async function createAndActivateHeadlessSession(chatId, cwd) {
1337
+ const session = await createHeadlessSession(cwd);
1338
+ chatState.activeSessionKey = session.key;
1339
+ chatState.lastActivityNotice = undefined;
1340
+ markSessionSeen(session);
1341
+ updateTypingIndicator();
1342
+ await botSendSystem(
1343
+ chatId,
1344
+ `Switched to session ${session.sessionNo}: ${getDisplaySessionName(session)} [headless]`,
1345
+ );
1346
+ }
1347
+
1348
+ async function promptToCreateHeadlessSessionDirectory(chatId, cwd) {
1349
+ if (!bot) return;
1350
+
1351
+ const prompt = [
1352
+ `Directory does not exist: ${cwd}`,
1353
+ "",
1354
+ "Reply to this message with Yes to create it.",
1355
+ "Any other reply cancels.",
1356
+ ].join("\n");
1357
+
1358
+ const message = await bot.sendMessage(chatId, prompt, {
1359
+ reply_markup: {
1360
+ force_reply: true,
1361
+ },
1362
+ });
1363
+
1364
+ pendingDirectoryCreation = {
1365
+ chatId,
1366
+ cwd,
1367
+ promptMessageId: message.message_id,
1368
+ };
1369
+ }
1370
+
1371
+ function getPendingDirectoryCreationReply(msg) {
1372
+ if (!pendingDirectoryCreation) return null;
1373
+ if (msg.chat?.id !== pendingDirectoryCreation.chatId) return null;
1374
+ if (msg.reply_to_message?.message_id !== pendingDirectoryCreation.promptMessageId) return null;
1375
+ return pendingDirectoryCreation;
1376
+ }
1377
+
1378
+ async function handlePendingDirectoryCreationReply(msg) {
1379
+ const pending = getPendingDirectoryCreationReply(msg);
1380
+ if (!pending) return false;
1381
+
1382
+ pendingDirectoryCreation = null;
1383
+
1384
+ if (!/^yes$/i.test((msg.text ?? "").trim())) {
1385
+ await botSendSystem(pending.chatId, `Cancelled directory creation: ${pending.cwd}`);
1386
+ return true;
1387
+ }
1388
+
1389
+ try {
1390
+ const cwd = await ensureHeadlessSessionPathExists(pending.cwd);
1391
+ await createAndActivateHeadlessSession(pending.chatId, cwd);
1392
+ } catch (error) {
1393
+ await botSend(pending.chatId, errorMessage(error));
1394
+ }
1395
+
1396
+ return true;
1397
+ }
1398
+
1399
+ function broadcastToWindowSessions(msg) {
1400
+ for (const session of sessions.values()) {
1401
+ if (session.kind !== "window") continue;
1402
+ makeJsonlWriter(session.socket)(msg);
1403
+ }
1404
+ }
1405
+
1406
+ async function handleSessionQuit(chatId, sessionNo) {
1407
+ const target = sessionNo === undefined ? getActiveSession() : getSessionByNo(sessionNo);
1408
+ if (!target) {
1409
+ await botSend(
1410
+ chatId,
1411
+ sessionNo === undefined
1412
+ ? "No active session. Use /session."
1413
+ : `No such session: ${sessionNo}. Use /session to list.`,
1414
+ );
1415
+ return;
1416
+ }
1417
+
1418
+ if (target.kind === "window") {
1419
+ await botSend(chatId, "Cannot quit session attached to a window remotely.");
1420
+ return;
1421
+ }
1422
+
1423
+ const wasActive = chatState.activeSessionKey === target.key;
1424
+ await target.quit();
1425
+ removeSession(target.key);
1426
+
1427
+ await botSendSystem(chatId, `Quit session ${target.sessionNo}: ${getDisplaySessionName(target)}`);
1428
+ if (wasActive && sessions.size > 0) {
1429
+ await botSendSystem(chatId, "Use /session to choose another session.");
1430
+ }
1431
+ }
1432
+
1433
+ function sessionHelpText() {
1434
+ return [
1435
+ "/session - list sessions",
1436
+ "/session new [path] - create a headless session in /path, ~/path, or the system temp directory if omitted; reply Yes to create a missing directory",
1437
+ "/session N - switch active session",
1438
+ "/session quit [N] - quit a headless session",
1439
+ "/esc - abort current run in active session",
1440
+ "/unpair - unpair Telegram and terminate headless sessions",
1441
+ ].join("\n");
1442
+ }
1443
+
1444
+ async function handleTelegramMessage(msg) {
1445
+ const chatId = msg.chat?.id;
1446
+ if (!chatId) return;
1447
+
1448
+ const text = msg.text ?? "";
1449
+
1450
+ const pinMatch = text.match(/^\/pin\s+(\d{6})\s*$/);
1451
+ if (pinMatch) {
1452
+ const code = pinMatch[1];
1453
+ if (pairedChatId && pairedChatId !== chatId) {
1454
+ await botSend(chatId, "This bot is already paired with another chat.");
1455
+ return;
1456
+ }
1457
+
1458
+ const pending = pendingPins.get(code);
1459
+ if (!pending) {
1460
+ await botSend(
1461
+ chatId,
1462
+ "Invalid or expired PIN. Run /telegram pair in pi to generate a new one.",
1463
+ );
1464
+ return;
1465
+ }
1466
+ if (Date.now() > pending.expiresAt) {
1467
+ pendingPins.delete(code);
1468
+ await botSend(chatId, "PIN expired. Run /telegram pair in pi to generate a new one.");
1469
+ return;
1470
+ }
1471
+
1472
+ await setPairedChatId(chatId);
1473
+ pendingPins.delete(code);
1474
+
1475
+ for (const session of sessions.values()) {
1476
+ markSessionSeen(session);
1477
+ }
1478
+
1479
+ if (pending.sessionKey && sessions.has(pending.sessionKey)) {
1480
+ chatState.activeSessionKey = pending.sessionKey;
1481
+ }
1482
+
1483
+ updateTypingIndicator();
1484
+ broadcastToWindowSessions({ type: "paired", chatId });
1485
+
1486
+ await botSend(chatId, "Paired successfully. Use /session to list sessions.");
1487
+ return;
1488
+ }
1489
+
1490
+ if (!isAuthorizedChat(chatId)) {
1491
+ await botSend(
1492
+ chatId,
1493
+ "Not paired. Run /telegram pair in pi to generate a PIN, then send /pin <PIN> here.",
1494
+ );
1495
+ return;
1496
+ }
1497
+
1498
+ if (await handlePendingDirectoryCreationReply(msg)) {
1499
+ return;
1500
+ }
1501
+
1502
+ if (!text) return;
1503
+
1504
+ if (text === "/help") {
1505
+ await botSend(
1506
+ chatId,
1507
+ [
1508
+ "The following commands are available:",
1509
+ "",
1510
+ sessionHelpText(),
1511
+ "",
1512
+ "(plain text) - send to active session",
1513
+ ].join("\n"),
1514
+ );
1515
+ return;
1516
+ }
1517
+
1518
+ if (text === "/session") {
1519
+ await sendSessionList(chatId);
1520
+ return;
1521
+ }
1522
+
1523
+ if (text === "/sessions") {
1524
+ await botSend(chatId, "Unknown command. Use /session.");
1525
+ return;
1526
+ }
1527
+
1528
+ const newMatch = text.match(/^\/session\s+new(?:\s+(.+))?\s*$/);
1529
+ if (newMatch) {
1530
+ if (pendingDirectoryCreation?.chatId === chatId) {
1531
+ await botSend(chatId, "Reply Yes or No to the pending folder-creation prompt first.");
1532
+ return;
1533
+ }
1534
+
1535
+ try {
1536
+ const pathInfo = await inspectHeadlessSessionPath(newMatch[1] ?? "");
1537
+ if (!pathInfo.exists) {
1538
+ await promptToCreateHeadlessSessionDirectory(chatId, pathInfo.cwd);
1539
+ return;
1540
+ }
1541
+
1542
+ await createAndActivateHeadlessSession(chatId, pathInfo.cwd);
1543
+ } catch (error) {
1544
+ await botSend(chatId, errorMessage(error));
1545
+ }
1546
+ return;
1547
+ }
1548
+
1549
+ const quitMatch = text.match(/^\/session\s+quit(?:\s+(\d+))?\s*$/);
1550
+ if (quitMatch) {
1551
+ const sessionNo = quitMatch[1] ? Number(quitMatch[1]) : undefined;
1552
+ await handleSessionQuit(chatId, sessionNo);
1553
+ return;
1554
+ }
1555
+
1556
+ const switchMatch = text.match(/^\/session\s+(\d+)\s*$/);
1557
+ if (switchMatch) {
1558
+ await switchSession(chatId, Number(switchMatch[1]));
1559
+ return;
1560
+ }
1561
+
1562
+ if (/^\/session(?:\s|$)/.test(text)) {
1563
+ await botSend(chatId, sessionHelpText());
1564
+ return;
1565
+ }
1566
+
1567
+ if (text === "/unpair") {
1568
+ try {
1569
+ await botSendSystem(
1570
+ chatId,
1571
+ "Unpaired Telegram. All sessions disconnected. Run /telegram pair in pi to pair again.",
1572
+ );
1573
+ } catch {
1574
+ // ignore
1575
+ }
1576
+ await shutdownDaemon({ clearPairingState: true });
1577
+ return;
1578
+ }
1579
+
1580
+ if (text === "/esc") {
1581
+ const session = getActiveSession();
1582
+ if (!session) {
1583
+ await botSend(chatId, "No active session. Use /session.");
1584
+ return;
1585
+ }
1586
+
1587
+ try {
1588
+ await session.abort();
1589
+ } catch (error) {
1590
+ await botSend(chatId, `Failed to abort session ${session.sessionNo}: ${errorMessage(error)}`);
1591
+ }
1592
+ return;
1593
+ }
1594
+
1595
+ const session = getActiveSession();
1596
+ if (!session) {
1597
+ await botSend(chatId, "No active session. Use /session.");
1598
+ return;
1599
+ }
1600
+
1601
+ try {
1602
+ await session.sendText(text);
1603
+ } catch (error) {
1604
+ await botSend(chatId, `Failed to send to session ${session.sessionNo}: ${errorMessage(error)}`);
1605
+ }
1606
+ }
1607
+
1608
+ async function maybeShutdownSoon() {
1609
+ if (pairedChatId !== undefined) return;
1610
+ if (sessions.size > 0) return;
1611
+ if (shutdownTimer || shuttingDown) return;
1612
+
1613
+ shutdownTimer = setTimeout(() => {
1614
+ shutdownTimer = null;
1615
+ if (pairedChatId !== undefined || sessions.size > 0 || shuttingDown) return;
1616
+ console.error("[telegram] No sessions connected and daemon is unpaired, shutting down.");
1617
+ shutdownDaemon().catch(() => {});
1618
+ }, UNPAIRED_IDLE_SHUTDOWN_MS);
1619
+ }
1620
+
1621
+ function cancelShutdown() {
1622
+ if (shutdownTimer) {
1623
+ clearTimeout(shutdownTimer);
1624
+ shutdownTimer = null;
1625
+ }
1626
+ }
1627
+
1628
+ async function startServer() {
1629
+ await fsp.mkdir(RUN_DIR, { recursive: true, mode: 0o700 });
1630
+
1631
+ if (fs.existsSync(SOCKET_PATH)) {
1632
+ const ok = await new Promise((resolve) => {
1633
+ const socket = net.connect(SOCKET_PATH);
1634
+ socket.on("connect", () => {
1635
+ socket.end();
1636
+ resolve(true);
1637
+ });
1638
+ socket.on("error", () => resolve(false));
1639
+ });
1640
+ if (ok) {
1641
+ console.error("[telegram] Daemon already running.");
1642
+ process.exit(0);
1643
+ }
1644
+ try {
1645
+ fs.unlinkSync(SOCKET_PATH);
1646
+ } catch {
1647
+ // ignore
1648
+ }
1649
+ }
1650
+
1651
+ const srv = net.createServer((socket) => {
1652
+ cancelShutdown();
1653
+
1654
+ const send = makeJsonlWriter(socket);
1655
+ let sessionKey;
1656
+
1657
+ attachJsonlReader(socket, (line) => {
1658
+ const msg = safeJsonParse(line);
1659
+ if (!msg || typeof msg.type !== "string") return;
1660
+
1661
+ switch (msg.type) {
1662
+ case "register": {
1663
+ const windowId = msg.windowId || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
1664
+ sessionKey = `window:${windowId}`;
1665
+ const existing = sessions.get(sessionKey);
1666
+ const sessionNo = existing?.sessionNo ?? nextSessionNo++;
1667
+
1668
+ if (existing?.kind === "window" && existing.socket !== socket) {
1669
+ void existing.quit();
1670
+ }
1671
+
1672
+ const session = {
1673
+ key: sessionKey,
1674
+ sessionNo,
1675
+ kind: "window",
1676
+ windowId,
1677
+ socket,
1678
+ cwd: msg.cwd,
1679
+ sessionName: msg.sessionName,
1680
+ busy: !!msg.busy,
1681
+ compacting: !!existing?.compacting,
1682
+ piSessionId: existing?.piSessionId,
1683
+ piSessionFile: existing?.piSessionFile,
1684
+ lastTurnText: existing?.lastTurnText,
1685
+ lastTurnSeq: existing?.lastTurnSeq ?? 0,
1686
+ unreadTurns: existing?.unreadTurns ?? [],
1687
+ droppedUnreadTurns: existing?.droppedUnreadTurns ?? 0,
1688
+ compactionWaiters: existing?.compactionWaiters ?? new Set(),
1689
+ sendQueue: existing?.sendQueue ?? Promise.resolve(),
1690
+ async sendText(text) {
1691
+ if (typeof session.piSessionId !== "string" || !session.piSessionId) {
1692
+ throw new Error("Window Pi session identity is not available yet.");
1693
+ }
1694
+
1695
+ const injectId = randomUUID();
1696
+ const targetSessionId = session.piSessionId;
1697
+ const targetSessionFile = session.piSessionFile;
1698
+ const sendOperation = session.sendQueue.then(async () => {
1699
+ await waitForSessionCompactionToFinish(session);
1700
+ if (socket.destroyed) throw new Error("Session is no longer connected.");
1701
+ send({
1702
+ type: "inject",
1703
+ id: injectId,
1704
+ text,
1705
+ sessionId: targetSessionId,
1706
+ sessionFile: targetSessionFile,
1707
+ });
1708
+ });
1709
+
1710
+ session.sendQueue = sendOperation.catch(() => {});
1711
+ await sendOperation;
1712
+ },
1713
+ async abort() {
1714
+ if (socket.destroyed) throw new Error("Session is no longer connected.");
1715
+ send({ type: "abort" });
1716
+ },
1717
+ async quit() {
1718
+ try {
1719
+ socket.end();
1720
+ } catch {
1721
+ // ignore
1722
+ }
1723
+ try {
1724
+ socket.destroy();
1725
+ } catch {
1726
+ // ignore
1727
+ }
1728
+ },
1729
+ };
1730
+
1731
+ sessions.set(sessionKey, session);
1732
+ updateWindowSessionRef(session, msg);
1733
+ setSessionCompacting(session, !!msg.compacting);
1734
+ send({ type: "registered", sessionNo });
1735
+ updateTypingIndicator();
1736
+ break;
1737
+ }
1738
+
1739
+ case "meta": {
1740
+ if (!sessionKey) break;
1741
+ const session = sessions.get(sessionKey);
1742
+ if (!session) break;
1743
+ session.cwd = msg.cwd ?? session.cwd;
1744
+ session.sessionName = msg.sessionName ?? session.sessionName;
1745
+ session.busy = !!msg.busy;
1746
+ updateWindowSessionRef(session, msg);
1747
+ if (typeof msg.compacting === "boolean") {
1748
+ setSessionCompacting(session, msg.compacting);
1749
+ }
1750
+ if (chatState.activeSessionKey === sessionKey) updateTypingIndicator();
1751
+ break;
1752
+ }
1753
+
1754
+ case "inject_result": {
1755
+ if (!sessionKey) break;
1756
+ const session = sessions.get(sessionKey);
1757
+ if (!session || session.kind !== "window") break;
1758
+ if (msg.status !== "rejected" || !pairedChatId) break;
1759
+
1760
+ const reason =
1761
+ typeof msg.reason === "string" && msg.reason.trim()
1762
+ ? msg.reason.trim()
1763
+ : "Telegram message was not delivered.";
1764
+ botSendSystem(pairedChatId, `[session ${session.sessionNo}] ${reason}`).catch(() => {});
1765
+ break;
1766
+ }
1767
+
1768
+ case "request_pin": {
1769
+ if (!sessionKey) {
1770
+ send({ type: "error", error: "not_registered" });
1771
+ break;
1772
+ }
1773
+
1774
+ let code;
1775
+ for (let i = 0; i < 10; i++) {
1776
+ code = String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0");
1777
+ if (!pendingPins.has(code)) break;
1778
+ }
1779
+
1780
+ const expiresAt = Date.now() + 60_000;
1781
+ pendingPins.set(code, { sessionKey, expiresAt });
1782
+
1783
+ const cleanupTimer = setTimeout(() => {
1784
+ const pending = pendingPins.get(code);
1785
+ if (pending && pending.expiresAt <= Date.now()) {
1786
+ pendingPins.delete(code);
1787
+ }
1788
+ }, 60_000);
1789
+ cleanupTimer.unref?.();
1790
+
1791
+ send({ type: "pin", code, expiresAt });
1792
+ break;
1793
+ }
1794
+
1795
+ case "shutdown": {
1796
+ shutdownDaemon({ clearPairingState: true }).catch(() => {});
1797
+ break;
1798
+ }
1799
+
1800
+ case "turn_end": {
1801
+ if (!sessionKey) break;
1802
+ const session = sessions.get(sessionKey);
1803
+ if (!session) break;
1804
+ const text = typeof msg.text === "string" ? msg.text : "";
1805
+ if (!text.trim()) break;
1806
+ void recordCompletedTurn(session, text).catch(() => {});
1807
+ break;
1808
+ }
1809
+
1810
+ default:
1811
+ break;
1812
+ }
1813
+ });
1814
+
1815
+ socket.on("close", () => {
1816
+ if (!sessionKey) {
1817
+ void maybeShutdownSoon();
1818
+ return;
1819
+ }
1820
+
1821
+ const current = sessions.get(sessionKey);
1822
+ if (current && current.kind === "window" && current.socket === socket) {
1823
+ rejectSessionCompactionWaiters(current, new Error("Session ended while compacting."));
1824
+ removeSession(sessionKey);
1825
+ }
1826
+
1827
+ void maybeShutdownSoon();
1828
+ });
1829
+
1830
+ socket.on("error", () => {
1831
+ // handled by close
1832
+ });
1833
+ });
1834
+
1835
+ await new Promise((resolve, reject) => {
1836
+ const onErr = (error) => {
1837
+ srv.off("listening", onListen);
1838
+ reject(error);
1839
+ };
1840
+ const onListen = () => {
1841
+ srv.off("error", onErr);
1842
+ resolve();
1843
+ };
1844
+ srv.once("error", onErr);
1845
+ srv.once("listening", onListen);
1846
+ srv.listen(SOCKET_PATH);
1847
+ });
1848
+
1849
+ try {
1850
+ fs.chmodSync(SOCKET_PATH, 0o600);
1851
+ } catch {
1852
+ // ignore
1853
+ }
1854
+
1855
+ return srv;
1856
+ }
1857
+
1858
+ server = await startServer();
1859
+
1860
+ bot = new TelegramBot(botTokenInfo.token, {
1861
+ polling: {
1862
+ params: {
1863
+ timeout: TELEGRAM_POLL_TIMEOUT_SECONDS,
1864
+ },
1865
+ },
1866
+ request: {
1867
+ timeout: TELEGRAM_HTTP_TIMEOUT_MS,
1868
+ },
1869
+ });
1870
+
1871
+ void syncBotCommands();
1872
+ // node-telegram-bot-api already retries failed polls. Avoid stop/start
1873
+ // recovery here: cancelling an in-flight poll can spin up overlapping
1874
+ // pollers and replay the same Telegram update multiple times.
1875
+ bot.on("polling_error", (error) => {
1876
+ const message = errorMessage(error);
1877
+ const kind = isLikelyNetworkPollingError(error) ? "polling_error (network)" : "polling_error";
1878
+ console.error(`[telegram] ${kind}: ${message}`);
1879
+ });
1880
+
1881
+ bot.on("message", (msg) => {
1882
+ const updateKey = getUpdateDedupeKeyForMessage(msg);
1883
+ if (wasUpdateProcessed(updateKey)) {
1884
+ return;
1885
+ }
1886
+
1887
+ handleTelegramMessage(msg).catch((error) =>
1888
+ console.error("[telegram] telegram handler error", error),
1889
+ );
1890
+ });
1891
+
1892
+ bot.on("callback_query", (query) => {
1893
+ const updateKey = getUpdateDedupeKeyForCallbackQuery(query);
1894
+ if (wasUpdateProcessed(updateKey)) {
1895
+ return;
1896
+ }
1897
+
1898
+ (async () => {
1899
+ const chatId = query.message?.chat?.id;
1900
+ if (!chatId || !isAuthorizedChat(chatId)) {
1901
+ try {
1902
+ await bot.answerCallbackQuery(query.id, { text: "Not authorized" });
1903
+ } catch {}
1904
+ return;
1905
+ }
1906
+
1907
+ const sessionKey = getSessionKeyFromCallbackData(query.data);
1908
+ if (!sessionKey) {
1909
+ try {
1910
+ await bot.answerCallbackQuery(query.id);
1911
+ } catch {}
1912
+ return;
1913
+ }
1914
+
1915
+ const session = sessions.get(sessionKey);
1916
+ if (!session) {
1917
+ try {
1918
+ await bot.answerCallbackQuery(query.id, {
1919
+ text: "That session is no longer available. Use /session.",
1920
+ });
1921
+ } catch {}
1922
+ return;
1923
+ }
1924
+
1925
+ try {
1926
+ await bot.answerCallbackQuery(query.id);
1927
+ } catch {}
1928
+ await activateSession(chatId, session);
1929
+ })().catch((error) => console.error("[telegram] callback query error", error));
1930
+ });
1931
+
1932
+ updateTypingIndicator();
1933
+
1934
+ process.on("SIGINT", () => {
1935
+ shutdownDaemon().catch(() => {});
1936
+ });
1937
+
1938
+ process.on("SIGTERM", () => {
1939
+ shutdownDaemon().catch(() => {});
1940
+ });
1941
+
1942
+ console.error(`[telegram] Daemon running. Socket: ${SOCKET_PATH}`);