claude-tg 1.0.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.
package/src/daemon.js ADDED
@@ -0,0 +1,1274 @@
1
+ const http = require('http');
2
+ const crypto = require('crypto');
3
+ const { execSync } = require('child_process');
4
+ const { Telegraf, Markup } = require('telegraf');
5
+ const { loadConfig, LOG_PATH } = require('./config');
6
+ const fs = require('fs');
7
+
8
+ // --- State ---
9
+
10
+ const pendingQuestions = new Map();
11
+ // Key: requestId (UUID)
12
+ // Value: { resolve, sessionId, toolName, createdAt, telegramMessageId, permissionSuggestions }
13
+
14
+ // session_id → { ttyPath, cwd, label (project name), lastActive }
15
+ const sessions = new Map();
16
+
17
+ // session_id → short numeric label (#1, #2, ...) for terminal identification
18
+ const sessionLabels = new Map();
19
+ let sessionCounter = 0;
20
+
21
+ // telegramMessageId → { sessionId, type: 'permission' | 'notification' }
22
+ const messageToSession = new Map();
23
+
24
+ // Elicitation state machine
25
+ // elicitationId → { sessionId, ttyPath, questions, answers, telegramMessageIds, ... }
26
+ const pendingElicitations = new Map();
27
+
28
+ let bot;
29
+ let config;
30
+
31
+ // --- Utilities ---
32
+
33
+ function log(msg) {
34
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
35
+ fs.appendFileSync(LOG_PATH, line);
36
+ }
37
+
38
+ function projectLabel(cwd) {
39
+ if (!cwd) return 'unknown';
40
+ return cwd.split('/').filter(Boolean).pop() || 'unknown';
41
+ }
42
+
43
+ function getSessionLabel(sessionId) {
44
+ if (!sessionId) return '?';
45
+ if (!sessionLabels.has(sessionId)) {
46
+ sessionLabels.set(sessionId, ++sessionCounter);
47
+ }
48
+ return sessionLabels.get(sessionId);
49
+ }
50
+
51
+ function shortId(uuid) {
52
+ return uuid.slice(0, 8);
53
+ }
54
+
55
+ function truncate(str, max = 300) {
56
+ if (!str) return '';
57
+ if (str.length <= max) return str;
58
+ return str.slice(0, max) + '…';
59
+ }
60
+
61
+ /**
62
+ * Register/update a session's TTY and metadata.
63
+ */
64
+ function trackSession(data) {
65
+ const existing = sessions.get(data.session_id) || {};
66
+ sessions.set(data.session_id, {
67
+ ...existing,
68
+ ttyPath: data.tty_path || existing.ttyPath || null,
69
+ cwd: data.cwd || existing.cwd,
70
+ label: projectLabel(data.cwd || existing.cwd),
71
+ lastActive: Date.now(),
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Check if a TTY has any active processes (terminal is still open + something running).
77
+ * More reliable than fs.statSync which succeeds even after terminal closes.
78
+ */
79
+ function isTtyAlive(ttyPath) {
80
+ if (!ttyPath) return false;
81
+ try {
82
+ const ttyName = ttyPath.replace('/dev/', '');
83
+ const result = execSync(`ps -t ${ttyName} -o pid= 2>/dev/null`, { timeout: 2000, stdio: 'pipe' }).toString().trim();
84
+ return result.length > 0;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Escape a string for use inside AppleScript double-quoted strings.
92
+ */
93
+ function escapeAppleScript(str) {
94
+ return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
95
+ }
96
+
97
+ /**
98
+ * Send text as input to a terminal session identified by its TTY path.
99
+ * Uses osascript to type into the correct terminal tab/session.
100
+ * Tries iTerm2 first, then Terminal.app.
101
+ */
102
+ function sendInputToTerminal(ttyPath, text) {
103
+ if (!ttyPath) {
104
+ log('sendInput: no TTY path');
105
+ return false;
106
+ }
107
+
108
+ const escaped = escapeAppleScript(text.trim());
109
+
110
+ // Try iTerm2 — write text targets a specific session by TTY, no focus needed
111
+ try {
112
+ const script = `
113
+ tell application "iTerm2"
114
+ repeat with w in windows
115
+ repeat with t in tabs of w
116
+ repeat with s in sessions of t
117
+ if tty of s is "${ttyPath}" then
118
+ tell s to write text "${escaped}"
119
+ return "ok"
120
+ end if
121
+ end repeat
122
+ end repeat
123
+ end repeat
124
+ end tell`;
125
+ execSync(`osascript -e '${script.replace(/'/g, "'\\''")}'`, { timeout: 5000, stdio: 'pipe' });
126
+ log(`Sent via iTerm2 to ${ttyPath}: ${truncate(text, 80)}`);
127
+ return true;
128
+ } catch {}
129
+
130
+ // Try Terminal.app — focus the tab, type text, press Enter
131
+ try {
132
+ const script = [
133
+ 'tell application "Terminal"',
134
+ ' repeat with w in windows',
135
+ ' repeat with t in tabs of w',
136
+ ` if tty of t is "${ttyPath}" then`,
137
+ ' set selected tab of w to t',
138
+ ' set frontmost of w to true',
139
+ ' delay 0.3',
140
+ ' tell application "System Events"',
141
+ ' tell process "Terminal"',
142
+ ` keystroke "${escaped}"`,
143
+ ' delay 0.2',
144
+ ' keystroke return',
145
+ ' end tell',
146
+ ' end tell',
147
+ ' return "ok"',
148
+ ' end if',
149
+ ' end repeat',
150
+ ' end repeat',
151
+ 'end tell',
152
+ ].join('\n');
153
+
154
+ fs.writeFileSync('/tmp/claude-tg-input.scpt', script);
155
+ execSync('osascript /tmp/claude-tg-input.scpt', { timeout: 10000, stdio: 'pipe' });
156
+ log(`Sent via Terminal.app to ${ttyPath}: ${truncate(text, 80)}`);
157
+ return true;
158
+ } catch (err) {
159
+ log(`Terminal.app send error: ${err.message}`);
160
+ }
161
+
162
+ log(`sendInput failed: no terminal found for ${ttyPath}`);
163
+ return false;
164
+ }
165
+
166
+ // --- Transcript reading ---
167
+
168
+ function tailLines(filePath, n) {
169
+ try {
170
+ const stat = fs.statSync(filePath);
171
+ const bufSize = Math.min(stat.size, n * 2000);
172
+ const buf = Buffer.alloc(bufSize);
173
+ const fd = fs.openSync(filePath, 'r');
174
+ fs.readSync(fd, buf, 0, bufSize, stat.size - bufSize);
175
+ fs.closeSync(fd);
176
+ const lines = buf.toString('utf8').split('\n').filter(Boolean);
177
+ return lines.slice(-n);
178
+ } catch {
179
+ return [];
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Extract conversation context from transcript JSONL.
185
+ * Returns: { userTask, recentContext }
186
+ */
187
+ function extractContext(transcriptPath) {
188
+ if (!transcriptPath) return { userTask: '', recentContext: '' };
189
+
190
+ try {
191
+ // First user message = the task
192
+ let userTask = '';
193
+ try {
194
+ const headBuf = Buffer.alloc(Math.min(fs.statSync(transcriptPath).size, 50000));
195
+ const fd = fs.openSync(transcriptPath, 'r');
196
+ fs.readSync(fd, headBuf, 0, headBuf.length, 0);
197
+ fs.closeSync(fd);
198
+ const headLines = headBuf.toString('utf8').split('\n').filter(Boolean);
199
+ for (const line of headLines) {
200
+ try {
201
+ const entry = JSON.parse(line);
202
+ if (entry.type === 'user' && entry.message?.role === 'user') {
203
+ const content = entry.message.content;
204
+ if (typeof content === 'string') {
205
+ userTask = content;
206
+ } else if (Array.isArray(content)) {
207
+ const textBlock = content.find((b) => b.type === 'text');
208
+ if (textBlock) userTask = textBlock.text;
209
+ }
210
+ break;
211
+ }
212
+ } catch {}
213
+ }
214
+ } catch {}
215
+
216
+ // Last assistant text = what Claude was doing/said
217
+ let recentContext = '';
218
+ const tailData = tailLines(transcriptPath, 20);
219
+ for (let i = tailData.length - 1; i >= 0; i--) {
220
+ try {
221
+ const entry = JSON.parse(tailData[i]);
222
+ if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
223
+ const content = entry.message.content;
224
+ if (Array.isArray(content)) {
225
+ const texts = content
226
+ .filter((b) => b.type === 'text' && b.text?.trim())
227
+ .map((b) => b.text.trim());
228
+ if (texts.length > 0) {
229
+ recentContext = texts.join(' ');
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ } catch {}
235
+ }
236
+
237
+ return {
238
+ userTask: truncate(userTask.trim(), 200),
239
+ recentContext: truncate(recentContext.trim(), 300),
240
+ };
241
+ } catch (err) {
242
+ log(`Context extraction error: ${err.message}`);
243
+ return { userTask: '', recentContext: '' };
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Extract AskUserQuestion data from the transcript tail.
249
+ * Returns the questions array or null if not found.
250
+ */
251
+ function extractElicitation(transcriptPath) {
252
+ if (!transcriptPath) return null;
253
+ try {
254
+ const lines = tailLines(transcriptPath, 30);
255
+ for (let i = lines.length - 1; i >= 0; i--) {
256
+ try {
257
+ const entry = JSON.parse(lines[i]);
258
+ if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
259
+ const content = entry.message.content;
260
+ if (Array.isArray(content)) {
261
+ for (let j = content.length - 1; j >= 0; j--) {
262
+ const block = content[j];
263
+ if (block.type === 'tool_use' && block.name === 'AskUserQuestion') {
264
+ const questions = block.input?.questions;
265
+ if (questions && Array.isArray(questions) && questions.length > 0) {
266
+ return questions;
267
+ }
268
+ }
269
+ }
270
+ }
271
+ }
272
+ } catch {}
273
+ }
274
+ } catch {}
275
+ return null;
276
+ }
277
+
278
+ // --- Message formatting ---
279
+
280
+ function formatToolDetails(toolName, toolInput) {
281
+ if (!toolInput) return '';
282
+ if (typeof toolInput === 'string') return toolInput;
283
+
284
+ switch (toolName) {
285
+ case 'Bash':
286
+ return toolInput.command || '';
287
+ case 'Write':
288
+ return `${toolInput.file_path || ''}\n(new file, ${(toolInput.content || '').length} chars)`;
289
+ case 'Edit': {
290
+ let edit = toolInput.file_path || '';
291
+ if (toolInput.old_string !== undefined) {
292
+ edit += `\n- ${truncate(toolInput.old_string, 80)}\n+ ${truncate(toolInput.new_string, 80)}`;
293
+ }
294
+ return edit;
295
+ }
296
+ case 'Read':
297
+ return toolInput.file_path || '';
298
+ case 'Glob':
299
+ return `${toolInput.pattern}${toolInput.path ? ' in ' + toolInput.path : ''}`;
300
+ case 'Grep':
301
+ return `/${toolInput.pattern}/${toolInput.glob ? ' ' + toolInput.glob : ''}`;
302
+ case 'WebFetch':
303
+ return toolInput.url || '';
304
+ case 'WebSearch':
305
+ return toolInput.query || '';
306
+ case 'Task':
307
+ return `[${toolInput.subagent_type || 'agent'}] ${truncate(toolInput.description || toolInput.prompt || '', 150)}`;
308
+ default:
309
+ return truncate(JSON.stringify(toolInput, null, 2), 400);
310
+ }
311
+ }
312
+
313
+ function formatPermissionMessage(data, context) {
314
+ const label = projectLabel(data.cwd);
315
+ const sessionNum = getSessionLabel(data.session_id);
316
+ const tool = data.tool_name || 'Unknown';
317
+ const details = formatToolDetails(tool, data.tool_input);
318
+
319
+ let msg = `📋 #${sessionNum} ${label}\n`;
320
+ msg += `━━━━━━━━━━━━━━━━━━━━\n`;
321
+
322
+ if (context.userTask) {
323
+ msg += `📝 Task: ${context.userTask}\n\n`;
324
+ }
325
+ if (context.recentContext) {
326
+ msg += `💭 Doing: ${context.recentContext}\n\n`;
327
+ }
328
+
329
+ msg += `🔧 ${tool}`;
330
+ if (details) msg += `\n${details}`;
331
+
332
+ return msg;
333
+ }
334
+
335
+ function formatNotification(data, context) {
336
+ const label = projectLabel(data.cwd);
337
+ const sessionNum = getSessionLabel(data.session_id);
338
+ const type = data.notification_type || data.type || 'notification';
339
+ const session = sessions.get(data.session_id);
340
+ const canReply = !!(session && session.ttyPath);
341
+
342
+ let msg = '';
343
+
344
+ if (type === 'idle_prompt') {
345
+ msg += `⏳ #${sessionNum} ${label} — Claude is idle\n`;
346
+ } else if (type === 'elicitation_dialog') {
347
+ msg += `💬 #${sessionNum} ${label} — Claude has a question\n`;
348
+ } else {
349
+ msg += `🔔 #${sessionNum} ${label} — ${type}\n`;
350
+ }
351
+
352
+ msg += `━━━━━━━━━━━━━━━━━━━━\n`;
353
+
354
+ if (context.userTask) {
355
+ msg += `📝 Task: ${context.userTask}\n\n`;
356
+ }
357
+
358
+ if (context.recentContext) {
359
+ msg += `💬 Claude said:\n${context.recentContext}\n`;
360
+ }
361
+
362
+ if (canReply) {
363
+ msg += `\n↩️ Reply to this message to send input`;
364
+ } else {
365
+ msg += `\nOpen your terminal to respond.`;
366
+ }
367
+
368
+ return msg;
369
+ }
370
+
371
+ // --- Elicitation UI ---
372
+
373
+ /**
374
+ * Send the next unanswered question for an elicitation to Telegram.
375
+ */
376
+ async function sendElicitationQuestion(elicId) {
377
+ const elic = pendingElicitations.get(elicId);
378
+ if (!elic) return;
379
+
380
+ const qIdx = elic.answers.size;
381
+ if (qIdx >= elic.questions.length) {
382
+ await sendElicitationSummary(elicId);
383
+ return;
384
+ }
385
+
386
+ const q = elic.questions[qIdx];
387
+ const sessionNum = getSessionLabel(elic.sessionId);
388
+ const session = sessions.get(elic.sessionId);
389
+ const label = session?.label || 'unknown';
390
+ const total = elic.questions.length;
391
+ const eid = shortId(elicId);
392
+
393
+ let msg = `💬 #${sessionNum} ${label} — Claude has a question\n`;
394
+ msg += `━━━━━━━━━━━━━━━━━━━━\n`;
395
+ if (elic.userTask) msg += `📝 Task: ${elic.userTask}\n\n`;
396
+ msg += `❓ [${qIdx + 1}/${total}] ${q.question}`;
397
+ if (q.multiSelect) msg += ` (select multiple)`;
398
+ msg += `\n`;
399
+
400
+ for (const opt of q.options) {
401
+ if (opt.description) {
402
+ msg += `\n• ${opt.label}: ${opt.description}`;
403
+ }
404
+ }
405
+
406
+ const buttons = q.options.map((opt, optIdx) =>
407
+ Markup.button.callback(opt.label, `e:${eid}:${qIdx}:${optIdx}`)
408
+ );
409
+ buttons.push(Markup.button.callback('✏️ Custom', `e:${eid}:${qIdx}:custom`));
410
+
411
+ if (q.multiSelect) {
412
+ buttons.push(Markup.button.callback('Next ➡️', `e:${eid}:${qIdx}:done`));
413
+ }
414
+
415
+ // Arrange in rows of 2
416
+ const rows = [];
417
+ for (let i = 0; i < buttons.length; i += 2) {
418
+ rows.push(buttons.slice(i, i + 2));
419
+ }
420
+
421
+ const keyboard = Markup.inlineKeyboard(rows);
422
+ const sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
423
+ elic.telegramMessageIds.push(sent.message_id);
424
+ elic.currentMessageId = sent.message_id;
425
+ }
426
+
427
+ /**
428
+ * Send a summary of all answers with Confirm/Redo buttons.
429
+ */
430
+ async function sendElicitationSummary(elicId) {
431
+ const elic = pendingElicitations.get(elicId);
432
+ if (!elic) return;
433
+
434
+ const sessionNum = getSessionLabel(elic.sessionId);
435
+ const session = sessions.get(elic.sessionId);
436
+ const label = session?.label || 'unknown';
437
+ const eid = shortId(elicId);
438
+
439
+ let msg = `📋 #${sessionNum} ${label} — Your answers\n`;
440
+ msg += `━━━━━━━━━━━━━━━━━━━━\n`;
441
+
442
+ for (let i = 0; i < elic.questions.length; i++) {
443
+ const q = elic.questions[i];
444
+ const a = elic.answers.get(i);
445
+ let answerText;
446
+ if (a?.isCustom) {
447
+ answerText = `"${a.customText}"`;
448
+ } else if (a?.multiSelections) {
449
+ answerText = a.multiSelections.map((s) => s.label).join(', ');
450
+ } else {
451
+ answerText = a?.label || '?';
452
+ }
453
+ msg += `${i + 1}. ${q.header || q.question}: ${answerText}\n`;
454
+ }
455
+
456
+ const summaryButtons = [
457
+ Markup.button.callback('✅ Confirm', `e:${eid}:confirm`),
458
+ Markup.button.callback('🔄 Redo', `e:${eid}:redo`),
459
+ ];
460
+ if (elic.isPermission) {
461
+ summaryButtons.push(Markup.button.callback('❌ Deny', `e:${eid}:deny`));
462
+ }
463
+ const keyboard = Markup.inlineKeyboard(summaryButtons);
464
+
465
+ const sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
466
+ elic.telegramMessageIds.push(sent.message_id);
467
+ }
468
+
469
+ /**
470
+ * Handle an elicitation callback query (prefix "e:").
471
+ */
472
+ async function handleElicitationCallback(ctx, cbData) {
473
+ const parts = cbData.split(':');
474
+ const eid = parts[1];
475
+
476
+ let elicId;
477
+ for (const [id] of pendingElicitations) {
478
+ if (shortId(id) === eid) {
479
+ elicId = id;
480
+ break;
481
+ }
482
+ }
483
+
484
+ if (!elicId) {
485
+ ctx.answerCbQuery('Expired or already answered.');
486
+ return;
487
+ }
488
+
489
+ const elic = pendingElicitations.get(elicId);
490
+
491
+ // Confirm
492
+ if (parts[2] === 'confirm') {
493
+ try { await ctx.answerCbQuery('Sending answers...'); } catch {}
494
+ try { await ctx.editMessageReplyMarkup(undefined); } catch {}
495
+
496
+ if (elic.isPermission && elic.permissionResolve) {
497
+ // From permission request — allow the tool, then inject answers after UI appears
498
+ elic.permissionResolve({ decision: 'allow' });
499
+ try { await ctx.editMessageText(ctx.callbackQuery.message.text + '\n\n✅ Answers submitted — injecting...'); } catch {}
500
+
501
+ setTimeout(() => {
502
+ const ok = injectElicitationAnswers(elic.ttyPath, elic.questions, elic.answers);
503
+ log(`Elicitation ${elicId}: ${ok ? 'keystrokes injected' : 'injection failed'}`);
504
+ if (!ok) {
505
+ bot.telegram.sendMessage(config.chatId, '⚠️ Could not inject answers into terminal').catch(() => {});
506
+ }
507
+ }, 2000);
508
+ } else {
509
+ // From notification flow — inject immediately
510
+ const ok = injectElicitationAnswers(elic.ttyPath, elic.questions, elic.answers);
511
+ try {
512
+ await ctx.editMessageText(ctx.callbackQuery.message.text +
513
+ (ok ? '\n\n✅ Answers submitted' : '\n\n⚠️ Could not send to terminal'));
514
+ } catch {}
515
+ }
516
+
517
+ pendingElicitations.delete(elicId);
518
+ log(`Elicitation ${elicId}: confirmed`);
519
+ return;
520
+ }
521
+
522
+ // Deny (for permission-based elicitations)
523
+ if (parts[2] === 'deny') {
524
+ try { await ctx.answerCbQuery('Denied'); } catch {}
525
+ try { await ctx.editMessageReplyMarkup(undefined); } catch {}
526
+ try { await ctx.editMessageText(ctx.callbackQuery.message.text + '\n\n❌ Denied'); } catch {}
527
+
528
+ if (elic.isPermission && elic.permissionResolve) {
529
+ elic.permissionResolve({ decision: 'deny' });
530
+ }
531
+
532
+ pendingElicitations.delete(elicId);
533
+ log(`Elicitation ${elicId}: denied`);
534
+ return;
535
+ }
536
+
537
+ // Redo
538
+ if (parts[2] === 'redo') {
539
+ try { await ctx.answerCbQuery('Starting over...'); } catch {}
540
+ try { await ctx.editMessageReplyMarkup(undefined); } catch {}
541
+ elic.answers.clear();
542
+ if (elic.multiToggles) elic.multiToggles.clear();
543
+ await sendElicitationQuestion(elicId);
544
+ log(`Elicitation ${elicId}: redo`);
545
+ return;
546
+ }
547
+
548
+ const qIdx = parseInt(parts[2]);
549
+ const optAction = parts[3];
550
+ const q = elic.questions[qIdx];
551
+
552
+ if (!q) {
553
+ ctx.answerCbQuery('Invalid question.');
554
+ return;
555
+ }
556
+
557
+ // Custom answer
558
+ if (optAction === 'custom') {
559
+ try { await ctx.answerCbQuery('Type your answer...'); } catch {}
560
+ try { await ctx.editMessageReplyMarkup(undefined); } catch {}
561
+ try { await ctx.editMessageText(ctx.callbackQuery.message.text + '\n\n✏️ Selected: Custom'); } catch {}
562
+
563
+ const prompt = await bot.telegram.sendMessage(
564
+ config.chatId,
565
+ `✏️ Type your custom answer for: "${q.question}"\n\nReply to this message with your answer.`
566
+ );
567
+
568
+ elic.customWaitingMessageId = prompt.message_id;
569
+ elic.customWaitingQIdx = qIdx;
570
+ elic.telegramMessageIds.push(prompt.message_id);
571
+ log(`Elicitation ${elicId}: waiting for custom answer to q${qIdx}`);
572
+ return;
573
+ }
574
+
575
+ // MultiSelect "done" — save current toggles and advance
576
+ if (optAction === 'done') {
577
+ const toggles = elic.multiToggles || new Map();
578
+ const selected = toggles.get(qIdx) || new Set();
579
+
580
+ if (selected.size === 0) {
581
+ ctx.answerCbQuery('Select at least one option.');
582
+ return;
583
+ }
584
+
585
+ const selections = [...selected].sort().map((idx) => ({
586
+ label: q.options[idx].label,
587
+ optionIndex: idx,
588
+ }));
589
+
590
+ elic.answers.set(qIdx, {
591
+ label: selections.map((s) => s.label).join(', '),
592
+ optionIndex: selections[0].optionIndex,
593
+ isCustom: false,
594
+ customText: null,
595
+ multiSelections: selections,
596
+ });
597
+
598
+ const selLabels = selections.map((s) => s.label).join(', ');
599
+ try { await ctx.answerCbQuery(`Selected: ${selLabels}`); } catch {}
600
+ try { await ctx.editMessageReplyMarkup(undefined); } catch {}
601
+ try { await ctx.editMessageText(ctx.callbackQuery.message.text + `\n\n✅ Selected: ${selLabels}`); } catch {}
602
+
603
+ log(`Elicitation ${elicId}: q${qIdx} multi = [${selLabels}]`);
604
+ await sendElicitationQuestion(elicId);
605
+ return;
606
+ }
607
+
608
+ // Regular option
609
+ const optIdx = parseInt(optAction);
610
+ const opt = q.options[optIdx];
611
+
612
+ if (!opt) {
613
+ ctx.answerCbQuery('Invalid option.');
614
+ return;
615
+ }
616
+
617
+ // MultiSelect — toggle and update buttons
618
+ if (q.multiSelect) {
619
+ if (!elic.multiToggles) elic.multiToggles = new Map();
620
+ if (!elic.multiToggles.has(qIdx)) elic.multiToggles.set(qIdx, new Set());
621
+ const selected = elic.multiToggles.get(qIdx);
622
+
623
+ if (selected.has(optIdx)) {
624
+ selected.delete(optIdx);
625
+ ctx.answerCbQuery(`Deselected: ${opt.label}`);
626
+ } else {
627
+ selected.add(optIdx);
628
+ ctx.answerCbQuery(`Selected: ${opt.label}`);
629
+ }
630
+
631
+ // Rebuild buttons with selection indicators
632
+ const buttons = q.options.map((o, i) => {
633
+ const prefix = selected.has(i) ? '✅ ' : '';
634
+ return Markup.button.callback(`${prefix}${o.label}`, `e:${eid}:${qIdx}:${i}`);
635
+ });
636
+ buttons.push(Markup.button.callback('✏️ Custom', `e:${eid}:${qIdx}:custom`));
637
+ buttons.push(Markup.button.callback('Next ➡️', `e:${eid}:${qIdx}:done`));
638
+
639
+ const rows = [];
640
+ for (let i = 0; i < buttons.length; i += 2) {
641
+ rows.push(buttons.slice(i, i + 2));
642
+ }
643
+
644
+ try { await ctx.editMessageReplyMarkup(Markup.inlineKeyboard(rows).reply_markup); } catch {}
645
+ return;
646
+ }
647
+
648
+ // Single select — save and advance
649
+ elic.answers.set(qIdx, {
650
+ label: opt.label,
651
+ optionIndex: optIdx,
652
+ isCustom: false,
653
+ customText: null,
654
+ multiSelections: null,
655
+ });
656
+
657
+ try { await ctx.answerCbQuery(`Selected: ${opt.label}`); } catch {}
658
+ try { await ctx.editMessageReplyMarkup(undefined); } catch {}
659
+ try { await ctx.editMessageText(ctx.callbackQuery.message.text + `\n\n✅ Selected: ${opt.label}`); } catch {}
660
+
661
+ log(`Elicitation ${elicId}: q${qIdx} = ${opt.label}`);
662
+ await sendElicitationQuestion(elicId);
663
+ }
664
+
665
+ /**
666
+ * Inject elicitation answers into the terminal via osascript keystrokes.
667
+ * Navigates the AskUserQuestion form using arrow keys, space, tab, and enter.
668
+ */
669
+ function injectElicitationAnswers(ttyPath, questions, answers) {
670
+ if (!ttyPath) {
671
+ log('injectElicitation: no TTY path');
672
+ return false;
673
+ }
674
+
675
+ // Build sequence of key events
676
+ const events = [];
677
+
678
+ for (let qIdx = 0; qIdx < questions.length; qIdx++) {
679
+ const q = questions[qIdx];
680
+ const answer = answers.get(qIdx);
681
+ if (!answer) continue;
682
+
683
+ if (answer.isCustom) {
684
+ // Navigate to "Other" (last position, after all defined options)
685
+ const otherPos = q.options.length;
686
+ for (let i = 0; i < otherPos; i++) {
687
+ events.push({ type: 'key_code', value: 125 }); // arrow down
688
+ }
689
+ events.push({ type: 'key_code', value: 36 }); // enter to select Other
690
+ events.push({ type: 'delay', value: 0.3 });
691
+ events.push({ type: 'keystroke', value: answer.customText }); // type custom text
692
+ } else if (answer.multiSelections) {
693
+ // MultiSelect: walk through all options, space on selected ones
694
+ const selectedSet = new Set(answer.multiSelections.map((s) => s.optionIndex));
695
+ for (let i = 0; i < q.options.length; i++) {
696
+ if (selectedSet.has(i)) {
697
+ events.push({ type: 'keystroke', value: ' ' }); // space to toggle
698
+ }
699
+ if (i < q.options.length - 1) {
700
+ events.push({ type: 'key_code', value: 125 }); // arrow down
701
+ }
702
+ }
703
+ } else {
704
+ // Single select: navigate to selected option
705
+ for (let i = 0; i < answer.optionIndex; i++) {
706
+ events.push({ type: 'key_code', value: 125 }); // arrow down
707
+ }
708
+ }
709
+
710
+ // Tab to next question, or nothing for the last one
711
+ if (qIdx < questions.length - 1) {
712
+ events.push({ type: 'key_code', value: 48 }); // tab
713
+ events.push({ type: 'delay', value: 0.15 });
714
+ }
715
+ }
716
+
717
+ // Submit the form
718
+ events.push({ type: 'delay', value: 0.2 });
719
+ events.push({ type: 'keystroke', value: 'return' });
720
+
721
+ // Build key action lines for AppleScript
722
+ const keyLines = events.map((e) => {
723
+ if (e.type === 'key_code') return ` key code ${e.value}`;
724
+ if (e.type === 'keystroke') {
725
+ if (e.value === 'return') return ' keystroke return';
726
+ if (e.value === ' ') return ' keystroke " "';
727
+ return ` keystroke "${escapeAppleScript(e.value)}"`;
728
+ }
729
+ if (e.type === 'delay') return ` delay ${e.value}`;
730
+ return '';
731
+ }).join('\n');
732
+
733
+ // Try iTerm2 first (focus + System Events)
734
+ try {
735
+ const script = [
736
+ 'tell application "iTerm2"',
737
+ ' activate',
738
+ ' repeat with w in windows',
739
+ ' repeat with t in tabs of w',
740
+ ' repeat with s in sessions of t',
741
+ ` if tty of s is "${ttyPath}" then`,
742
+ ' select s',
743
+ ' end if',
744
+ ' end repeat',
745
+ ' end repeat',
746
+ ' end repeat',
747
+ 'end tell',
748
+ 'delay 0.5',
749
+ 'tell application "System Events"',
750
+ ' tell process "iTerm2"',
751
+ keyLines,
752
+ ' end tell',
753
+ 'end tell',
754
+ ].join('\n');
755
+
756
+ fs.writeFileSync('/tmp/claude-tg-elicit.scpt', script);
757
+ execSync('osascript /tmp/claude-tg-elicit.scpt', { timeout: 30000, stdio: 'pipe' });
758
+ log(`Elicitation keystrokes sent via iTerm2 to ${ttyPath}`);
759
+ return true;
760
+ } catch {}
761
+
762
+ // Try Terminal.app
763
+ try {
764
+ const script = [
765
+ 'tell application "Terminal"',
766
+ ' repeat with w in windows',
767
+ ' repeat with t in tabs of w',
768
+ ` if tty of t is "${ttyPath}" then`,
769
+ ' set selected tab of w to t',
770
+ ' set frontmost of w to true',
771
+ ' end if',
772
+ ' end repeat',
773
+ ' end repeat',
774
+ 'end tell',
775
+ 'delay 0.5',
776
+ 'tell application "System Events"',
777
+ ' tell process "Terminal"',
778
+ keyLines,
779
+ ' end tell',
780
+ 'end tell',
781
+ ].join('\n');
782
+
783
+ fs.writeFileSync('/tmp/claude-tg-elicit.scpt', script);
784
+ execSync('osascript /tmp/claude-tg-elicit.scpt', { timeout: 30000, stdio: 'pipe' });
785
+ log(`Elicitation keystrokes sent via Terminal.app to ${ttyPath}`);
786
+ return true;
787
+ } catch (err) {
788
+ log(`Terminal.app elicitation error: ${err.message}`);
789
+ }
790
+
791
+ log(`injectElicitation failed: no terminal found for ${ttyPath}`);
792
+ return false;
793
+ }
794
+
795
+ // --- Telegram bot ---
796
+
797
+ function startBot() {
798
+ bot = new Telegraf(config.botToken);
799
+
800
+ bot.command('start', (ctx) => {
801
+ const chatId = ctx.chat.id.toString();
802
+ ctx.reply(`Chat ID registered: ${chatId}\n\nThis chat will receive Claude Code permission requests.`);
803
+ log(`/start from chat ${chatId}`);
804
+ });
805
+
806
+ bot.command('status', (ctx) => {
807
+ const pendingCount = pendingQuestions.size;
808
+ const activeSessions = [...sessions.entries()].filter(([, s]) => {
809
+ if (s.ttyPath) return isTtyAlive(s.ttyPath);
810
+ return Date.now() - s.lastActive < 60 * 60 * 1000;
811
+ });
812
+
813
+ let msg = '';
814
+ if (activeSessions.length === 0 && pendingCount === 0) {
815
+ ctx.reply('No active sessions or pending requests.');
816
+ return;
817
+ }
818
+
819
+ if (activeSessions.length > 0) {
820
+ msg += `${activeSessions.length} active session(s):\n`;
821
+ for (const [sid, s] of activeSessions) {
822
+ const num = getSessionLabel(sid);
823
+ const tty = s.ttyPath ? s.ttyPath.split('/').pop() : 'no tty';
824
+ msg += ` #${num} ${s.label} (${tty})\n`;
825
+ }
826
+ }
827
+
828
+ if (pendingCount > 0) {
829
+ msg += `\n${pendingCount} pending permission(s):\n`;
830
+ for (const [id, q] of pendingQuestions) {
831
+ const age = Math.round((Date.now() - q.createdAt) / 1000 / 60);
832
+ const sLabel = getSessionLabel(q.sessionId);
833
+ msg += ` #${sLabel} ${q.toolName} (${age}m ago)\n`;
834
+ }
835
+ }
836
+
837
+ if (pendingElicitations.size > 0) {
838
+ msg += `\n${pendingElicitations.size} pending elicitation(s):\n`;
839
+ for (const [, elic] of pendingElicitations) {
840
+ const sLabel = getSessionLabel(elic.sessionId);
841
+ msg += ` #${sLabel} ${elic.answers.size}/${elic.questions.length} answered\n`;
842
+ }
843
+ }
844
+
845
+ ctx.reply(msg);
846
+ });
847
+
848
+ // Handle inline keyboard button presses (permission decisions + elicitations)
849
+ bot.on('callback_query', async (ctx) => {
850
+ const data = ctx.callbackQuery.data;
851
+ if (!data) return;
852
+
853
+ // Route elicitation callbacks
854
+ if (data.startsWith('e:')) {
855
+ await handleElicitationCallback(ctx, data);
856
+ return;
857
+ }
858
+
859
+ const [rid, action] = data.split(':');
860
+ let requestId;
861
+ for (const [id] of pendingQuestions) {
862
+ if (shortId(id) === rid) {
863
+ requestId = id;
864
+ break;
865
+ }
866
+ }
867
+
868
+ if (!requestId) {
869
+ ctx.answerCbQuery('Request expired or already answered.');
870
+ return;
871
+ }
872
+
873
+ const pending = pendingQuestions.get(requestId);
874
+ pendingQuestions.delete(requestId);
875
+
876
+ let label;
877
+ if (action === 'allow') {
878
+ label = '✅ Allowed';
879
+ pending.resolve({ decision: 'allow' });
880
+ } else if (action === 'deny') {
881
+ label = '❌ Denied';
882
+ pending.resolve({ decision: 'deny' });
883
+ } else if (action === 'always') {
884
+ label = '✅ Always Allowed';
885
+ pending.resolve({ decision: 'always' });
886
+ }
887
+
888
+ try { await ctx.answerCbQuery(label); } catch {}
889
+ try { await ctx.editMessageReplyMarkup(undefined); } catch {}
890
+ try { await ctx.editMessageText(ctx.callbackQuery.message.text + `\n\n${label}`); } catch {}
891
+ log(`Answered ${requestId}: ${action}`);
892
+ });
893
+
894
+ // Handle text messages — route as user input to a terminal
895
+ bot.on('text', async (ctx) => {
896
+ // Ignore commands
897
+ if (ctx.message.text.startsWith('/')) return;
898
+
899
+ const text = ctx.message.text;
900
+ const replyTo = ctx.message.reply_to_message?.message_id;
901
+
902
+ // Check if this is a reply to a custom elicitation answer prompt
903
+ if (replyTo) {
904
+ for (const [elicId, elic] of pendingElicitations) {
905
+ if (elic.customWaitingMessageId === replyTo) {
906
+ const qIdx = elic.customWaitingQIdx;
907
+ const q = elic.questions[qIdx];
908
+
909
+ elic.answers.set(qIdx, {
910
+ label: text,
911
+ optionIndex: q.options.length, // "Other" position
912
+ isCustom: true,
913
+ customText: text,
914
+ multiSelections: null,
915
+ });
916
+
917
+ elic.customWaitingMessageId = null;
918
+ elic.customWaitingQIdx = null;
919
+
920
+ ctx.reply(`✅ Custom answer for "${q.question}": ${text}`);
921
+ log(`Elicitation ${elicId}: q${qIdx} custom = "${text}"`);
922
+
923
+ await sendElicitationQuestion(elicId);
924
+ return;
925
+ }
926
+ }
927
+ }
928
+
929
+ let targetSessionId = null;
930
+
931
+ // If replying to a specific message, route to that session
932
+ if (replyTo) {
933
+ const mapping = messageToSession.get(replyTo);
934
+ if (mapping) {
935
+ if (mapping.type === 'permission') {
936
+ ctx.reply('⚠️ That was a permission request — use the buttons above.\nTo send text input, reply to a notification message.');
937
+ return;
938
+ }
939
+ targetSessionId = mapping.sessionId;
940
+ }
941
+ }
942
+
943
+ // If no reply-to, try auto-routing
944
+ if (!targetSessionId) {
945
+ // Find sessions that have notifications and are still alive (TTY exists)
946
+ const recentNotifications = [...messageToSession.entries()]
947
+ .filter(([, m]) => {
948
+ if (m.type !== 'notification') return false;
949
+ const s = sessions.get(m.sessionId);
950
+ if (s?.ttyPath) return isTtyAlive(s.ttyPath);
951
+ return Date.now() - m.createdAt < 60 * 60 * 1000;
952
+ })
953
+ .map(([, m]) => m.sessionId);
954
+
955
+ const uniqueSessions = [...new Set(recentNotifications)];
956
+
957
+ if (uniqueSessions.length === 1) {
958
+ targetSessionId = uniqueSessions[0];
959
+ } else if (uniqueSessions.length === 0) {
960
+ ctx.reply('No idle Claude sessions to send input to.');
961
+ return;
962
+ } else {
963
+ // Multiple sessions — ask user to be specific
964
+ const labels = uniqueSessions.map((sid) => {
965
+ const s = sessions.get(sid);
966
+ const num = getSessionLabel(sid);
967
+ return ` #${num} ${s?.label || 'unknown'}`;
968
+ }).join('\n');
969
+ ctx.reply(`Multiple sessions are waiting. Reply to a specific notification message to choose:\n\n${labels}`);
970
+ return;
971
+ }
972
+ }
973
+
974
+ // Send the text to the terminal
975
+ const session = sessions.get(targetSessionId);
976
+ if (!session || !session.ttyPath) {
977
+ ctx.reply(`⚠️ No TTY for session #${getSessionLabel(targetSessionId)}. Open the terminal to respond.`);
978
+ return;
979
+ }
980
+
981
+ const ok = sendInputToTerminal(session.ttyPath, text);
982
+ if (ok) {
983
+ const num = getSessionLabel(targetSessionId);
984
+ ctx.reply(`➡️ Sent to #${num} ${session.label}`);
985
+ } else {
986
+ ctx.reply(`⚠️ Could not send to terminal. Session may have ended, or terminal app not recognized.`);
987
+ }
988
+ });
989
+
990
+ bot.catch((err) => {
991
+ log(`Bot error: ${err.message}`);
992
+ });
993
+
994
+ bot.launch({ dropPendingUpdates: true });
995
+ log('Telegram bot started');
996
+ }
997
+
998
+ // --- HTTP handlers ---
999
+
1000
+ async function sendPermissionRequest(data) {
1001
+ trackSession(data);
1002
+
1003
+ // AskUserQuestion — show actual questions with option buttons instead of Allow/Deny
1004
+ if (data.tool_name === 'AskUserQuestion' && data.tool_input?.questions?.length > 0) {
1005
+ return handleElicitationPermission(data);
1006
+ }
1007
+
1008
+ const requestId = crypto.randomUUID();
1009
+ const rid = shortId(requestId);
1010
+ const context = extractContext(data.transcript_path);
1011
+ const msg = formatPermissionMessage(data, context);
1012
+
1013
+ const keyboard = Markup.inlineKeyboard([
1014
+ Markup.button.callback('Allow', `${rid}:allow`),
1015
+ Markup.button.callback('Deny', `${rid}:deny`),
1016
+ Markup.button.callback('Always Allow', `${rid}:always`),
1017
+ ]);
1018
+
1019
+ const sent = await bot.telegram.sendMessage(config.chatId, msg, keyboard);
1020
+
1021
+ // Track this message for reply routing
1022
+ messageToSession.set(sent.message_id, {
1023
+ sessionId: data.session_id,
1024
+ type: 'permission',
1025
+ createdAt: Date.now(),
1026
+ });
1027
+
1028
+ return new Promise((resolve) => {
1029
+ pendingQuestions.set(requestId, {
1030
+ resolve,
1031
+ sessionId: data.session_id,
1032
+ toolName: data.tool_name || 'Unknown',
1033
+ createdAt: Date.now(),
1034
+ telegramMessageId: sent.message_id,
1035
+ permissionSuggestions: data.permission_suggestions,
1036
+ });
1037
+ log(`Permission request ${requestId} for ${data.tool_name}`);
1038
+ });
1039
+ }
1040
+
1041
+ /**
1042
+ * Handle AskUserQuestion as an interactive elicitation via Telegram.
1043
+ * Returns a promise that resolves with { decision: 'allow' | 'deny' } when user confirms/denies.
1044
+ */
1045
+ function handleElicitationPermission(data) {
1046
+ const elicId = crypto.randomUUID();
1047
+ const context = extractContext(data.transcript_path);
1048
+ const questions = data.tool_input.questions;
1049
+
1050
+ pendingElicitations.set(elicId, {
1051
+ sessionId: data.session_id,
1052
+ ttyPath: data.tty_path || sessions.get(data.session_id)?.ttyPath,
1053
+ questions,
1054
+ answers: new Map(),
1055
+ multiToggles: new Map(),
1056
+ telegramMessageIds: [],
1057
+ currentMessageId: null,
1058
+ customWaitingMessageId: null,
1059
+ customWaitingQIdx: null,
1060
+ userTask: context.userTask,
1061
+ createdAt: Date.now(),
1062
+ isPermission: true,
1063
+ permissionResolve: null,
1064
+ });
1065
+
1066
+ log(`Elicitation (permission) ${elicId}: ${questions.length} question(s)`);
1067
+ sendElicitationQuestion(elicId).catch((e) => log(`Elicitation send error: ${e.message}`));
1068
+
1069
+ return new Promise((resolve) => {
1070
+ const elic = pendingElicitations.get(elicId);
1071
+ elic.permissionResolve = resolve;
1072
+ });
1073
+ }
1074
+
1075
+ async function sendNotification(data) {
1076
+ trackSession(data);
1077
+
1078
+ const notifType = data.notification_type || data.type;
1079
+
1080
+ // Check if this is an elicitation
1081
+ if (notifType === 'elicitation_dialog') {
1082
+ // Skip if already handling this session's elicitation via permission request
1083
+ for (const [, elic] of pendingElicitations) {
1084
+ if (elic.sessionId === data.session_id) {
1085
+ log(`Skipping elicitation_dialog — already handling for session ${data.session_id}`);
1086
+ return;
1087
+ }
1088
+ }
1089
+
1090
+ const questions = extractElicitation(data.transcript_path);
1091
+ if (questions && questions.length > 0) {
1092
+ const elicId = crypto.randomUUID();
1093
+ const context = extractContext(data.transcript_path);
1094
+
1095
+ pendingElicitations.set(elicId, {
1096
+ sessionId: data.session_id,
1097
+ ttyPath: data.tty_path || sessions.get(data.session_id)?.ttyPath,
1098
+ questions,
1099
+ answers: new Map(),
1100
+ multiToggles: new Map(),
1101
+ telegramMessageIds: [],
1102
+ currentMessageId: null,
1103
+ customWaitingMessageId: null,
1104
+ customWaitingQIdx: null,
1105
+ userTask: context.userTask,
1106
+ createdAt: Date.now(),
1107
+ });
1108
+
1109
+ log(`Elicitation ${elicId}: ${questions.length} question(s) from session ${data.session_id}`);
1110
+ await sendElicitationQuestion(elicId);
1111
+ return;
1112
+ }
1113
+ }
1114
+
1115
+ // Default notification flow
1116
+ const context = extractContext(data.transcript_path);
1117
+ const msg = formatNotification(data, context);
1118
+ const sent = await bot.telegram.sendMessage(config.chatId, msg);
1119
+
1120
+ messageToSession.set(sent.message_id, {
1121
+ sessionId: data.session_id,
1122
+ type: 'notification',
1123
+ createdAt: Date.now(),
1124
+ });
1125
+
1126
+ log(`Notification sent: ${notifType} (msg ${sent.message_id})`);
1127
+ }
1128
+
1129
+ function readBody(req) {
1130
+ return new Promise((resolve, reject) => {
1131
+ let body = '';
1132
+ req.on('data', (chunk) => { body += chunk; });
1133
+ req.on('end', () => {
1134
+ try { resolve(JSON.parse(body)); }
1135
+ catch (e) { reject(e); }
1136
+ });
1137
+ req.on('error', reject);
1138
+ });
1139
+ }
1140
+
1141
+ function startServer() {
1142
+ const server = http.createServer(async (req, res) => {
1143
+ try {
1144
+ if (req.method === 'GET' && req.url === '/api/health') {
1145
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1146
+ res.end(JSON.stringify({
1147
+ status: 'ok',
1148
+ pending: pendingQuestions.size,
1149
+ sessions: sessions.size,
1150
+ }));
1151
+ return;
1152
+ }
1153
+
1154
+ if (req.method === 'POST' && req.url === '/api/permission') {
1155
+ const data = await readBody(req);
1156
+ const result = await sendPermissionRequest(data);
1157
+
1158
+ let response;
1159
+ if (result.decision === 'allow') {
1160
+ response = { decision: { behavior: 'allow' } };
1161
+ } else if (result.decision === 'deny') {
1162
+ response = { decision: { behavior: 'deny', message: 'Denied via Telegram' } };
1163
+ } else if (result.decision === 'always') {
1164
+ response = {
1165
+ decision: {
1166
+ behavior: 'allow',
1167
+ updatedPermissions: data.permission_suggestions,
1168
+ },
1169
+ };
1170
+ }
1171
+
1172
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1173
+ res.end(JSON.stringify(response));
1174
+ return;
1175
+ }
1176
+
1177
+ if (req.method === 'POST' && req.url === '/api/notify') {
1178
+ const data = await readBody(req);
1179
+ sendNotification(data).catch((e) => log(`Notify error: ${e.message}`));
1180
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1181
+ res.end(JSON.stringify({ status: 'ok' }));
1182
+ return;
1183
+ }
1184
+
1185
+ res.writeHead(404);
1186
+ res.end('Not found');
1187
+ } catch (err) {
1188
+ log(`Server error: ${err.message}`);
1189
+ res.writeHead(500);
1190
+ res.end('Internal error');
1191
+ }
1192
+ });
1193
+
1194
+ server.listen(config.port, '127.0.0.1', () => {
1195
+ log(`HTTP server listening on 127.0.0.1:${config.port}`);
1196
+ });
1197
+
1198
+ return server;
1199
+ }
1200
+
1201
+ // --- Cleanup stale state periodically ---
1202
+
1203
+ function startCleanup() {
1204
+ setInterval(() => {
1205
+ const now = Date.now();
1206
+ const staleThreshold = 60 * 60 * 1000; // 1 hour
1207
+
1208
+ // Clean message mappings — keep if session's TTY is still alive
1209
+ for (const [msgId, m] of messageToSession) {
1210
+ const s = sessions.get(m.sessionId);
1211
+ if (s?.ttyPath && isTtyAlive(s.ttyPath)) continue;
1212
+ if (now - m.createdAt > staleThreshold) {
1213
+ messageToSession.delete(msgId);
1214
+ }
1215
+ }
1216
+
1217
+ // Clean stale sessions — only if terminal is gone
1218
+ for (const [sid, s] of sessions) {
1219
+ if (s.ttyPath) {
1220
+ if (!isTtyAlive(s.ttyPath)) {
1221
+ sessions.delete(sid);
1222
+ log(`Cleaned session ${sid} (TTY gone: ${s.ttyPath})`);
1223
+ }
1224
+ } else if (now - s.lastActive > staleThreshold) {
1225
+ sessions.delete(sid);
1226
+ log(`Cleaned stale session ${sid} (no TTY, inactive)`);
1227
+ }
1228
+ }
1229
+
1230
+ // Clean stale elicitations (30 min timeout)
1231
+ for (const [elicId, elic] of pendingElicitations) {
1232
+ if (now - elic.createdAt > 30 * 60 * 1000) {
1233
+ pendingElicitations.delete(elicId);
1234
+ log(`Cleaned stale elicitation ${elicId}`);
1235
+ }
1236
+ }
1237
+ }, 10 * 60 * 1000); // every 10 min
1238
+ }
1239
+
1240
+ // --- Main ---
1241
+
1242
+ function main() {
1243
+ config = loadConfig();
1244
+ if (!config.botToken || !config.chatId) {
1245
+ console.error('Missing botToken or chatId. Run: claude-tg setup');
1246
+ process.exit(1);
1247
+ }
1248
+
1249
+ log('Daemon starting...');
1250
+ startBot();
1251
+ startServer();
1252
+ startCleanup();
1253
+
1254
+ process.on('unhandledRejection', (err) => {
1255
+ log(`Unhandled rejection: ${err?.message || err}`);
1256
+ });
1257
+
1258
+ process.on('SIGTERM', () => {
1259
+ log('Received SIGTERM, shutting down');
1260
+ bot.stop('SIGTERM');
1261
+ process.exit(0);
1262
+ });
1263
+ process.on('SIGINT', () => {
1264
+ log('Received SIGINT, shutting down');
1265
+ bot.stop('SIGINT');
1266
+ process.exit(0);
1267
+ });
1268
+ }
1269
+
1270
+ if (require.main === module) {
1271
+ main();
1272
+ }
1273
+
1274
+ module.exports = { main };