claude-notification-plugin 1.1.21 → 1.1.29

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.
@@ -1,690 +1,796 @@
1
- #!/usr/bin/env node
2
- // noinspection UnnecessaryLocalVariableJS
3
-
4
- import fs from 'fs';
5
- import path from 'path';
6
- import process from 'process';
7
- import { createLogger } from './logger.js';
8
- import { createTaskLogger } from './task-logger.js';
9
- import { TelegramPoller, escapeHtml } from './telegram-poller.js';
10
- import { WorkQueue } from './work-queue.js';
11
- import { TaskRunner } from './task-runner.js';
12
- import { WorktreeManager } from './worktree-manager.js';
13
- import { parseMessage, parseTarget } from './message-parser.js';
14
- import { CLAUDE_DIR, CONFIG_PATH, LISTENER_LOG_FILENAME } from '../bin/constants.js';
15
-
16
- // ----------------------
17
- // CONFIG
18
- // ----------------------
19
-
20
- const DEFAULT_LOG_DIR = CLAUDE_DIR;
21
-
22
- function loadConfig () {
23
- try {
24
- const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
25
- return JSON.parse(raw);
26
- } catch (err) {
27
- console.error(`Failed to load config from ${CONFIG_PATH}: ${err.message}`);
28
- process.exit(1);
29
- }
30
- }
31
-
32
- // ----------------------
33
- // MAIN DAEMON
34
- // ----------------------
35
-
36
- const config = loadConfig();
37
- const listenerLogDir = config.listener?.logDir || DEFAULT_LOG_DIR;
38
- fs.mkdirSync(listenerLogDir, { recursive: true });
39
- const logger = createLogger(path.join(listenerLogDir, LISTENER_LOG_FILENAME));
40
-
41
- // Validate required fields
42
- const token = process.env.CLAUDE_NOTIFY_TELEGRAM_TOKEN || config.telegramToken || config.telegram?.token;
43
- const chatId = process.env.CLAUDE_NOTIFY_TELEGRAM_CHAT_ID || config.telegramChatId || config.telegram?.chatId;
44
-
45
- if (!token || !chatId) {
46
- logger.error('Missing telegramToken or telegramChatId in config');
47
- console.error('Missing telegramToken or telegramChatId in config');
48
- process.exit(1);
49
- }
50
-
51
- if (!config.listener?.projects || Object.keys(config.listener.projects).length === 0) {
52
- logger.error('No projects defined in config.listener.projects');
53
- console.error('No projects defined in config.listener.projects. Run "claude-notify listener setup" to configure.');
54
- process.exit(1);
55
- }
56
-
57
- // Validate project paths — skip projects with missing/invalid directories
58
- const validatedProjects = {};
59
- for (const [alias, proj] of Object.entries(config.listener.projects)) {
60
- const projPath = typeof proj === 'string' ? proj : proj?.path;
61
- if (!projPath) {
62
- logger.warn(`Project "${alias}": no path configured, skipping`);
63
- console.error(`\u26a0 Project "${alias}": no path configured, skipping`);
64
- continue;
65
- }
66
- try {
67
- const stat = fs.statSync(projPath);
68
- if (!stat.isDirectory()) {
69
- logger.warn(`Project "${alias}": path "${projPath}" is not a directory, skipping`);
70
- console.error(`\u26a0 Project "${alias}": path "${projPath}" is not a directory, skipping`);
71
- continue;
72
- }
73
- validatedProjects[alias] = proj;
74
- } catch {
75
- logger.warn(`Project "${alias}": path "${projPath}" does not exist, skipping`);
76
- console.error(`\u26a0 Project "${alias}": path "${projPath}" does not exist, skipping`);
77
- }
78
- }
79
-
80
- if (Object.keys(validatedProjects).length === 0) {
81
- logger.error('No projects with valid paths found in config.listener.projects');
82
- console.error('No projects with valid paths found. Run "claude-notify listener setup" to configure.');
83
- process.exit(1);
84
- }
85
-
86
- config.listener.projects = validatedProjects;
87
- const listenerConfig = config.listener;
88
- const taskTimeoutMinutes = listenerConfig.taskTimeoutMinutes || 30;
89
- const taskTimeout = taskTimeoutMinutes * 60_000;
90
-
91
- const poller = new TelegramPoller(token, chatId, logger);
92
- const queue = new WorkQueue(
93
- logger,
94
- listenerConfig.maxQueuePerWorkDir || 10,
95
- listenerConfig.maxTotalTasks || 50,
96
- );
97
- const taskLogDir = config.listener?.taskLogDir || listenerLogDir;
98
- fs.mkdirSync(taskLogDir, { recursive: true });
99
- const taskLogger = createTaskLogger(taskLogDir);
100
- const runner = new TaskRunner(logger, taskTimeout, taskLogger);
101
- const worktreeManager = new WorktreeManager(config, logger);
102
-
103
- const startTime = Date.now();
104
-
105
- logger.info('Listener started');
106
- logger.info(`Projects: ${JSON.stringify(Object.keys(listenerConfig.projects))}`);
107
-
108
- // ----------------------
109
- // DISCOVER WORKTREES ON START
110
- // ----------------------
111
-
112
- for (const alias of Object.keys(listenerConfig.projects)) {
113
- worktreeManager.discoverWorktrees(alias);
114
- }
115
-
116
- // ----------------------
117
- // WATCHDOG
118
- // ----------------------
119
-
120
- const recovered = queue.watchdog(taskTimeout);
121
- for (const { workDir, next } of recovered) {
122
- if (next) {
123
- startTask(workDir, next);
124
- }
125
- }
126
-
127
- // ----------------------
128
- // TASK RUNNER EVENTS
129
- // ----------------------
130
-
131
- runner.on('complete', async (workDir, task, output) => {
132
- const entry = queue.queues[workDir];
133
- const label = formatLabel(entry);
134
-
135
- // Delete the "Running" message
136
- await poller.deleteMessage(task.runningMessageId);
137
-
138
- // Build result: try replying to user's original message without duplicating the task text.
139
- // If reply fails (user deleted their message), resend with task text included.
140
- const headerShort = `✅ <code>${label}</code>`;
141
- const headerFull = `✅ <code>${label}</code>\n\n${escapeHtml(task.text)}`;
142
- let body = '';
143
- if (output) {
144
- if (output.length > 20000) {
145
- const head = output.slice(0, 2000);
146
- const tail = output.slice(-2000);
147
- body = `\n\n<pre>${escapeHtml(head)}\n\n... (${output.length} chars) ...\n\n${escapeHtml(tail)}</pre>`;
148
- await poller.sendDocument(
149
- Buffer.from(output, 'utf-8'),
150
- `result_${task.id}.txt`,
151
- `Full output for: ${task.text.slice(0, 100)}`
152
- );
153
- } else {
154
- body = `\n\n<pre>${escapeHtml(output)}</pre>`;
155
- }
156
- }
157
-
158
- // Try reply to original message (short header, task text visible in quote)
159
- const sentId = await poller.sendMessage(headerShort + body, task.telegramMessageId);
160
- if (!sentId && task.telegramMessageId) {
161
- // Reply failed — original message was deleted, send without reply but with full task text
162
- await poller.sendMessage(headerFull + body);
163
- }
164
-
165
- // Process next in queue
166
- const next = queue.onTaskComplete(workDir, output);
167
- if (next) {
168
- startTask(workDir, next);
169
- }
170
- });
171
-
172
- runner.on('error', async (workDir, task, errorMsg) => {
173
- const entry = queue.queues[workDir];
174
- const label = formatLabel(entry);
175
-
176
- await poller.deleteMessage(task.runningMessageId);
177
-
178
- const body = `\n\n<pre>${escapeHtml(errorMsg)}</pre>`;
179
- const sentId = await poller.sendMessage(`❌ <code>${label}</code>\nError:${body}`, task.telegramMessageId);
180
- if (!sentId && task.telegramMessageId) {
181
- await poller.sendMessage(`❌ <code>${label}</code>\nError: ${escapeHtml(task.text)}${body}`);
182
- }
183
-
184
- const next = queue.onTaskComplete(workDir, `ERROR: ${errorMsg}`);
185
- if (next) {
186
- startTask(workDir, next);
187
- }
188
- });
189
-
190
- runner.on('timeout', async (workDir, task) => {
191
- const entry = queue.queues[workDir];
192
- const label = formatLabel(entry);
193
- const timeoutMin = Math.round(taskTimeout / 60000);
194
-
195
- await poller.deleteMessage(task.runningMessageId);
196
-
197
- const headerShort = `⏰ <code>${label}</code>\nTask forcefully stopped — timeout exceeded (${timeoutMin} min)`;
198
- const headerFull = `${headerShort}: ${escapeHtml(task.text)}`;
199
- const sentId = await poller.sendMessage(headerShort, task.telegramMessageId);
200
- if (!sentId && task.telegramMessageId) {
201
- await poller.sendMessage(headerFull);
202
- }
203
-
204
- const next = queue.onTaskComplete(workDir, 'TIMEOUT');
205
- if (next) {
206
- startTask(workDir, next);
207
- }
208
- });
209
-
210
- // ----------------------
211
- // HELPERS
212
- // ----------------------
213
-
214
- function formatLabel (entry) {
215
- if (!entry) {
216
- return 'unknown';
217
- }
218
- if (entry.branch && entry.branch !== 'main' && entry.branch !== 'master') {
219
- return `/${entry.project}/${entry.branch}`;
220
- }
221
- return `/${entry.project}`;
222
- }
223
-
224
- async function startTask (workDir, task) {
225
- const entry = queue.queues[workDir];
226
- const label = formatLabel(entry);
227
- const runningShort = `⏳ <code>${label}</code>\nRunning...`;
228
- const runningFull = `⏳ <code>${label}</code>\nRunning: ${escapeHtml(task.text)}`;
229
- let runningMsgId = null;
230
-
231
- if (task.telegramMessageId) {
232
- // In replies, the quoted user message already contains task text.
233
- runningMsgId = await poller.sendMessage(runningShort, task.telegramMessageId);
234
- if (!runningMsgId) {
235
- runningMsgId = await poller.sendMessage(runningFull);
236
- }
237
- } else {
238
- runningMsgId = await poller.sendMessage(runningFull);
239
- }
240
-
241
- task.runningMessageId = runningMsgId;
242
- try {
243
- const started = runner.run(workDir, task);
244
- queue.markStarted(workDir, started.pid);
245
- } catch (err) {
246
- logger.error(`Failed to start task: ${err.message}`);
247
- poller.sendMessage(`❌ <code>${label}</code>\nFailed to start: ${escapeHtml(err.message)}`);
248
- queue.onTaskComplete(workDir, `START_ERROR: ${err.message}`);
249
- }
250
- }
251
-
252
- function formatDuration (ms) {
253
- const sec = Math.floor(ms / 1000);
254
- if (sec < 60) {
255
- return `${sec}s`;
256
- }
257
- return `${Math.floor(sec / 60)}m ${sec % 60}s`;
258
- }
259
-
260
- // ----------------------
261
- // COMMAND HANDLERS
262
- // ----------------------
263
-
264
- async function handleCommand (cmd, args) {
265
- switch (cmd) {
266
- case '/status':
267
- return handleStatus(args);
268
- case '/queue':
269
- return handleQueue();
270
- case '/cancel':
271
- return handleCancel(args);
272
- case '/drop':
273
- return handleDrop(args);
274
- case '/clear':
275
- return handleClear(args);
276
- case '/projects':
277
- return handleProjects();
278
- case '/worktrees':
279
- return handleWorktrees(args);
280
- case '/worktree':
281
- return handleCreateWorktree(args);
282
- case '/rmworktree':
283
- return handleRemoveWorktree(args);
284
- case '/history':
285
- return handleHistory();
286
- case '/stop':
287
- return handleStop();
288
- case '/help':
289
- return handleHelp();
290
- default:
291
- return `Unknown command: ${cmd}`;
292
- }
293
- }
294
-
295
- function handleStatus (args) {
296
- const target = parseTarget(args);
297
-
298
- if (target) {
299
- const statuses = queue.getProjectStatus(target.project);
300
- if (statuses.length === 0) {
301
- return `📊 Project "${target.project}": no active queues`;
302
- }
303
- let text = `📊 Project "<b>${escapeHtml(target.project)}</b>":\n`;
304
- for (const s of statuses) {
305
- const branchLabel = s.branch || 'main';
306
- if (s.active) {
307
- const elapsed = s.active.startedAt
308
- ? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
309
- : '?';
310
- text += `\n<b>${escapeHtml(branchLabel)}</b>:\n`;
311
- text += ` ▶ ${escapeHtml(s.active.text)} (${elapsed})\n`;
312
- text += ` Queue: ${s.queueLength} tasks\n`;
313
- } else {
314
- text += `\n<b>${escapeHtml(branchLabel)}</b>: ✅ idle\n`;
315
- text += ` Queue: ${s.queueLength} tasks\n`;
316
- }
317
- }
318
- return text;
319
- }
320
-
321
- // All projects
322
- const all = queue.getAllStatus();
323
- if (Object.keys(all).length === 0) {
324
- const uptime = formatDuration(Date.now() - startTime);
325
- return `📊 No active tasks\nUptime: ${uptime}`;
326
- }
327
-
328
- let text = '📊 <b>Status:</b>\n';
329
- const uptime = formatDuration(Date.now() - startTime);
330
- text += `Uptime: ${uptime}\n`;
331
- for (const [project, statuses] of Object.entries(all)) {
332
- text += `\n<b>${escapeHtml(project)}</b>:`;
333
- for (const s of statuses) {
334
- const branchLabel = s.branch || 'main';
335
- if (s.active) {
336
- const elapsed = s.active.startedAt
337
- ? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
338
- : '?';
339
- text += `\n ${escapeHtml(branchLabel)}: ▶ ${escapeHtml(s.active.text)} (${elapsed})`;
340
- if (s.queueLength > 0) {
341
- text += ` +${s.queueLength} queued`;
342
- }
343
- } else {
344
- text += `\n ${escapeHtml(branchLabel)}: ✅ idle`;
345
- }
346
- }
347
- }
348
- return text;
349
- }
350
-
351
- function handleQueue () {
352
- const all = queue.getAllStatus();
353
- if (Object.keys(all).length === 0) {
354
- return '📋 All queues are empty';
355
- }
356
-
357
- let text = '📋 <b>Queues:</b>\n';
358
- for (const [project, statuses] of Object.entries(all)) {
359
- for (const s of statuses) {
360
- const label = s.branch && s.branch !== 'main' && s.branch !== 'master'
361
- ? `/${project}/${s.branch}`
362
- : `/${project}`;
363
- if (s.active || s.queueLength > 0) {
364
- text += `\n<b>${escapeHtml(label)}</b>:`;
365
- if (s.active) {
366
- text += `\n ▶ ${escapeHtml(s.active.text)}`;
367
- }
368
- const entry = queue.queues[Object.keys(queue.queues).find(
369
- (wd) => queue.queues[wd].project === project && queue.queues[wd].branch === s.branch
370
- )];
371
- if (entry?.queue) {
372
- for (let i = 0; i < entry.queue.length; i++) {
373
- text += `\n ${i + 1}. ${escapeHtml(entry.queue[i].text)}`;
374
- }
375
- }
376
- }
377
- }
378
- }
379
- return text;
380
- }
381
-
382
- async function handleCancel (args) {
383
- const target = parseTarget(args);
384
- const projectAlias = target?.project || 'default';
385
- const branch = target?.branch || null;
386
-
387
- let workDir;
388
- try {
389
- workDir = worktreeManager.resolveWorkDir(projectAlias, branch);
390
- } catch (err) {
391
- return `❌ ${escapeHtml(err.message)}`;
392
- }
393
-
394
- if (!runner.isRunning(workDir)) {
395
- return `❌ No active task in /${escapeHtml(projectAlias)}${branch ? '/' + escapeHtml(branch) : ''}`;
396
- }
397
-
398
- runner.cancel(workDir);
399
- const next = queue.cancelActive(workDir);
400
- const label = branch ? `/${projectAlias}/${branch}` : `/${projectAlias}`;
401
-
402
- if (next) {
403
- startTask(workDir, next);
404
- return `🛑 [${escapeHtml(label)}] Task cancelled. Starting next.`;
405
- }
406
- return `🛑 [${escapeHtml(label)}] Task cancelled`;
407
- }
408
-
409
- function handleDrop (args) {
410
- const target = parseTarget(args);
411
- if (!target) {
412
- return '❌ Usage: /drop /project N';
413
- }
414
- const index = parseInt(target.rest, 10);
415
- if (!index || index < 1) {
416
- return '❌ Specify task number (starting from 1)';
417
- }
418
-
419
- let workDir;
420
- try {
421
- workDir = worktreeManager.resolveWorkDir(target.project, target.branch);
422
- } catch (err) {
423
- return `❌ ${escapeHtml(err.message)}`;
424
- }
425
-
426
- const removed = queue.removeFromQueue(workDir, index);
427
- if (!removed) {
428
- return `❌ Task #${index} not found in queue`;
429
- }
430
- return `🗑 Removed from queue: ${escapeHtml(removed.text)}`;
431
- }
432
-
433
- function handleClear (args) {
434
- const target = parseTarget(args);
435
- const projectAlias = target?.project || 'default';
436
- const branch = target?.branch || null;
437
-
438
- let workDir;
439
- try {
440
- workDir = worktreeManager.resolveWorkDir(projectAlias, branch);
441
- } catch (err) {
442
- return `❌ ${escapeHtml(err.message)}`;
443
- }
444
-
445
- const count = queue.clearQueue(workDir);
446
- const label = branch ? `/${projectAlias}/${branch}` : `/${projectAlias}`;
447
- return `🧹 [${escapeHtml(label)}] Queue cleared (${count} tasks)`;
448
- }
449
-
450
- function handleProjects () {
451
- const projects = listenerConfig.projects;
452
- let text = '📂 <b>Projects:</b>\n';
453
- for (const [alias, proj] of Object.entries(projects)) {
454
- const projPath = typeof proj === 'string' ? proj : proj.path;
455
- text += `\n<b>/${escapeHtml(alias)}</b> <code>${escapeHtml(projPath)}</code>`;
456
- const worktrees = typeof proj === 'object' ? proj.worktrees : null;
457
- if (worktrees && Object.keys(worktrees).length > 0) {
458
- for (const [branch, wtPath] of Object.entries(worktrees)) {
459
- text += `\n /${escapeHtml(branch)} → <code>${escapeHtml(wtPath)}</code>`;
460
- }
461
- }
462
- }
463
- return text;
464
- }
465
-
466
- function handleWorktrees (args) {
467
- const target = parseTarget(args);
468
- if (!target) {
469
- return '❌ Usage: /worktrees /project';
470
- }
471
-
472
- const result = worktreeManager.listWorktrees(target.project);
473
- if (!result) {
474
- return `❌ Project "${escapeHtml(target.project)}" not found`;
475
- }
476
-
477
- let text = `🌳 Worktrees for "<b>${escapeHtml(target.project)}</b>":\n`;
478
- text += `\n• <b>main</b> → <code>${escapeHtml(result.main)}</code>`;
479
- for (const [branch, wtPath] of Object.entries(result.worktrees)) {
480
- text += `\n• <b>${escapeHtml(branch)}</b> → <code>${escapeHtml(wtPath)}</code>`;
481
- }
482
- return text;
483
- }
484
-
485
- function handleCreateWorktree (args) {
486
- const target = parseTarget(args);
487
- if (!target || !target.branch) {
488
- return '❌ Usage: /worktree /project/branch';
489
- }
490
-
491
- const branch = target.branch;
492
- try {
493
- const wtDir = worktreeManager.createWorktree(target.project, branch);
494
- return `🌿 Created worktree for "<b>${escapeHtml(target.project)}</b>":\n`
495
- + `Branch: <b>${escapeHtml(branch)}</b>\n`
496
- + `Path: <code>${escapeHtml(wtDir)}</code>`;
497
- } catch (err) {
498
- return `❌ ${escapeHtml(err.message)}`;
499
- }
500
- }
501
-
502
- function handleRemoveWorktree (args) {
503
- const target = parseTarget(args);
504
- if (!target || !target.branch) {
505
- return '❌ Usage: /rmworktree /project/branch';
506
- }
507
-
508
- const branch = target.branch;
509
-
510
- // Check if there's an active task in this worktree
511
- let workDir;
512
- try {
513
- const project = listenerConfig.projects[target.project];
514
- workDir = project?.worktrees?.[branch];
515
- } catch {
516
- // ignore
517
- }
518
-
519
- if (workDir && runner.isRunning(workDir)) {
520
- return `❌ Cannot remove worktree: task is running. First /cancel /${escapeHtml(target.project)}/${escapeHtml(branch)}`;
521
- }
522
-
523
- try {
524
- worktreeManager.removeWorktree(target.project, branch);
525
- return `🗑 Worktree <b>${escapeHtml(branch)}</b> removed from "<b>${escapeHtml(target.project)}</b>"`;
526
- } catch (err) {
527
- return `❌ ${escapeHtml(err.message)}`;
528
- }
529
- }
530
-
531
- function handleHistory () {
532
- const history = queue.getHistory(10);
533
- if (history.length === 0) {
534
- return '📜 History is empty';
535
- }
536
- let text = '📜 <b>Recent tasks:</b>\n';
537
- for (const h of history.reverse()) {
538
- const label = h.branch && h.branch !== 'main' && h.branch !== 'master'
539
- ? `/${h.project}/${h.branch}`
540
- : `/${h.project}`;
541
- const status = h.result === 'CANCELLED' ? '🛑' : h.result?.startsWith('ERROR') ? '❌' : '✅';
542
- text += `\n${status} [${escapeHtml(label)}] ${escapeHtml(h.text)}`;
543
- }
544
- return text;
545
- }
546
-
547
- async function handleStop () {
548
- await poller.sendMessage('👋 Listener shutting down...');
549
- runner.cancelAll();
550
- logger.info('Graceful shutdown requested via /stop');
551
- setTimeout(() => process.exit(0), 1000);
552
- return null; // Message already sent
553
- }
554
-
555
- function handleHelp () {
556
- return `<b>📖 Commands:</b>
557
-
558
- /status status of all projects
559
- /status /project project status
560
- /queue all queues
561
- /cancel [/project[/branch]] — cancel task
562
- /drop /project N — remove task from queue
563
- /clear /project[/branch] — clear queue
564
- /projects — list projects
565
- /worktrees /project — project worktrees
566
- /worktree /project/branch — create worktree
567
- /rmworktree /project/branch remove worktree
568
- /history task history
569
- /stop stop listener
570
- /help this help
571
-
572
- <b>Tasks:</b>
573
- <code>/project task</code> main worktree
574
- <code>/project/branch task</code> — worktree
575
- <code>task</code> default project`;
576
- }
577
-
578
- // ----------------------
579
- // TASK HANDLER
580
- // ----------------------
581
-
582
- async function handleTask (parsed, telegramMessageId) {
583
- let workDir;
584
- let autoCreated = false;
585
-
586
- try {
587
- const project = listenerConfig.projects[parsed.project];
588
- if (!project) {
589
- await poller.sendMessage(`❌ Project "<b>${escapeHtml(parsed.project)}</b>" not found. Use /projects to list.`);
590
- return;
591
- }
592
-
593
- // Check if worktree needs auto-creation (for notification)
594
- if (parsed.branch) {
595
- const existing = typeof project === 'object' && project.worktrees?.[parsed.branch];
596
- if (!existing) {
597
- autoCreated = true;
598
- }
599
- }
600
-
601
- workDir = worktreeManager.resolveWorkDir(parsed.project, parsed.branch);
602
- } catch (err) {
603
- await poller.sendMessage(`❌ ${escapeHtml(err.message)}`);
604
- return;
605
- }
606
-
607
- if (autoCreated) {
608
- await poller.sendMessage(`🌿 Created worktree <b>${escapeHtml(parsed.branch)}</b> for "<b>${escapeHtml(parsed.project)}</b>"`);
609
- logger.info(`Auto-created worktree for task: /${parsed.project}/${parsed.branch} → ${workDir}`);
610
- }
611
-
612
- const result = queue.enqueue(
613
- workDir,
614
- parsed.project,
615
- parsed.branch || 'main',
616
- parsed.text,
617
- telegramMessageId,
618
- );
619
-
620
- if (result.error) {
621
- await poller.sendMessage(`❌ ${escapeHtml(result.error)}`);
622
- return;
623
- }
624
-
625
- if (result.immediate) {
626
- startTask(workDir, result.task);
627
- } else {
628
- const entry = queue.queues[workDir];
629
- const label = formatLabel(entry);
630
- await poller.sendMessage(
631
- `📋 [${escapeHtml(label)}] Queued (position ${result.position}).\n`
632
- + `Currently running: ${escapeHtml(result.activeTask.text)}`,
633
- telegramMessageId,
634
- );
635
- }
636
- }
637
-
638
- // ----------------------
639
- // MAIN LOOP
640
- // ----------------------
641
-
642
- let running = true;
643
-
644
- process.on('SIGTERM', () => {
645
- logger.info('Received SIGTERM');
646
- running = false;
647
- runner.cancelAll();
648
- setTimeout(() => process.exit(0), 2000);
649
- });
650
-
651
- process.on('SIGINT', () => {
652
- logger.info('Received SIGINT');
653
- running = false;
654
- runner.cancelAll();
655
- setTimeout(() => process.exit(0), 2000);
656
- });
657
-
658
- async function mainLoop () {
659
- while (running) {
660
- try {
661
- const messages = await poller.getUpdates();
662
- for (const msg of messages) {
663
- const parsed = parseMessage(msg.text);
664
- if (!parsed) {
665
- continue;
666
- }
667
-
668
- if (parsed.type === 'command') {
669
- logger.info(`Command: ${parsed.cmd} ${parsed.args}`);
670
- const response = await handleCommand(parsed.cmd, parsed.args);
671
- if (response) {
672
- await poller.sendMessage(response, msg.messageId);
673
- }
674
- } else if (parsed.type === 'task') {
675
- logger.info(`Task for /${parsed.project}${parsed.branch ? '/' + parsed.branch : ''}: ${parsed.text}`);
676
- await handleTask(parsed, msg.messageId);
677
- }
678
- }
679
- } catch (err) {
680
- logger.error(`Main loop error: ${err.message}`);
681
- // Wait before retrying on error
682
- await new Promise((resolve) => setTimeout(resolve, 5000));
683
- }
684
- }
685
- }
686
-
687
- (async () => {
688
- await poller.flush();
689
- await mainLoop();
690
- })();
1
+ #!/usr/bin/env node
2
+ // noinspection UnnecessaryLocalVariableJS
3
+
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import process from 'process';
7
+ import { createLogger } from './logger.js';
8
+ import { createTaskLogger } from './task-logger.js';
9
+ import { TelegramPoller, escapeHtml } from './telegram-poller.js';
10
+ import { WorkQueue } from './work-queue.js';
11
+ import { TaskRunner } from './task-runner.js';
12
+ import { WorktreeManager } from './worktree-manager.js';
13
+ import { parseMessage, parseTarget } from './message-parser.js';
14
+ import { CLAUDE_DIR, CONFIG_PATH, LISTENER_LOG_FILENAME } from '../bin/constants.js';
15
+
16
+ // ----------------------
17
+ // CONFIG
18
+ // ----------------------
19
+
20
+ const DEFAULT_LOG_DIR = CLAUDE_DIR;
21
+
22
+ function loadConfig () {
23
+ try {
24
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
25
+ return JSON.parse(raw);
26
+ } catch (err) {
27
+ console.error(`Failed to load config from ${CONFIG_PATH}: ${err.message}`);
28
+ process.exit(1);
29
+ }
30
+ }
31
+
32
+ // ----------------------
33
+ // MAIN DAEMON
34
+ // ----------------------
35
+
36
+ const config = loadConfig();
37
+ const listenerLogDir = config.listener?.logDir || DEFAULT_LOG_DIR;
38
+ fs.mkdirSync(listenerLogDir, { recursive: true });
39
+ const logger = createLogger(path.join(listenerLogDir, LISTENER_LOG_FILENAME));
40
+
41
+ // Validate required fields
42
+ const token = process.env.CLAUDE_NOTIFY_TELEGRAM_TOKEN || config.telegramToken || config.telegram?.token;
43
+ const chatId = process.env.CLAUDE_NOTIFY_TELEGRAM_CHAT_ID || config.telegramChatId || config.telegram?.chatId;
44
+
45
+ if (!token || !chatId) {
46
+ logger.error('Missing telegramToken or telegramChatId in config');
47
+ console.error('Missing telegramToken or telegramChatId in config');
48
+ process.exit(1);
49
+ }
50
+
51
+ if (!config.listener?.projects || Object.keys(config.listener.projects).length === 0) {
52
+ logger.error('No projects defined in config.listener.projects');
53
+ console.error('No projects defined in config.listener.projects. Run "claude-notify listener setup" to configure.');
54
+ process.exit(1);
55
+ }
56
+
57
+ // Validate project paths — skip projects with missing/invalid directories
58
+ const validatedProjects = {};
59
+ for (const [alias, proj] of Object.entries(config.listener.projects)) {
60
+ const projPath = typeof proj === 'string' ? proj : proj?.path;
61
+ if (!projPath) {
62
+ logger.warn(`Project "${alias}": no path configured, skipping`);
63
+ console.error(`\u26a0 Project "${alias}": no path configured, skipping`);
64
+ continue;
65
+ }
66
+ try {
67
+ const stat = fs.statSync(projPath);
68
+ if (!stat.isDirectory()) {
69
+ logger.warn(`Project "${alias}": path "${projPath}" is not a directory, skipping`);
70
+ console.error(`\u26a0 Project "${alias}": path "${projPath}" is not a directory, skipping`);
71
+ continue;
72
+ }
73
+ validatedProjects[alias] = proj;
74
+ } catch {
75
+ logger.warn(`Project "${alias}": path "${projPath}" does not exist, skipping`);
76
+ console.error(`\u26a0 Project "${alias}": path "${projPath}" does not exist, skipping`);
77
+ }
78
+ }
79
+
80
+ if (Object.keys(validatedProjects).length === 0) {
81
+ logger.error('No projects with valid paths found in config.listener.projects');
82
+ console.error('No projects with valid paths found. Run "claude-notify listener setup" to configure.');
83
+ process.exit(1);
84
+ }
85
+
86
+ config.listener.projects = validatedProjects;
87
+ const listenerConfig = config.listener;
88
+ const globalClaudeArgs = listenerConfig.claudeArgs || [];
89
+ const continueSessionEnabled = listenerConfig.continueSession !== false; // default: true
90
+ const taskTimeoutMinutes = listenerConfig.taskTimeoutMinutes || 30;
91
+ const taskTimeout = taskTimeoutMinutes * 60_000;
92
+
93
+ const poller = new TelegramPoller(token, chatId, logger);
94
+ const queue = new WorkQueue(
95
+ logger,
96
+ listenerConfig.maxQueuePerWorkDir || 10,
97
+ listenerConfig.maxTotalTasks || 50,
98
+ );
99
+ const taskLogDir = config.listener?.taskLogDir || listenerLogDir;
100
+ fs.mkdirSync(taskLogDir, { recursive: true });
101
+ const taskLogger = createTaskLogger(taskLogDir);
102
+ const runner = new TaskRunner(logger, taskTimeout, taskLogger);
103
+ const worktreeManager = new WorktreeManager(config, logger);
104
+
105
+ const startTime = Date.now();
106
+
107
+ // Session tracking per workDir: { taskCount, lastSessionId, lastContextPct }
108
+ const sessions = new Map();
109
+ // WorkDirs that should start a fresh session on next task
110
+ const freshSessionDirs = new Set();
111
+
112
+ logger.info('Listener started');
113
+ logger.info(`Projects: ${JSON.stringify(Object.keys(listenerConfig.projects))}`);
114
+ logger.info(`Session continuity: ${continueSessionEnabled ? 'enabled' : 'disabled'}`);
115
+
116
+ // ----------------------
117
+ // DISCOVER WORKTREES ON START
118
+ // ----------------------
119
+
120
+ for (const alias of Object.keys(listenerConfig.projects)) {
121
+ worktreeManager.discoverWorktrees(alias);
122
+ }
123
+
124
+ // ----------------------
125
+ // WATCHDOG
126
+ // ----------------------
127
+
128
+ const recovered = queue.watchdog(taskTimeout);
129
+ for (const { workDir, next } of recovered) {
130
+ if (next) {
131
+ startTask(workDir, next);
132
+ }
133
+ }
134
+
135
+ // ----------------------
136
+ // TASK RUNNER EVENTS
137
+ // ----------------------
138
+
139
+ runner.on('complete', async (workDir, task, result) => {
140
+ const entry = queue.queues[workDir];
141
+ const label = formatLabel(entry);
142
+
143
+ // Delete the "Running" message
144
+ await poller.deleteMessage(task.runningMessageId);
145
+
146
+ // Update session tracking
147
+ const session = sessions.get(workDir) || { taskCount: 0 };
148
+ session.taskCount++;
149
+ session.lastSessionId = result.sessionId || session.lastSessionId;
150
+ if (result.contextWindow && result.totalTokens) {
151
+ session.lastContextPct = Math.round((result.totalTokens / result.contextWindow) * 100);
152
+ }
153
+ sessions.set(workDir, session);
154
+
155
+ // Build session info line
156
+ const sessionParts = [];
157
+ if (task.continueSession) {
158
+ sessionParts.push(`#${session.taskCount}`);
159
+ }
160
+ if (result.durationMs) {
161
+ sessionParts.push(formatDuration(result.durationMs));
162
+ }
163
+ if (result.numTurns > 1) {
164
+ sessionParts.push(`${result.numTurns} turns`);
165
+ }
166
+ if (session.lastContextPct) {
167
+ sessionParts.push(`ctx ${session.lastContextPct}%`);
168
+ }
169
+ if (result.cost) {
170
+ sessionParts.push(`$${result.cost.toFixed(2)}`);
171
+ }
172
+ const sessionInfo = sessionParts.length > 0 ? ` (${sessionParts.join(', ')})` : '';
173
+ const sessionIcon = task.continueSession ? '🔄' : '🆕';
174
+
175
+ // Build result
176
+ const output = result.text || '';
177
+ const headerShort = `✅ ${sessionIcon} <code>${label}</code>${sessionInfo}`;
178
+ const headerFull = `${headerShort}\n\n${escapeHtml(task.text)}`;
179
+ let body = '';
180
+ if (output) {
181
+ if (output.length > 20000) {
182
+ const head = output.slice(0, 2000);
183
+ const tail = output.slice(-2000);
184
+ body = `\n\n<pre>${escapeHtml(head)}\n\n... (${output.length} chars) ...\n\n${escapeHtml(tail)}</pre>`;
185
+ await poller.sendDocument(
186
+ Buffer.from(output, 'utf-8'),
187
+ `result_${task.id}.txt`,
188
+ `Full output for: ${task.text.slice(0, 100)}`
189
+ );
190
+ } else {
191
+ body = `\n\n<pre>${escapeHtml(output)}</pre>`;
192
+ }
193
+ }
194
+
195
+ // Try reply to original message (short header, task text visible in quote)
196
+ const sentId = await poller.sendMessage(headerShort + body, task.telegramMessageId);
197
+ if (!sentId && task.telegramMessageId) {
198
+ // Reply failed original message was deleted, send without reply but with full task text
199
+ await poller.sendMessage(headerFull + body);
200
+ }
201
+
202
+ // Process next in queue
203
+ const next = queue.onTaskComplete(workDir, output);
204
+ if (next) {
205
+ startTask(workDir, next);
206
+ }
207
+ });
208
+
209
+ runner.on('error', async (workDir, task, errorMsg) => {
210
+ const entry = queue.queues[workDir];
211
+ const label = formatLabel(entry);
212
+
213
+ await poller.deleteMessage(task.runningMessageId);
214
+
215
+ const body = `\n\n<pre>${escapeHtml(errorMsg)}</pre>`;
216
+ const sentId = await poller.sendMessage(`❌ <code>${label}</code>\nError:${body}`, task.telegramMessageId);
217
+ if (!sentId && task.telegramMessageId) {
218
+ await poller.sendMessage(`❌ <code>${label}</code>\nError: ${escapeHtml(task.text)}${body}`);
219
+ }
220
+
221
+ const next = queue.onTaskComplete(workDir, `ERROR: ${errorMsg}`);
222
+ if (next) {
223
+ startTask(workDir, next);
224
+ }
225
+ });
226
+
227
+ runner.on('timeout', async (workDir, task) => {
228
+ const entry = queue.queues[workDir];
229
+ const label = formatLabel(entry);
230
+ const timeoutMin = Math.round(taskTimeout / 60000);
231
+
232
+ await poller.deleteMessage(task.runningMessageId);
233
+
234
+ const headerShort = `⏰ <code>${label}</code>\nTask forcefully stopped — timeout exceeded (${timeoutMin} min)`;
235
+ const headerFull = `${headerShort}: ${escapeHtml(task.text)}`;
236
+ const sentId = await poller.sendMessage(headerShort, task.telegramMessageId);
237
+ if (!sentId && task.telegramMessageId) {
238
+ await poller.sendMessage(headerFull);
239
+ }
240
+
241
+ const next = queue.onTaskComplete(workDir, 'TIMEOUT');
242
+ if (next) {
243
+ startTask(workDir, next);
244
+ }
245
+ });
246
+
247
+ // ----------------------
248
+ // HELPERS
249
+ // ----------------------
250
+
251
+ function formatLabel (entry) {
252
+ if (!entry) {
253
+ return 'unknown';
254
+ }
255
+ if (entry.branch && entry.branch !== 'main' && entry.branch !== 'master') {
256
+ return `/${entry.project}/${entry.branch}`;
257
+ }
258
+ return `/${entry.project}`;
259
+ }
260
+
261
+ function getClaudeArgs (projectAlias) {
262
+ const project = listenerConfig.projects[projectAlias];
263
+ const projectArgs = (typeof project === 'object' && project.claudeArgs) || [];
264
+ // Project-level args override global args
265
+ return projectArgs.length > 0 ? projectArgs : globalClaudeArgs;
266
+ }
267
+
268
+ function shouldContinueSession (workDir) {
269
+ if (!continueSessionEnabled) {
270
+ return false;
271
+ }
272
+ if (freshSessionDirs.has(workDir)) {
273
+ freshSessionDirs.delete(workDir);
274
+ return false;
275
+ }
276
+ return sessions.has(workDir);
277
+ }
278
+
279
+ async function startTask (workDir, task) {
280
+ const entry = queue.queues[workDir];
281
+ const label = formatLabel(entry);
282
+ const continueSession = shouldContinueSession(workDir);
283
+ const session = sessions.get(workDir);
284
+
285
+ // Build running message with session info
286
+ let sessionTag = '';
287
+ if (continueSession && session) {
288
+ const ctxPart = session.lastContextPct ? `, ctx ${session.lastContextPct}%` : '';
289
+ sessionTag = ` 🔄 #${session.taskCount + 1}${ctxPart}`;
290
+ } else {
291
+ sessionTag = ' 🆕';
292
+ }
293
+
294
+ const runningShort = `⏳ <code>${label}</code>${sessionTag}\nRunning...`;
295
+ const runningFull = `⏳ <code>${label}</code>${sessionTag}\nRunning: ${escapeHtml(task.text)}`;
296
+ let runningMsgId = null;
297
+
298
+ if (task.telegramMessageId) {
299
+ // In replies, the quoted user message already contains task text.
300
+ runningMsgId = await poller.sendMessage(runningShort, task.telegramMessageId);
301
+ if (!runningMsgId) {
302
+ runningMsgId = await poller.sendMessage(runningFull);
303
+ }
304
+ } else {
305
+ runningMsgId = await poller.sendMessage(runningFull);
306
+ }
307
+
308
+ task.runningMessageId = runningMsgId;
309
+ const claudeArgs = getClaudeArgs(entry?.project);
310
+ try {
311
+ const started = runner.run(workDir, task, claudeArgs, continueSession);
312
+ queue.markStarted(workDir, started.pid);
313
+ } catch (err) {
314
+ logger.error(`Failed to start task: ${err.message}`);
315
+ poller.sendMessage(`❌ <code>${label}</code>\nFailed to start: ${escapeHtml(err.message)}`);
316
+ queue.onTaskComplete(workDir, `START_ERROR: ${err.message}`);
317
+ }
318
+ }
319
+
320
+ function formatDuration (ms) {
321
+ const sec = Math.floor(ms / 1000);
322
+ if (sec < 60) {
323
+ return `${sec}s`;
324
+ }
325
+ return `${Math.floor(sec / 60)}m ${sec % 60}s`;
326
+ }
327
+
328
+ // ----------------------
329
+ // COMMAND HANDLERS
330
+ // ----------------------
331
+
332
+ async function handleCommand (cmd, args) {
333
+ switch (cmd) {
334
+ case '/status':
335
+ return handleStatus(args);
336
+ case '/queue':
337
+ return handleQueue();
338
+ case '/cancel':
339
+ return handleCancel(args);
340
+ case '/drop':
341
+ return handleDrop(args);
342
+ case '/clear':
343
+ return handleClear(args);
344
+ case '/newsession':
345
+ return handleNewSession(args);
346
+ case '/projects':
347
+ return handleProjects();
348
+ case '/worktrees':
349
+ return handleWorktrees(args);
350
+ case '/worktree':
351
+ return handleCreateWorktree(args);
352
+ case '/rmworktree':
353
+ return handleRemoveWorktree(args);
354
+ case '/history':
355
+ return handleHistory();
356
+ case '/stop':
357
+ return handleStop();
358
+ case '/help':
359
+ return handleHelp();
360
+ default:
361
+ return `Unknown command: ${cmd}`;
362
+ }
363
+ }
364
+
365
+ function handleStatus (args) {
366
+ const target = parseTarget(args);
367
+
368
+ if (target) {
369
+ const statuses = queue.getProjectStatus(target.project);
370
+ if (statuses.length === 0) {
371
+ return `📊 Project "${target.project}": no active queues`;
372
+ }
373
+ let text = `📊 Project "<b>${escapeHtml(target.project)}</b>":\n`;
374
+ for (const s of statuses) {
375
+ const branchLabel = s.branch || 'main';
376
+ if (s.active) {
377
+ const elapsed = s.active.startedAt
378
+ ? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
379
+ : '?';
380
+ text += `\n<b>${escapeHtml(branchLabel)}</b>:\n`;
381
+ text += ` ▶ ${escapeHtml(s.active.text)} (${elapsed})\n`;
382
+ text += ` Queue: ${s.queueLength} tasks\n`;
383
+ } else {
384
+ text += `\n<b>${escapeHtml(branchLabel)}</b>: idle\n`;
385
+ text += ` Queue: ${s.queueLength} tasks\n`;
386
+ }
387
+ }
388
+ return text;
389
+ }
390
+
391
+ // All projects
392
+ const all = queue.getAllStatus();
393
+ if (Object.keys(all).length === 0) {
394
+ const uptime = formatDuration(Date.now() - startTime);
395
+ return `📊 No active tasks\nUptime: ${uptime}`;
396
+ }
397
+
398
+ let text = '📊 <b>Status:</b>\n';
399
+ const uptime = formatDuration(Date.now() - startTime);
400
+ text += `Uptime: ${uptime}\n`;
401
+ for (const [project, statuses] of Object.entries(all)) {
402
+ text += `\n<b>${escapeHtml(project)}</b>:`;
403
+ for (const s of statuses) {
404
+ const branchLabel = s.branch || 'main';
405
+ if (s.active) {
406
+ const elapsed = s.active.startedAt
407
+ ? formatDuration(Date.now() - new Date(s.active.startedAt).getTime())
408
+ : '?';
409
+ text += `\n ${escapeHtml(branchLabel)}: ▶ ${escapeHtml(s.active.text)} (${elapsed})`;
410
+ if (s.queueLength > 0) {
411
+ text += ` +${s.queueLength} queued`;
412
+ }
413
+ } else {
414
+ text += `\n ${escapeHtml(branchLabel)}: ✅ idle`;
415
+ }
416
+ }
417
+ }
418
+ return text;
419
+ }
420
+
421
+ function handleQueue () {
422
+ const all = queue.getAllStatus();
423
+ if (Object.keys(all).length === 0) {
424
+ return '📋 All queues are empty';
425
+ }
426
+
427
+ let text = '📋 <b>Queues:</b>\n';
428
+ for (const [project, statuses] of Object.entries(all)) {
429
+ for (const s of statuses) {
430
+ const label = s.branch && s.branch !== 'main' && s.branch !== 'master'
431
+ ? `/${project}/${s.branch}`
432
+ : `/${project}`;
433
+ if (s.active || s.queueLength > 0) {
434
+ text += `\n<b>${escapeHtml(label)}</b>:`;
435
+ if (s.active) {
436
+ text += `\n ▶ ${escapeHtml(s.active.text)}`;
437
+ }
438
+ const entry = queue.queues[Object.keys(queue.queues).find(
439
+ (wd) => queue.queues[wd].project === project && queue.queues[wd].branch === s.branch
440
+ )];
441
+ if (entry?.queue) {
442
+ for (let i = 0; i < entry.queue.length; i++) {
443
+ text += `\n ${i + 1}. ${escapeHtml(entry.queue[i].text)}`;
444
+ }
445
+ }
446
+ }
447
+ }
448
+ }
449
+ return text;
450
+ }
451
+
452
+ async function handleCancel (args) {
453
+ const target = parseTarget(args);
454
+ const projectAlias = target?.project || 'default';
455
+ const branch = target?.branch || null;
456
+
457
+ let workDir;
458
+ try {
459
+ workDir = worktreeManager.resolveWorkDir(projectAlias, branch);
460
+ } catch (err) {
461
+ return `❌ ${escapeHtml(err.message)}`;
462
+ }
463
+
464
+ if (!runner.isRunning(workDir)) {
465
+ return `❌ No active task in /${escapeHtml(projectAlias)}${branch ? '/' + escapeHtml(branch) : ''}`;
466
+ }
467
+
468
+ runner.cancel(workDir);
469
+ const next = queue.cancelActive(workDir);
470
+ const label = branch ? `/${projectAlias}/${branch}` : `/${projectAlias}`;
471
+
472
+ if (next) {
473
+ startTask(workDir, next);
474
+ return `🛑 [${escapeHtml(label)}] Task cancelled. Starting next.`;
475
+ }
476
+ return `🛑 [${escapeHtml(label)}] Task cancelled`;
477
+ }
478
+
479
+ function handleDrop (args) {
480
+ const target = parseTarget(args);
481
+ if (!target) {
482
+ return '❌ Usage: /drop /project N';
483
+ }
484
+ const index = parseInt(target.rest, 10);
485
+ if (!index || index < 1) {
486
+ return '❌ Specify task number (starting from 1)';
487
+ }
488
+
489
+ let workDir;
490
+ try {
491
+ workDir = worktreeManager.resolveWorkDir(target.project, target.branch);
492
+ } catch (err) {
493
+ return `❌ ${escapeHtml(err.message)}`;
494
+ }
495
+
496
+ const removed = queue.removeFromQueue(workDir, index);
497
+ if (!removed) {
498
+ return `❌ Task #${index} not found in queue`;
499
+ }
500
+ return `🗑 Removed from queue: ${escapeHtml(removed.text)}`;
501
+ }
502
+
503
+ function handleClear (args) {
504
+ const target = parseTarget(args);
505
+ const projectAlias = target?.project || 'default';
506
+ const branch = target?.branch || null;
507
+
508
+ let workDir;
509
+ try {
510
+ workDir = worktreeManager.resolveWorkDir(projectAlias, branch);
511
+ } catch (err) {
512
+ return `❌ ${escapeHtml(err.message)}`;
513
+ }
514
+
515
+ const count = queue.clearQueue(workDir);
516
+ const label = branch ? `/${projectAlias}/${branch}` : `/${projectAlias}`;
517
+
518
+ // Also reset session
519
+ sessions.delete(workDir);
520
+ freshSessionDirs.add(workDir);
521
+ logger.info(`Session reset for ${workDir} via /clear`);
522
+
523
+ return `🧹 [${escapeHtml(label)}] Queue cleared (${count} tasks), session reset`;
524
+ }
525
+
526
+ function handleNewSession (args) {
527
+ const target = parseTarget(args);
528
+ const projectAlias = target?.project || 'default';
529
+ const branch = target?.branch || null;
530
+
531
+ let workDir;
532
+ try {
533
+ workDir = worktreeManager.resolveWorkDir(projectAlias, branch);
534
+ } catch (err) {
535
+ return `❌ ${escapeHtml(err.message)}`;
536
+ }
537
+
538
+ const label = branch ? `/${projectAlias}/${branch}` : `/${projectAlias}`;
539
+ const session = sessions.get(workDir);
540
+
541
+ sessions.delete(workDir);
542
+ freshSessionDirs.add(workDir);
543
+ logger.info(`Session reset for ${workDir} via /newsession`);
544
+
545
+ if (session) {
546
+ return `🆕 [${escapeHtml(label)}] Session reset (was #${session.taskCount} tasks, ctx ${session.lastContextPct || '?'}%). Next task starts fresh.`;
547
+ }
548
+ return `🆕 [${escapeHtml(label)}] Next task will start a new session.`;
549
+ }
550
+
551
+ function handleProjects () {
552
+ const projects = listenerConfig.projects;
553
+ let text = '📂 <b>Projects:</b>\n';
554
+ for (const [alias, proj] of Object.entries(projects)) {
555
+ const projPath = typeof proj === 'string' ? proj : proj.path;
556
+ text += `\n<b>/${escapeHtml(alias)}</b> → <code>${escapeHtml(projPath)}</code>`;
557
+ const worktrees = typeof proj === 'object' ? proj.worktrees : null;
558
+ if (worktrees && Object.keys(worktrees).length > 0) {
559
+ for (const [branch, wtPath] of Object.entries(worktrees)) {
560
+ text += `\n /${escapeHtml(branch)} → <code>${escapeHtml(wtPath)}</code>`;
561
+ }
562
+ }
563
+ }
564
+ return text;
565
+ }
566
+
567
+ function handleWorktrees (args) {
568
+ const target = parseTarget(args);
569
+ if (!target) {
570
+ return '❌ Usage: /worktrees /project';
571
+ }
572
+
573
+ const result = worktreeManager.listWorktrees(target.project);
574
+ if (!result) {
575
+ return `❌ Project "${escapeHtml(target.project)}" not found`;
576
+ }
577
+
578
+ let text = `🌳 Worktrees for "<b>${escapeHtml(target.project)}</b>":\n`;
579
+ text += `\n• <b>main</b> → <code>${escapeHtml(result.main)}</code>`;
580
+ for (const [branch, wtPath] of Object.entries(result.worktrees)) {
581
+ text += `\n• <b>${escapeHtml(branch)}</b> → <code>${escapeHtml(wtPath)}</code>`;
582
+ }
583
+ return text;
584
+ }
585
+
586
+ function handleCreateWorktree (args) {
587
+ const target = parseTarget(args);
588
+ if (!target || !target.branch) {
589
+ return '❌ Usage: /worktree /project/branch';
590
+ }
591
+
592
+ const branch = target.branch;
593
+ try {
594
+ const wtDir = worktreeManager.createWorktree(target.project, branch);
595
+ return `🌿 Created worktree for "<b>${escapeHtml(target.project)}</b>":\n`
596
+ + `Branch: <b>${escapeHtml(branch)}</b>\n`
597
+ + `Path: <code>${escapeHtml(wtDir)}</code>`;
598
+ } catch (err) {
599
+ return `❌ ${escapeHtml(err.message)}`;
600
+ }
601
+ }
602
+
603
+ function handleRemoveWorktree (args) {
604
+ const target = parseTarget(args);
605
+ if (!target || !target.branch) {
606
+ return '❌ Usage: /rmworktree /project/branch';
607
+ }
608
+
609
+ const branch = target.branch;
610
+
611
+ // Check if there's an active task in this worktree
612
+ let workDir;
613
+ try {
614
+ const project = listenerConfig.projects[target.project];
615
+ workDir = project?.worktrees?.[branch];
616
+ } catch {
617
+ // ignore
618
+ }
619
+
620
+ if (workDir && runner.isRunning(workDir)) {
621
+ return `❌ Cannot remove worktree: task is running. First /cancel /${escapeHtml(target.project)}/${escapeHtml(branch)}`;
622
+ }
623
+
624
+ try {
625
+ worktreeManager.removeWorktree(target.project, branch);
626
+ return `🗑 Worktree <b>${escapeHtml(branch)}</b> removed from "<b>${escapeHtml(target.project)}</b>"`;
627
+ } catch (err) {
628
+ return `❌ ${escapeHtml(err.message)}`;
629
+ }
630
+ }
631
+
632
+ function handleHistory () {
633
+ const history = queue.getHistory(10);
634
+ if (history.length === 0) {
635
+ return '📜 History is empty';
636
+ }
637
+ let text = '📜 <b>Recent tasks:</b>\n';
638
+ for (const h of history.reverse()) {
639
+ const label = h.branch && h.branch !== 'main' && h.branch !== 'master'
640
+ ? `/${h.project}/${h.branch}`
641
+ : `/${h.project}`;
642
+ const status = h.result === 'CANCELLED' ? '🛑' : h.result?.startsWith('ERROR') ? '❌' : '✅';
643
+ text += `\n${status} [${escapeHtml(label)}] ${escapeHtml(h.text)}`;
644
+ }
645
+ return text;
646
+ }
647
+
648
+ async function handleStop () {
649
+ await poller.sendMessage('👋 Listener shutting down...');
650
+ runner.cancelAll();
651
+ logger.info('Graceful shutdown requested via /stop');
652
+ setTimeout(() => process.exit(0), 1000);
653
+ return null; // Message already sent
654
+ }
655
+
656
+ function handleHelp () {
657
+ return `<b>📖 Commands:</b>
658
+
659
+ /status status of all projects
660
+ /status /project — project status
661
+ /queue all queues
662
+ /cancel [/project[/branch]] cancel task
663
+ /drop /project N — remove task from queue
664
+ /clear /project[/branch] — clear queue + reset session
665
+ /newsession [/project[/branch]] — reset session (keep queue)
666
+ /projects — list projects
667
+ /worktrees /project — project worktrees
668
+ /worktree /project/branch create worktree
669
+ /rmworktree /project/branch — remove worktree
670
+ /history task history
671
+ /stop stop listener
672
+ /help this help
673
+
674
+ <b>Tasks:</b>
675
+ <code>/project task</code> main worktree
676
+ <code>/project/branch task</code> — worktree
677
+ <code>task</code> — default project
678
+
679
+ <b>Session:</b>
680
+ 🆕 = new session, 🔄 = continuing session
681
+ ctx N% = context window usage`;
682
+ }
683
+
684
+ // ----------------------
685
+ // TASK HANDLER
686
+ // ----------------------
687
+
688
+ async function handleTask (parsed, telegramMessageId) {
689
+ let workDir;
690
+ let autoCreated = false;
691
+
692
+ try {
693
+ const project = listenerConfig.projects[parsed.project];
694
+ if (!project) {
695
+ await poller.sendMessage(`❌ Project "<b>${escapeHtml(parsed.project)}</b>" not found. Use /projects to list.`);
696
+ return;
697
+ }
698
+
699
+ // Check if worktree needs auto-creation (for notification)
700
+ if (parsed.branch) {
701
+ const existing = typeof project === 'object' && project.worktrees?.[parsed.branch];
702
+ if (!existing) {
703
+ autoCreated = true;
704
+ }
705
+ }
706
+
707
+ workDir = worktreeManager.resolveWorkDir(parsed.project, parsed.branch);
708
+ } catch (err) {
709
+ await poller.sendMessage(`❌ ${escapeHtml(err.message)}`);
710
+ return;
711
+ }
712
+
713
+ if (autoCreated) {
714
+ await poller.sendMessage(`🌿 Created worktree <b>${escapeHtml(parsed.branch)}</b> for "<b>${escapeHtml(parsed.project)}</b>"`);
715
+ logger.info(`Auto-created worktree for task: /${parsed.project}/${parsed.branch} → ${workDir}`);
716
+ }
717
+
718
+ const result = queue.enqueue(
719
+ workDir,
720
+ parsed.project,
721
+ parsed.branch || 'main',
722
+ parsed.text,
723
+ telegramMessageId,
724
+ );
725
+
726
+ if (result.error) {
727
+ await poller.sendMessage(`❌ ${escapeHtml(result.error)}`);
728
+ return;
729
+ }
730
+
731
+ if (result.immediate) {
732
+ startTask(workDir, result.task);
733
+ } else {
734
+ const entry = queue.queues[workDir];
735
+ const label = formatLabel(entry);
736
+ await poller.sendMessage(
737
+ `📋 [${escapeHtml(label)}] Queued (position ${result.position}).\n`
738
+ + `Currently running: ${escapeHtml(result.activeTask.text)}`,
739
+ telegramMessageId,
740
+ );
741
+ }
742
+ }
743
+
744
+ // ----------------------
745
+ // MAIN LOOP
746
+ // ----------------------
747
+
748
+ let running = true;
749
+
750
+ process.on('SIGTERM', () => {
751
+ logger.info('Received SIGTERM');
752
+ running = false;
753
+ runner.cancelAll();
754
+ setTimeout(() => process.exit(0), 2000);
755
+ });
756
+
757
+ process.on('SIGINT', () => {
758
+ logger.info('Received SIGINT');
759
+ running = false;
760
+ runner.cancelAll();
761
+ setTimeout(() => process.exit(0), 2000);
762
+ });
763
+
764
+ async function mainLoop () {
765
+ while (running) {
766
+ try {
767
+ const messages = await poller.getUpdates();
768
+ for (const msg of messages) {
769
+ const parsed = parseMessage(msg.text);
770
+ if (!parsed) {
771
+ continue;
772
+ }
773
+
774
+ if (parsed.type === 'command') {
775
+ logger.info(`Command: ${parsed.cmd} ${parsed.args}`);
776
+ const response = await handleCommand(parsed.cmd, parsed.args);
777
+ if (response) {
778
+ await poller.sendMessage(response, msg.messageId);
779
+ }
780
+ } else if (parsed.type === 'task') {
781
+ logger.info(`Task for /${parsed.project}${parsed.branch ? '/' + parsed.branch : ''}: ${parsed.text}`);
782
+ await handleTask(parsed, msg.messageId);
783
+ }
784
+ }
785
+ } catch (err) {
786
+ logger.error(`Main loop error: ${err.message}`);
787
+ // Wait before retrying on error
788
+ await new Promise((resolve) => setTimeout(resolve, 5000));
789
+ }
790
+ }
791
+ }
792
+
793
+ (async () => {
794
+ await poller.flush();
795
+ await mainLoop();
796
+ })();