@masslessai/push-todo 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/daemon.js ADDED
@@ -0,0 +1,1369 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Push Task Execution Daemon
4
+ *
5
+ * Polls Supabase for queued tasks and executes them via Claude Code.
6
+ * Auto-heals (starts) on any /push-todo command via daemon-health.js.
7
+ *
8
+ * Architecture:
9
+ * - Git branch = worktree = Claude session (1:1:1 mapping)
10
+ * - Uses Claude's --continue to resume sessions in worktrees
11
+ * - Certainty analysis determines execution mode (immediate, planning, or clarify)
12
+ *
13
+ * Certainty-Based Execution:
14
+ * - High certainty (>= 0.7): Execute immediately in standard mode
15
+ * - Medium certainty (0.4-0.7): Execute with --plan flag (planning mode first)
16
+ * - Low certainty (< 0.4): Update todo with clarification questions, skip execution
17
+ *
18
+ * Ported from: plugins/push-todo/scripts/daemon.py
19
+ */
20
+
21
+ import { spawn, execSync, execFileSync } from 'child_process';
22
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, unlinkSync, statSync, renameSync } from 'fs';
23
+ import { homedir, hostname, platform } from 'os';
24
+ import { join, dirname } from 'path';
25
+ import { fileURLToPath } from 'url';
26
+
27
+ const __filename = fileURLToPath(import.meta.url);
28
+ const __dirname = dirname(__filename);
29
+
30
+ // ==================== Configuration ====================
31
+
32
+ const API_BASE_URL = 'https://jxuzqcbqhiaxmfitzxlo.supabase.co/functions/v1';
33
+ const POLL_INTERVAL = 30000; // 30 seconds
34
+ const MAX_CONCURRENT_TASKS = 5;
35
+ const TASK_TIMEOUT_MS = 3600000; // 1 hour
36
+
37
+ // Retry configuration
38
+ const RETRY_MAX_ATTEMPTS = 3;
39
+ const RETRY_INITIAL_DELAY = 2000;
40
+ const RETRY_MAX_DELAY = 30000;
41
+ const RETRY_BACKOFF_FACTOR = 2;
42
+
43
+ // Certainty thresholds
44
+ const CERTAINTY_HIGH_THRESHOLD = 0.7;
45
+ const CERTAINTY_LOW_THRESHOLD = 0.4;
46
+
47
+ // Stuck detection
48
+ const STUCK_IDLE_THRESHOLD = 600000; // 10 min
49
+ const STUCK_WARNING_THRESHOLD = 300000; // 5 min
50
+
51
+ // Stuck patterns that indicate Claude is waiting for input
52
+ const STUCK_PATTERNS = [
53
+ 'waiting for permission',
54
+ 'approve this action',
55
+ 'permission required',
56
+ 'plan ready for approval',
57
+ 'waiting for user',
58
+ 'enter plan mode',
59
+ 'press enter to continue',
60
+ 'y/n',
61
+ '[Y/n]',
62
+ 'confirm:'
63
+ ];
64
+
65
+ // Retryable error patterns
66
+ const RETRYABLE_ERRORS = [
67
+ 'timeout', 'connection refused', 'connection reset',
68
+ 'network is unreachable', 'temporary failure', 'rate limit',
69
+ '429', '502', '503', '504'
70
+ ];
71
+
72
+ // Notification settings
73
+ const NOTIFY_ON_COMPLETE = true;
74
+ const NOTIFY_ON_FAILURE = true;
75
+ const NOTIFY_ON_NEEDS_INPUT = true;
76
+
77
+ // Paths
78
+ const PUSH_DIR = join(homedir(), '.push');
79
+ const CONFIG_DIR = join(homedir(), '.config', 'push');
80
+ const PID_FILE = join(PUSH_DIR, 'daemon.pid');
81
+ const LOG_FILE = join(PUSH_DIR, 'daemon.log');
82
+ const STATUS_FILE = join(PUSH_DIR, 'daemon_status.json');
83
+ const VERSION_FILE = join(PUSH_DIR, 'daemon.version');
84
+ const CONFIG_FILE = join(CONFIG_DIR, 'config');
85
+ const MACHINE_ID_FILE = join(CONFIG_DIR, 'machine_id');
86
+ const REGISTRY_FILE = join(CONFIG_DIR, 'projects.json');
87
+
88
+ // Log rotation settings
89
+ const LOG_MAX_SIZE = 10 * 1024 * 1024; // 10 MB
90
+ const LOG_BACKUP_COUNT = 3;
91
+
92
+ // State
93
+ const runningTasks = new Map(); // displayNumber -> taskInfo
94
+ const taskDetails = new Map(); // displayNumber -> details
95
+ const completedToday = [];
96
+ const taskLastOutput = new Map(); // displayNumber -> timestamp
97
+ const taskStdoutBuffer = new Map(); // displayNumber -> lines[]
98
+ const taskProjectPaths = new Map(); // displayNumber -> projectPath
99
+ let daemonStartTime = null;
100
+
101
+ // ==================== Logging ====================
102
+
103
+ function rotateLogs() {
104
+ try {
105
+ if (!existsSync(LOG_FILE)) return;
106
+
107
+ const stats = statSync(LOG_FILE);
108
+ if (stats.size < LOG_MAX_SIZE) return;
109
+
110
+ // Rotate existing backups
111
+ for (let i = LOG_BACKUP_COUNT; i > 0; i--) {
112
+ const oldBackup = `${LOG_FILE}.${i}`;
113
+ const newBackup = `${LOG_FILE}.${i + 1}`;
114
+
115
+ if (i === LOG_BACKUP_COUNT && existsSync(oldBackup)) {
116
+ unlinkSync(oldBackup);
117
+ } else if (existsSync(oldBackup)) {
118
+ renameSync(oldBackup, newBackup);
119
+ }
120
+ }
121
+
122
+ // Rotate current log
123
+ renameSync(LOG_FILE, `${LOG_FILE}.1`);
124
+ } catch {
125
+ // Non-critical - continue even if rotation fails
126
+ }
127
+ }
128
+
129
+ function log(message, level = 'INFO') {
130
+ const timestamp = new Date().toISOString();
131
+ const line = `[${timestamp}] [${level}] ${message}\n`;
132
+
133
+ if (process.env.PUSH_DAEMON !== '1') {
134
+ process.stdout.write(line);
135
+ }
136
+
137
+ try {
138
+ appendFileSync(LOG_FILE, line);
139
+ } catch {}
140
+ }
141
+
142
+ function logError(message) {
143
+ log(message, 'ERROR');
144
+ }
145
+
146
+ // ==================== Mac Notifications ====================
147
+
148
+ function sendMacNotification(title, message, sound = 'default') {
149
+ if (platform() !== 'darwin') return;
150
+
151
+ try {
152
+ const escapedTitle = title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
153
+ const escapedMessage = message.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
154
+
155
+ let script = `display notification "${escapedMessage}" with title "${escapedTitle}"`;
156
+ if (sound && sound !== 'default') {
157
+ script += ` sound name "${sound}"`;
158
+ }
159
+
160
+ execSync(`osascript -e '${script}'`, { timeout: 5000, stdio: 'pipe' });
161
+ } catch {
162
+ // Non-critical
163
+ }
164
+ }
165
+
166
+ // ==================== Config ====================
167
+
168
+ function getApiKey() {
169
+ if (process.env.PUSH_API_KEY) {
170
+ return process.env.PUSH_API_KEY;
171
+ }
172
+
173
+ if (existsSync(CONFIG_FILE)) {
174
+ try {
175
+ const content = readFileSync(CONFIG_FILE, 'utf8');
176
+ const match = content.match(/^export\s+PUSH_API_KEY\s*=\s*["']?([^"'\n]+)["']?/m);
177
+ if (match) return match[1];
178
+ } catch {}
179
+ }
180
+
181
+ return null;
182
+ }
183
+
184
+ function getMachineId() {
185
+ if (existsSync(MACHINE_ID_FILE)) {
186
+ try {
187
+ return readFileSync(MACHINE_ID_FILE, 'utf8').trim();
188
+ } catch {}
189
+ }
190
+ return null;
191
+ }
192
+
193
+ function getMachineName() {
194
+ return hostname();
195
+ }
196
+
197
+ function getVersion() {
198
+ try {
199
+ const pkgPath = join(__dirname, '..', 'package.json');
200
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
201
+ return pkg.version || '3.0.0';
202
+ } catch {
203
+ return '3.0.0';
204
+ }
205
+ }
206
+
207
+ // ==================== E2EE Decryption ====================
208
+
209
+ let decryptTodoField = null;
210
+ let e2eeAvailable = false;
211
+
212
+ try {
213
+ const encryption = await import('./encryption.js');
214
+ decryptTodoField = encryption.decryptTodoField;
215
+ const [available] = encryption.isE2EEAvailable();
216
+ e2eeAvailable = available;
217
+ } catch {
218
+ e2eeAvailable = false;
219
+ }
220
+
221
+ function decryptTaskFields(task) {
222
+ if (!e2eeAvailable || !decryptTodoField) {
223
+ return task;
224
+ }
225
+
226
+ const decrypted = { ...task };
227
+ const encryptedFields = [
228
+ 'summary', 'content', 'normalizedContent', 'normalized_content',
229
+ 'originalTranscript', 'original_transcript', 'transcript', 'title'
230
+ ];
231
+
232
+ for (const field of encryptedFields) {
233
+ if (decrypted[field]) {
234
+ decrypted[field] = decryptTodoField(decrypted[field]);
235
+ }
236
+ }
237
+
238
+ return decrypted;
239
+ }
240
+
241
+ // ==================== Certainty Analysis ====================
242
+
243
+ let CertaintyAnalyzer = null;
244
+ let getExecutionMode = null;
245
+
246
+ try {
247
+ const certainty = await import('./certainty.js');
248
+ CertaintyAnalyzer = certainty.CertaintyAnalyzer;
249
+ getExecutionMode = certainty.getExecutionMode;
250
+ } catch {
251
+ // Certainty analysis not available
252
+ }
253
+
254
+ function analyzeTaskCertainty(task) {
255
+ if (!CertaintyAnalyzer) {
256
+ log('Certainty analysis not available, executing directly');
257
+ return null;
258
+ }
259
+
260
+ const content = task.normalizedContent || task.normalized_content ||
261
+ task.content || task.summary || '';
262
+ const summary = task.summary;
263
+ const transcript = task.originalTranscript || task.original_transcript;
264
+
265
+ try {
266
+ const analyzer = new CertaintyAnalyzer();
267
+ const analysis = analyzer.analyze(content, summary, transcript);
268
+
269
+ const displayNum = task.displayNumber || task.display_number;
270
+ log(`Task #${displayNum} certainty: ${analysis.score} (${analysis.level})`);
271
+
272
+ if (analysis.reasons && analysis.reasons.length > 0) {
273
+ for (const reason of analysis.reasons.slice(0, 3)) {
274
+ log(` - ${reason.factor}: ${reason.explanation}`);
275
+ }
276
+ }
277
+
278
+ return analysis;
279
+ } catch (e) {
280
+ log(`Certainty analysis failed: ${e.message}`);
281
+ return null;
282
+ }
283
+ }
284
+
285
+ function determineExecutionMode(analysis) {
286
+ if (!analysis || !getExecutionMode) {
287
+ return 'immediate';
288
+ }
289
+ return getExecutionMode(analysis);
290
+ }
291
+
292
+ // ==================== API ====================
293
+
294
+ function isRetryableError(error) {
295
+ const errorStr = String(error).toLowerCase();
296
+ return RETRYABLE_ERRORS.some(pattern => errorStr.includes(pattern.toLowerCase()));
297
+ }
298
+
299
+ async function apiRequest(endpoint, options = {}, retry = true) {
300
+ const apiKey = getApiKey();
301
+ if (!apiKey) {
302
+ throw new Error('No API key configured');
303
+ }
304
+
305
+ const url = `${API_BASE_URL}/${endpoint}`;
306
+ const maxAttempts = retry ? RETRY_MAX_ATTEMPTS : 1;
307
+ let delay = RETRY_INITIAL_DELAY;
308
+
309
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
310
+ try {
311
+ const controller = new AbortController();
312
+ const timeout = setTimeout(() => controller.abort(), 30000);
313
+
314
+ const response = await fetch(url, {
315
+ ...options,
316
+ signal: controller.signal,
317
+ headers: {
318
+ 'Authorization': `Bearer ${apiKey}`,
319
+ 'Content-Type': 'application/json',
320
+ ...options.headers
321
+ }
322
+ });
323
+
324
+ clearTimeout(timeout);
325
+ return response;
326
+ } catch (error) {
327
+ const isLast = attempt === maxAttempts;
328
+
329
+ if (!isLast && retry && isRetryableError(error)) {
330
+ log(`API request failed (attempt ${attempt}/${maxAttempts}): ${error.message}`);
331
+ log(`Retrying in ${delay}ms...`);
332
+ await new Promise(r => setTimeout(r, delay));
333
+ delay = Math.min(delay * RETRY_BACKOFF_FACTOR, RETRY_MAX_DELAY);
334
+ } else {
335
+ throw error;
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ async function fetchQueuedTasks() {
342
+ try {
343
+ const machineId = getMachineId();
344
+ const params = new URLSearchParams();
345
+ params.set('execution_status', 'queued');
346
+ if (machineId) {
347
+ params.set('machine_id', machineId);
348
+ }
349
+
350
+ const response = await apiRequest(`synced-todos?${params}`);
351
+
352
+ if (!response.ok) {
353
+ if (response.status === 404) return [];
354
+ throw new Error(`HTTP ${response.status}`);
355
+ }
356
+
357
+ const data = await response.json();
358
+ return data.todos || [];
359
+ } catch (error) {
360
+ logError(`Failed to fetch queued tasks: ${error.message}`);
361
+ return [];
362
+ }
363
+ }
364
+
365
+ async function updateTaskStatus(displayNumber, status, extra = {}) {
366
+ try {
367
+ const payload = {
368
+ displayNumber,
369
+ status,
370
+ ...extra
371
+ };
372
+
373
+ const machineId = getMachineId();
374
+ const machineName = getMachineName();
375
+ if (machineId) {
376
+ payload.machineId = machineId;
377
+ payload.machineName = machineName;
378
+ }
379
+
380
+ const response = await apiRequest('update-task-execution', {
381
+ method: 'PATCH',
382
+ body: JSON.stringify(payload)
383
+ });
384
+
385
+ const result = await response.json().catch(() => null);
386
+ return response.ok && result?.success !== false;
387
+ } catch (error) {
388
+ logError(`Failed to update task status: ${error.message}`);
389
+ return false;
390
+ }
391
+ }
392
+
393
+ async function claimTask(displayNumber) {
394
+ const machineId = getMachineId();
395
+ const machineName = getMachineName();
396
+
397
+ if (!machineId) {
398
+ // No machine ID - skip atomic claiming
399
+ return true;
400
+ }
401
+
402
+ const payload = {
403
+ displayNumber,
404
+ status: 'running',
405
+ machineId,
406
+ machineName,
407
+ atomic: true
408
+ };
409
+
410
+ try {
411
+ const response = await apiRequest('update-task-execution', {
412
+ method: 'PATCH',
413
+ body: JSON.stringify(payload)
414
+ });
415
+
416
+ const result = await response.json().catch(() => ({}));
417
+
418
+ if (result.claimed === true) {
419
+ log(`Task #${displayNumber} claimed by this machine (${machineName})`);
420
+ return true;
421
+ }
422
+
423
+ if (result.claimed === false) {
424
+ log(`Task #${displayNumber} already claimed by ${result.claimedBy || 'another machine'}`);
425
+ return false;
426
+ }
427
+
428
+ // Backward compatibility
429
+ return response.ok;
430
+ } catch (error) {
431
+ log(`Task #${displayNumber} claim request failed: ${error.message}`);
432
+ return false;
433
+ }
434
+ }
435
+
436
+ // ==================== Project Registry ====================
437
+
438
+ function getProjectPath(gitRemote) {
439
+ if (!existsSync(REGISTRY_FILE)) {
440
+ return null;
441
+ }
442
+
443
+ try {
444
+ const data = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
445
+ return data.projects?.[gitRemote]?.path || null;
446
+ } catch {
447
+ return null;
448
+ }
449
+ }
450
+
451
+ function getListedProjects() {
452
+ if (!existsSync(REGISTRY_FILE)) {
453
+ return {};
454
+ }
455
+
456
+ try {
457
+ const data = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
458
+ const result = {};
459
+ for (const [remote, info] of Object.entries(data.projects || {})) {
460
+ result[remote] = info.path;
461
+ }
462
+ return result;
463
+ } catch {
464
+ return {};
465
+ }
466
+ }
467
+
468
+ // ==================== Git Worktree Management ====================
469
+
470
+ function getWorktreeSuffix() {
471
+ const machineId = getMachineId();
472
+ if (machineId) {
473
+ // Extract the random suffix from machine_id (last 8 chars after hyphen)
474
+ const parts = machineId.split('-');
475
+ if (parts.length > 1) {
476
+ return parts[parts.length - 1].slice(0, 8);
477
+ }
478
+ return machineId.slice(0, 8);
479
+ }
480
+ return 'local';
481
+ }
482
+
483
+ function getWorktreePath(displayNumber, projectPath) {
484
+ const suffix = getWorktreeSuffix();
485
+ const worktreeName = `push-${displayNumber}-${suffix}`;
486
+
487
+ if (projectPath) {
488
+ return join(dirname(projectPath), worktreeName);
489
+ }
490
+ return join(process.cwd(), '..', worktreeName);
491
+ }
492
+
493
+ function createWorktree(displayNumber, projectPath) {
494
+ const suffix = getWorktreeSuffix();
495
+ const branch = `push-${displayNumber}-${suffix}`;
496
+ const worktreePath = getWorktreePath(displayNumber, projectPath);
497
+
498
+ if (existsSync(worktreePath)) {
499
+ log(`Worktree already exists: ${worktreePath}`);
500
+ return worktreePath;
501
+ }
502
+
503
+ const gitCwd = projectPath || process.cwd();
504
+
505
+ try {
506
+ // Try to create with new branch
507
+ execSync(`git worktree add "${worktreePath}" -b ${branch}`, {
508
+ cwd: gitCwd,
509
+ timeout: 30000,
510
+ stdio: 'pipe'
511
+ });
512
+ log(`Created worktree: ${worktreePath}`);
513
+ return worktreePath;
514
+ } catch {
515
+ // Branch might already exist, try without -b
516
+ try {
517
+ execSync(`git worktree add "${worktreePath}" ${branch}`, {
518
+ cwd: gitCwd,
519
+ timeout: 30000,
520
+ stdio: 'pipe'
521
+ });
522
+ log(`Created worktree (existing branch): ${worktreePath}`);
523
+ return worktreePath;
524
+ } catch (e) {
525
+ logError(`Failed to create worktree: ${e.message}`);
526
+ return null;
527
+ }
528
+ }
529
+ }
530
+
531
+ function cleanupWorktree(displayNumber, projectPath) {
532
+ const worktreePath = getWorktreePath(displayNumber, projectPath);
533
+
534
+ if (!existsSync(worktreePath)) return;
535
+
536
+ const gitCwd = projectPath || process.cwd();
537
+ const suffix = getWorktreeSuffix();
538
+ const branch = `push-${displayNumber}-${suffix}`;
539
+
540
+ try {
541
+ execSync(`git worktree remove "${worktreePath}" --force`, {
542
+ cwd: gitCwd,
543
+ timeout: 30000,
544
+ stdio: 'pipe'
545
+ });
546
+ log(`Cleaned up worktree: ${worktreePath}`);
547
+ log(`Branch preserved for review: ${branch}`);
548
+ } catch (e) {
549
+ logError(`Failed to cleanup worktree: ${e.message}`);
550
+ }
551
+ }
552
+
553
+ // ==================== PR Auto-Creation ====================
554
+
555
+ function createPRForTask(displayNumber, summary, projectPath) {
556
+ const suffix = getWorktreeSuffix();
557
+ const branch = `push-${displayNumber}-${suffix}`;
558
+ const gitCwd = projectPath || process.cwd();
559
+
560
+ try {
561
+ // Check if branch has commits
562
+ const logResult = execSync(`git log HEAD..${branch} --oneline`, {
563
+ cwd: gitCwd,
564
+ timeout: 10000,
565
+ encoding: 'utf8',
566
+ stdio: ['pipe', 'pipe', 'pipe']
567
+ });
568
+
569
+ if (!logResult.trim()) {
570
+ log(`Branch ${branch} has no new commits, skipping PR creation`);
571
+ return null;
572
+ }
573
+
574
+ const commitCount = logResult.trim().split('\n').length;
575
+ log(`Branch ${branch} has ${commitCount} new commit(s)`);
576
+
577
+ // Push branch
578
+ execSync(`git push -u origin ${branch}`, {
579
+ cwd: gitCwd,
580
+ timeout: 60000,
581
+ stdio: 'pipe'
582
+ });
583
+ log(`Pushed branch ${branch} to origin`);
584
+
585
+ // Create PR using gh CLI
586
+ const prTitle = `Push Task #${displayNumber}: ${summary.slice(0, 50)}`;
587
+ const prBody = `## Summary
588
+
589
+ Automated PR from Push daemon for task #${displayNumber}.
590
+
591
+ **Task:** ${summary}
592
+
593
+ ---
594
+
595
+ *This PR was created automatically by the Push task execution daemon.*
596
+ *Review the changes and merge when ready.*`;
597
+
598
+ const prResult = execSync(`gh pr create --head ${branch} --title "${prTitle.replace(/"/g, '\\"')}" --body "${prBody.replace(/"/g, '\\"')}"`, {
599
+ cwd: gitCwd,
600
+ timeout: 30000,
601
+ encoding: 'utf8',
602
+ stdio: ['pipe', 'pipe', 'pipe']
603
+ });
604
+
605
+ const prUrl = prResult.trim();
606
+ log(`Created PR for task #${displayNumber}: ${prUrl}`);
607
+ return prUrl;
608
+ } catch (e) {
609
+ if (e.message?.includes('already exists')) {
610
+ log(`PR already exists for branch ${branch}`);
611
+ } else if (e.message?.includes('not found') || e.message?.includes('ENOENT')) {
612
+ log('GitHub CLI (gh) not installed, skipping PR creation');
613
+ } else {
614
+ logError(`Failed to create PR: ${e.message}`);
615
+ }
616
+ return null;
617
+ }
618
+ }
619
+
620
+ // ==================== Stuck Detection ====================
621
+
622
+ function checkStuckPatterns(displayNumber, line) {
623
+ const lineLower = line.toLowerCase();
624
+
625
+ for (const pattern of STUCK_PATTERNS) {
626
+ if (lineLower.includes(pattern.toLowerCase())) {
627
+ return `Detected: '${pattern}'`;
628
+ }
629
+ }
630
+
631
+ return null;
632
+ }
633
+
634
+ function monitorTaskStdout(displayNumber, proc) {
635
+ if (!proc.stdout) return;
636
+
637
+ // Initialize tracking
638
+ if (!taskLastOutput.has(displayNumber)) {
639
+ taskLastOutput.set(displayNumber, Date.now());
640
+ }
641
+ if (!taskStdoutBuffer.has(displayNumber)) {
642
+ taskStdoutBuffer.set(displayNumber, []);
643
+ }
644
+
645
+ // Non-blocking check for available data
646
+ proc.stdout.once('readable', () => {
647
+ let chunk;
648
+ while ((chunk = proc.stdout.read()) !== null) {
649
+ const lines = chunk.toString().split('\n');
650
+ for (const line of lines) {
651
+ if (!line.trim()) continue;
652
+
653
+ taskLastOutput.set(displayNumber, Date.now());
654
+
655
+ // Keep last 20 lines
656
+ const buffer = taskStdoutBuffer.get(displayNumber);
657
+ buffer.push(line);
658
+ if (buffer.length > 20) buffer.shift();
659
+
660
+ // Check for stuck patterns
661
+ const stuckReason = checkStuckPatterns(displayNumber, line);
662
+ if (stuckReason) {
663
+ log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
664
+ log(` Line: ${line.slice(0, 100)}...`);
665
+
666
+ updateTaskDetail(displayNumber, {
667
+ phase: 'stuck',
668
+ detail: `Waiting for input: ${stuckReason}`
669
+ });
670
+
671
+ if (NOTIFY_ON_NEEDS_INPUT) {
672
+ const info = taskDetails.get(displayNumber) || {};
673
+ sendMacNotification(
674
+ `Task #${displayNumber} needs input`,
675
+ `${info.summary?.slice(0, 40) || 'Unknown'}... ${stuckReason}`,
676
+ 'Ping'
677
+ );
678
+ }
679
+ }
680
+ }
681
+ }
682
+ });
683
+ }
684
+
685
+ function checkTaskIdle(displayNumber) {
686
+ const lastOutput = taskLastOutput.get(displayNumber);
687
+ if (!lastOutput) return false;
688
+
689
+ const elapsed = Date.now() - lastOutput;
690
+
691
+ if (elapsed > STUCK_IDLE_THRESHOLD) {
692
+ log(`Task #${displayNumber} has been idle for ${Math.floor(elapsed / 1000)}s`);
693
+ return true;
694
+ }
695
+
696
+ if (elapsed > STUCK_WARNING_THRESHOLD) {
697
+ log(`Task #${displayNumber} idle warning: ${Math.floor(elapsed / 1000)}s since last output`);
698
+ }
699
+
700
+ return false;
701
+ }
702
+
703
+ // ==================== Session ID Extraction ====================
704
+
705
+ function extractSessionIdFromStdout(proc, buffer) {
706
+ let remaining = '';
707
+ if (proc.stdout) {
708
+ try {
709
+ remaining = proc.stdout.read()?.toString() || '';
710
+ } catch {}
711
+ }
712
+
713
+ const allOutput = buffer.join('\n') + '\n' + remaining;
714
+
715
+ // Try to parse JSON output
716
+ for (const line of allOutput.split('\n')) {
717
+ const trimmed = line.trim();
718
+ if (trimmed.startsWith('{') && trimmed.includes('session_id')) {
719
+ try {
720
+ const data = JSON.parse(trimmed);
721
+ if (data.session_id) return data.session_id;
722
+ } catch {}
723
+ }
724
+ }
725
+
726
+ // Try parsing whole output as JSON
727
+ try {
728
+ const data = JSON.parse(allOutput.trim());
729
+ return data.session_id || null;
730
+ } catch {}
731
+
732
+ return null;
733
+ }
734
+
735
+ // ==================== Task Execution ====================
736
+
737
+ function updateTaskDetail(displayNumber, updates) {
738
+ const current = taskDetails.get(displayNumber) || {};
739
+ taskDetails.set(displayNumber, { ...current, ...updates });
740
+ updateStatusFile();
741
+ }
742
+
743
+ function executeTask(task) {
744
+ // Decrypt E2EE fields
745
+ task = decryptTaskFields(task);
746
+
747
+ const displayNumber = task.displayNumber || task.display_number;
748
+ const gitRemote = task.gitRemote || task.git_remote;
749
+ const summary = task.summary || 'No summary';
750
+ const content = task.normalizedContent || task.normalized_content ||
751
+ task.content || task.summary || 'Work on this task';
752
+
753
+ if (!displayNumber) {
754
+ log('Task has no display number, skipping');
755
+ return null;
756
+ }
757
+
758
+ if (runningTasks.has(displayNumber)) {
759
+ log(`Task #${displayNumber} already running, skipping`);
760
+ return null;
761
+ }
762
+
763
+ if (runningTasks.size >= MAX_CONCURRENT_TASKS) {
764
+ log(`Max concurrent tasks (${MAX_CONCURRENT_TASKS}) reached, skipping #${displayNumber}`);
765
+ return null;
766
+ }
767
+
768
+ // Get project path
769
+ let projectPath = null;
770
+ if (gitRemote) {
771
+ projectPath = getProjectPath(gitRemote);
772
+ if (!projectPath) {
773
+ log(`Task #${displayNumber}: Project not registered: ${gitRemote}`);
774
+ log("Run '/push-todo connect' in the project directory to register");
775
+ return null;
776
+ }
777
+
778
+ if (!existsSync(projectPath)) {
779
+ logError(`Task #${displayNumber}: Project path does not exist: ${projectPath}`);
780
+ updateTaskStatus(displayNumber, 'failed', {
781
+ error: `Project path not found: ${projectPath}`
782
+ });
783
+ return null;
784
+ }
785
+
786
+ log(`Task #${displayNumber}: Project ${gitRemote} -> ${projectPath}`);
787
+ }
788
+
789
+ // Atomic task claiming
790
+ if (!claimTask(displayNumber)) {
791
+ return null;
792
+ }
793
+
794
+ // Track task details
795
+ updateTaskDetail(displayNumber, {
796
+ taskId: task.id || task.todo_id || '',
797
+ summary,
798
+ status: 'running',
799
+ phase: 'analyzing',
800
+ detail: 'Analyzing task certainty...',
801
+ startedAt: Date.now(),
802
+ gitRemote
803
+ });
804
+
805
+ log(`Analyzing task #${displayNumber}: ${content.slice(0, 60)}...`);
806
+
807
+ // Analyze certainty
808
+ const analysis = analyzeTaskCertainty(task);
809
+ const executionMode = determineExecutionMode(analysis);
810
+
811
+ log(`Task #${displayNumber} execution mode: ${executionMode}`);
812
+
813
+ // Handle low-certainty tasks
814
+ if (executionMode === 'clarify') {
815
+ log(`Task #${displayNumber} requires clarification (certainty too low)`);
816
+
817
+ const questions = analysis?.clarificationQuestions?.map(q => ({
818
+ question: q.question,
819
+ options: q.options,
820
+ priority: q.priority
821
+ })) || [];
822
+
823
+ let clarificationSummary = 'Task requires clarification before execution.';
824
+ if (analysis) {
825
+ clarificationSummary += ` Certainty score: ${analysis.score}`;
826
+ if (analysis.reasons?.length > 0) {
827
+ clarificationSummary += ` (${analysis.reasons[0].explanation})`;
828
+ }
829
+ }
830
+
831
+ updateTaskStatus(displayNumber, 'needs_clarification', {
832
+ summary: clarificationSummary,
833
+ certaintyScore: analysis?.score,
834
+ clarificationQuestions: questions
835
+ });
836
+
837
+ if (NOTIFY_ON_NEEDS_INPUT) {
838
+ sendMacNotification(
839
+ `Task #${displayNumber} needs clarification`,
840
+ `${summary.slice(0, 50)}... Low certainty - please clarify.`,
841
+ 'Ping'
842
+ );
843
+ }
844
+
845
+ taskDetails.delete(displayNumber);
846
+ updateStatusFile();
847
+ return null;
848
+ }
849
+
850
+ // Create worktree
851
+ const worktreePath = createWorktree(displayNumber, projectPath);
852
+ if (!worktreePath) {
853
+ updateTaskStatus(displayNumber, 'failed', { error: 'Failed to create git worktree' });
854
+ taskDetails.delete(displayNumber);
855
+ return null;
856
+ }
857
+
858
+ taskProjectPaths.set(displayNumber, projectPath);
859
+
860
+ // Build prompt
861
+ let prompt;
862
+ if (executionMode === 'planning') {
863
+ const reasonsText = analysis?.reasons?.slice(0, 3).map(r => `- ${r.explanation}`).join('\n') || '';
864
+ prompt = `Work on Push task #${displayNumber}:
865
+
866
+ ${content}
867
+
868
+ IMPORTANT: This task has medium certainty (score: ${analysis?.score ?? 'N/A'}).
869
+ Please START BY ENTERING PLAN MODE to clarify the approach before implementing.
870
+
871
+ Reasons for lower certainty:
872
+ ${reasonsText}
873
+
874
+ After your plan is approved, implement the changes.
875
+
876
+ When you're done, the SessionEnd hook will automatically report completion to Supabase.
877
+
878
+ If you need to understand the codebase, start by reading the CLAUDE.md file if it exists.`;
879
+ } else {
880
+ prompt = `Work on Push task #${displayNumber}:
881
+
882
+ ${content}
883
+
884
+ IMPORTANT: When you're done, the SessionEnd hook will automatically report completion to Supabase.
885
+
886
+ If you need to understand the codebase, start by reading the CLAUDE.md file if it exists.`;
887
+ }
888
+
889
+ // Update status to running
890
+ updateTaskStatus(displayNumber, 'running', {
891
+ certaintyScore: analysis?.score
892
+ });
893
+
894
+ // Build Claude command
895
+ const allowedTools = [
896
+ 'Read', 'Edit', 'Write', 'Glob', 'Grep',
897
+ 'Bash(git *)',
898
+ 'Bash(npm *)', 'Bash(npx *)', 'Bash(yarn *)',
899
+ 'Bash(python *)', 'Bash(python3 *)', 'Bash(pip *)', 'Bash(pip3 *)',
900
+ 'Task'
901
+ ].join(',');
902
+
903
+ const claudeArgs = [
904
+ '-p', prompt,
905
+ '--allowedTools', allowedTools,
906
+ '--output-format', 'json'
907
+ ];
908
+
909
+ if (executionMode === 'planning') {
910
+ claudeArgs.unshift('--plan');
911
+ }
912
+
913
+ try {
914
+ const child = spawn('claude', claudeArgs, {
915
+ cwd: worktreePath,
916
+ stdio: ['ignore', 'pipe', 'pipe'],
917
+ env: {
918
+ ...process.env,
919
+ PUSH_TASK_ID: task.id,
920
+ PUSH_DISPLAY_NUMBER: String(displayNumber)
921
+ }
922
+ });
923
+
924
+ const taskInfo = {
925
+ process: child,
926
+ task,
927
+ displayNumber,
928
+ startTime: Date.now(),
929
+ projectPath,
930
+ executionMode
931
+ };
932
+
933
+ runningTasks.set(displayNumber, taskInfo);
934
+ taskLastOutput.set(displayNumber, Date.now());
935
+ taskStdoutBuffer.set(displayNumber, []);
936
+
937
+ // Monitor stdout
938
+ child.stdout.on('data', (data) => {
939
+ const lines = data.toString().split('\n');
940
+ for (const line of lines) {
941
+ if (line.trim()) {
942
+ taskLastOutput.set(displayNumber, Date.now());
943
+ const buffer = taskStdoutBuffer.get(displayNumber) || [];
944
+ buffer.push(line);
945
+ if (buffer.length > 20) buffer.shift();
946
+ taskStdoutBuffer.set(displayNumber, buffer);
947
+
948
+ const stuckReason = checkStuckPatterns(displayNumber, line);
949
+ if (stuckReason) {
950
+ log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
951
+ updateTaskDetail(displayNumber, {
952
+ phase: 'stuck',
953
+ detail: `Waiting for input: ${stuckReason}`
954
+ });
955
+ }
956
+ }
957
+ }
958
+ });
959
+
960
+ // Handle completion
961
+ child.on('close', (code) => {
962
+ handleTaskCompletion(displayNumber, code);
963
+ });
964
+
965
+ child.on('error', (error) => {
966
+ logError(`Task #${displayNumber} error: ${error.message}`);
967
+ runningTasks.delete(displayNumber);
968
+ updateTaskStatus(displayNumber, 'failed', { error: error.message });
969
+ taskDetails.delete(displayNumber);
970
+ updateStatusFile();
971
+ });
972
+
973
+ const modeDesc = executionMode === 'planning' ? 'planning mode' : 'standard mode';
974
+ updateTaskDetail(displayNumber, {
975
+ phase: 'executing',
976
+ detail: `Running Claude in ${modeDesc}...`,
977
+ claudePid: child.pid
978
+ });
979
+
980
+ log(`Started Claude for task #${displayNumber} in ${modeDesc} (PID: ${child.pid})`);
981
+
982
+ if (executionMode === 'planning') {
983
+ sendMacNotification(
984
+ `Task #${displayNumber} started (planning)`,
985
+ `${summary.slice(0, 60)}...`,
986
+ 'default'
987
+ );
988
+ }
989
+
990
+ return taskInfo;
991
+ } catch (error) {
992
+ logError(`Error starting Claude for task #${displayNumber}: ${error.message}`);
993
+ updateTaskStatus(displayNumber, 'failed', { error: error.message });
994
+ taskDetails.delete(displayNumber);
995
+ return null;
996
+ }
997
+ }
998
+
999
+ function handleTaskCompletion(displayNumber, exitCode) {
1000
+ const taskInfo = runningTasks.get(displayNumber);
1001
+ if (!taskInfo) return;
1002
+
1003
+ runningTasks.delete(displayNumber);
1004
+
1005
+ const duration = Math.floor((Date.now() - taskInfo.startTime) / 1000);
1006
+ const info = taskDetails.get(displayNumber) || {};
1007
+ const summary = info.summary || 'Unknown task';
1008
+ const projectPath = taskProjectPaths.get(displayNumber);
1009
+
1010
+ log(`Task #${displayNumber} completed with code ${exitCode} (${duration}s)`);
1011
+
1012
+ if (exitCode === 0) {
1013
+ // Extract session ID
1014
+ const buffer = taskStdoutBuffer.get(displayNumber) || [];
1015
+ const sessionId = extractSessionIdFromStdout(taskInfo.process, buffer);
1016
+
1017
+ if (sessionId) {
1018
+ log(`Task #${displayNumber} session_id: ${sessionId}`);
1019
+ } else {
1020
+ log(`Task #${displayNumber} could not extract session_id`);
1021
+ }
1022
+
1023
+ updateTaskStatus(displayNumber, 'completed', {
1024
+ duration,
1025
+ sessionId
1026
+ });
1027
+
1028
+ // Auto-create PR
1029
+ const prUrl = createPRForTask(displayNumber, summary, projectPath);
1030
+
1031
+ if (NOTIFY_ON_COMPLETE) {
1032
+ const prNote = prUrl ? ' PR ready for review.' : '';
1033
+ sendMacNotification(
1034
+ `Task #${displayNumber} complete`,
1035
+ `${summary.slice(0, 50)}...${prNote}`,
1036
+ 'Glass'
1037
+ );
1038
+ }
1039
+
1040
+ completedToday.push({
1041
+ displayNumber,
1042
+ summary,
1043
+ completedAt: new Date().toISOString(),
1044
+ duration,
1045
+ status: 'completed',
1046
+ prUrl,
1047
+ sessionId
1048
+ });
1049
+ } else {
1050
+ const stderr = taskInfo.process.stderr?.read()?.toString() || '';
1051
+ const errorMsg = `Exit code ${exitCode}: ${stderr.slice(0, 200)}`;
1052
+ updateTaskStatus(displayNumber, 'failed', { error: errorMsg });
1053
+
1054
+ if (NOTIFY_ON_FAILURE) {
1055
+ sendMacNotification(
1056
+ `Task #${displayNumber} failed`,
1057
+ `${summary.slice(0, 40)}... Exit code ${exitCode}`,
1058
+ 'Basso'
1059
+ );
1060
+ }
1061
+
1062
+ completedToday.push({
1063
+ displayNumber,
1064
+ summary,
1065
+ completedAt: new Date().toISOString(),
1066
+ duration,
1067
+ status: 'failed'
1068
+ });
1069
+ }
1070
+
1071
+ // Cleanup
1072
+ taskDetails.delete(displayNumber);
1073
+ taskLastOutput.delete(displayNumber);
1074
+ taskStdoutBuffer.delete(displayNumber);
1075
+ taskProjectPaths.delete(displayNumber);
1076
+
1077
+ cleanupWorktree(displayNumber, projectPath);
1078
+ updateStatusFile();
1079
+ }
1080
+
1081
+ // ==================== Status File ====================
1082
+
1083
+ function updateStatusFile() {
1084
+ const now = new Date();
1085
+
1086
+ const activeTasks = [];
1087
+
1088
+ // Running tasks
1089
+ for (const [displayNum, taskInfo] of runningTasks) {
1090
+ const info = taskDetails.get(displayNum) || {};
1091
+ const elapsed = Math.floor((Date.now() - taskInfo.startTime) / 1000);
1092
+
1093
+ activeTasks.push({
1094
+ displayNumber: displayNum,
1095
+ taskId: info.taskId || '',
1096
+ summary: info.summary || 'Unknown task',
1097
+ status: 'running',
1098
+ phase: info.phase || 'executing',
1099
+ detail: info.detail || 'Running Claude...',
1100
+ startedAt: new Date(taskInfo.startTime).toISOString(),
1101
+ elapsedSeconds: elapsed
1102
+ });
1103
+ }
1104
+
1105
+ // Queued tasks
1106
+ for (const [displayNum, info] of taskDetails) {
1107
+ if (!runningTasks.has(displayNum) && info.status === 'queued') {
1108
+ activeTasks.push({
1109
+ displayNumber: displayNum,
1110
+ taskId: info.taskId || '',
1111
+ summary: info.summary || 'Unknown task',
1112
+ status: 'queued',
1113
+ queuedAt: info.queuedAt
1114
+ });
1115
+ }
1116
+ }
1117
+
1118
+ // Sort: running first, then queued
1119
+ activeTasks.sort((a, b) => {
1120
+ if (a.status === 'running' && b.status !== 'running') return -1;
1121
+ if (a.status !== 'running' && b.status === 'running') return 1;
1122
+ return a.displayNumber - b.displayNumber;
1123
+ });
1124
+
1125
+ const status = {
1126
+ daemon: {
1127
+ pid: process.pid,
1128
+ version: getVersion(),
1129
+ startedAt: daemonStartTime,
1130
+ machineName: getMachineName(),
1131
+ machineId: getMachineId()?.slice(-8)
1132
+ },
1133
+ running: true,
1134
+ activeTasks,
1135
+ runningTasks: activeTasks.filter(t => t.status === 'running'),
1136
+ queuedTasks: activeTasks.filter(t => t.status === 'queued'),
1137
+ completedToday: completedToday.slice(-10),
1138
+ stats: {
1139
+ running: runningTasks.size,
1140
+ maxConcurrent: MAX_CONCURRENT_TASKS,
1141
+ completedToday: completedToday.length
1142
+ },
1143
+ updatedAt: now.toISOString()
1144
+ };
1145
+
1146
+ try {
1147
+ const tempFile = `${STATUS_FILE}.tmp`;
1148
+ writeFileSync(tempFile, JSON.stringify(status, null, 2));
1149
+ renameSync(tempFile, STATUS_FILE);
1150
+ } catch {}
1151
+ }
1152
+
1153
+ // ==================== Task Checking ====================
1154
+
1155
+ async function checkTimeouts() {
1156
+ const now = Date.now();
1157
+ const timedOut = [];
1158
+
1159
+ for (const [displayNumber, taskInfo] of runningTasks) {
1160
+ const elapsed = now - taskInfo.startTime;
1161
+
1162
+ if (elapsed > TASK_TIMEOUT_MS) {
1163
+ log(`Task #${displayNumber} TIMEOUT after ${Math.floor(elapsed / 1000)}s`);
1164
+ timedOut.push(displayNumber);
1165
+ }
1166
+
1167
+ // Also check idle
1168
+ checkTaskIdle(displayNumber);
1169
+ }
1170
+
1171
+ // Handle timeouts
1172
+ for (const displayNumber of timedOut) {
1173
+ const taskInfo = runningTasks.get(displayNumber);
1174
+ if (!taskInfo) continue;
1175
+
1176
+ const info = taskDetails.get(displayNumber) || {};
1177
+ const duration = Math.floor((now - taskInfo.startTime) / 1000);
1178
+
1179
+ // Terminate
1180
+ log(`Terminating stuck task #${displayNumber} (PID: ${taskInfo.process.pid})`);
1181
+ try {
1182
+ taskInfo.process.kill('SIGTERM');
1183
+ await new Promise(r => setTimeout(r, 5000));
1184
+ taskInfo.process.kill('SIGKILL');
1185
+ } catch {}
1186
+
1187
+ runningTasks.delete(displayNumber);
1188
+
1189
+ const timeoutError = `Task timed out after ${duration}s (limit: ${TASK_TIMEOUT_MS / 1000}s)`;
1190
+ updateTaskStatus(displayNumber, 'failed', { error: timeoutError });
1191
+
1192
+ if (NOTIFY_ON_FAILURE) {
1193
+ sendMacNotification(
1194
+ `Task #${displayNumber} timed out`,
1195
+ `${info.summary?.slice(0, 40) || 'Unknown'}... (${duration}s limit reached)`,
1196
+ 'Basso'
1197
+ );
1198
+ }
1199
+
1200
+ completedToday.push({
1201
+ displayNumber,
1202
+ summary: info.summary || 'Unknown task',
1203
+ completedAt: new Date().toISOString(),
1204
+ duration,
1205
+ status: 'timeout'
1206
+ });
1207
+
1208
+ // Cleanup
1209
+ const projectPath = taskProjectPaths.get(displayNumber);
1210
+ taskDetails.delete(displayNumber);
1211
+ taskLastOutput.delete(displayNumber);
1212
+ taskStdoutBuffer.delete(displayNumber);
1213
+ taskProjectPaths.delete(displayNumber);
1214
+ cleanupWorktree(displayNumber, projectPath);
1215
+ }
1216
+
1217
+ if (timedOut.length > 0) {
1218
+ updateStatusFile();
1219
+ }
1220
+ }
1221
+
1222
+ // ==================== Main Loop ====================
1223
+
1224
+ async function pollAndExecute() {
1225
+ // Check for available slots
1226
+ if (runningTasks.size >= MAX_CONCURRENT_TASKS) {
1227
+ log(`All ${MAX_CONCURRENT_TASKS} slots in use, skipping poll`);
1228
+ return;
1229
+ }
1230
+
1231
+ const availableSlots = MAX_CONCURRENT_TASKS - runningTasks.size;
1232
+
1233
+ // Fetch queued tasks
1234
+ const tasks = await fetchQueuedTasks();
1235
+
1236
+ if (tasks.length === 0) {
1237
+ if (runningTasks.size > 0) {
1238
+ log(`No new tasks. ${runningTasks.size} task(s) running.`);
1239
+ }
1240
+ return;
1241
+ }
1242
+
1243
+ log(`Found ${tasks.length} queued tasks, ${availableSlots} slots available`);
1244
+
1245
+ // Execute tasks up to available slots
1246
+ for (const task of tasks.slice(0, availableSlots)) {
1247
+ const displayNumber = task.displayNumber || task.display_number;
1248
+
1249
+ if (runningTasks.has(displayNumber)) {
1250
+ continue;
1251
+ }
1252
+
1253
+ executeTask(task);
1254
+ }
1255
+
1256
+ updateStatusFile();
1257
+ }
1258
+
1259
+ async function mainLoop() {
1260
+ daemonStartTime = new Date().toISOString();
1261
+
1262
+ log('=' .repeat(60));
1263
+ log('Push task execution daemon started');
1264
+ log(`Machine: ${getMachineName()} (${getMachineId() || 'no ID'})`);
1265
+ log(`PID: ${process.pid}`);
1266
+ log(`Polling interval: ${POLL_INTERVAL / 1000}s`);
1267
+ log(`Max concurrent tasks: ${MAX_CONCURRENT_TASKS}`);
1268
+ log(`E2EE: ${e2eeAvailable ? 'Available' : 'Not available'}`);
1269
+ log(`Certainty analysis: ${CertaintyAnalyzer ? 'Available' : 'Not available'}`);
1270
+ log(`Log file: ${LOG_FILE}`);
1271
+
1272
+ // Show registered projects
1273
+ const projects = getListedProjects();
1274
+ const projectCount = Object.keys(projects).length;
1275
+ if (projectCount > 0) {
1276
+ log(`Registered projects (${projectCount}):`);
1277
+ for (const [remote, path] of Object.entries(projects)) {
1278
+ log(` - ${remote}`);
1279
+ log(` -> ${path}`);
1280
+ }
1281
+ } else {
1282
+ log('No projects registered yet');
1283
+ log("Run '/push-todo connect' in your project directories");
1284
+ }
1285
+ log('=' .repeat(60));
1286
+
1287
+ // Check API key
1288
+ if (!getApiKey()) {
1289
+ log("WARNING: No API key configured. Run '/push-todo connect' first.");
1290
+ }
1291
+
1292
+ // Write version file
1293
+ try {
1294
+ writeFileSync(VERSION_FILE, getVersion());
1295
+ } catch {}
1296
+
1297
+ // Initial status
1298
+ updateStatusFile();
1299
+
1300
+ // Main poll loop
1301
+ const poll = async () => {
1302
+ try {
1303
+ await checkTimeouts();
1304
+ await pollAndExecute();
1305
+ } catch (error) {
1306
+ logError(`Poll error: ${error.message}`);
1307
+ }
1308
+ };
1309
+
1310
+ // Initial poll
1311
+ await poll();
1312
+
1313
+ // Set up interval
1314
+ setInterval(poll, POLL_INTERVAL);
1315
+
1316
+ log(`Daemon running (PID: ${process.pid}, poll interval: ${POLL_INTERVAL / 1000}s)`);
1317
+ }
1318
+
1319
+ // ==================== Signal Handling ====================
1320
+
1321
+ function cleanup() {
1322
+ log('Daemon shutting down...');
1323
+
1324
+ // Kill running tasks
1325
+ for (const [displayNumber, taskInfo] of runningTasks) {
1326
+ log(`Killing task #${displayNumber}`);
1327
+ try {
1328
+ taskInfo.process.kill('SIGTERM');
1329
+ } catch {}
1330
+ }
1331
+
1332
+ // Clean up files
1333
+ try { unlinkSync(PID_FILE); } catch {}
1334
+
1335
+ // Update status
1336
+ try {
1337
+ writeFileSync(STATUS_FILE, JSON.stringify({
1338
+ running: false,
1339
+ stoppedAt: new Date().toISOString()
1340
+ }));
1341
+ } catch {}
1342
+
1343
+ process.exit(0);
1344
+ }
1345
+
1346
+ process.on('SIGTERM', cleanup);
1347
+ process.on('SIGINT', cleanup);
1348
+ process.on('uncaughtException', (error) => {
1349
+ logError(`Uncaught exception: ${error.message}`);
1350
+ cleanup();
1351
+ });
1352
+
1353
+ // ==================== Entry Point ====================
1354
+
1355
+ // Ensure directories exist
1356
+ mkdirSync(PUSH_DIR, { recursive: true });
1357
+ mkdirSync(CONFIG_DIR, { recursive: true });
1358
+
1359
+ // Rotate logs
1360
+ rotateLogs();
1361
+
1362
+ // Write PID file
1363
+ writeFileSync(PID_FILE, String(process.pid));
1364
+
1365
+ // Start main loop
1366
+ mainLoop().catch((error) => {
1367
+ logError(`Fatal error: ${error.message}`);
1368
+ cleanup();
1369
+ });