claude-notification-plugin 1.1.20 → 1.1.26
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/.claude-plugin/plugin.json +1 -1
- package/README.md +26 -11
- package/bin/install.js +1 -0
- package/bin/listener-cli.js +31 -0
- package/commit-sha +1 -1
- package/listener/LISTENER-DETAILED.md +74 -7
- package/listener/listener.js +796 -690
- package/listener/message-parser.js +1 -1
- package/listener/task-runner.js +195 -159
- package/package.json +1 -1
package/listener/listener.js
CHANGED
|
@@ -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
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// ----------------------
|
|
117
|
-
//
|
|
118
|
-
// ----------------------
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
return
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
return
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
return `❌
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
if (
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
+
})();
|