@masslessai/push-todo 4.1.9 → 4.2.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/cli.js CHANGED
@@ -121,6 +121,7 @@ ${bold('CRON (scheduled jobs):')}
121
121
  --cron <expression> 5-field cron expression
122
122
  --notify <message> Send Mac notification
123
123
  --create-todo <content> Create todo reminder
124
+ --health-check <path> Run codebase health check (scope: general|tests|deps)
124
125
  push-todo cron list List all cron jobs
125
126
  push-todo cron remove <id> Remove a cron job by ID
126
127
 
@@ -182,6 +183,8 @@ const options = {
182
183
  'create-todo': { type: 'string' },
183
184
  'notify': { type: 'string' },
184
185
  'queue-execution': { type: 'string' },
186
+ 'health-check': { type: 'string' },
187
+ 'scope': { type: 'string' },
185
188
  // Skill CLI options (Phase 3)
186
189
  'report-progress': { type: 'string' },
187
190
  'phase': { type: 'string' },
@@ -688,8 +691,14 @@ export async function run(argv) {
688
691
  action = { type: 'notify', content: values.notify };
689
692
  } else if (values['queue-execution']) {
690
693
  action = { type: 'queue-execution', todoId: values['queue-execution'] };
694
+ } else if (values['health-check']) {
695
+ action = {
696
+ type: 'health-check',
697
+ projectPath: values['health-check'],
698
+ scope: values.scope || 'general',
699
+ };
691
700
  } else {
692
- console.error(red('Action required: --create-todo, --notify, or --queue-execution'));
701
+ console.error(red('Action required: --create-todo, --notify, --queue-execution, or --health-check'));
693
702
  process.exit(1);
694
703
  }
695
704
 
package/lib/cron.js CHANGED
@@ -301,8 +301,11 @@ export function listJobs() {
301
301
  *
302
302
  * @param {Object} job - Job object
303
303
  * @param {Function} [logFn] - Optional log function
304
+ * @param {Object} [context] - Injected dependencies from daemon
305
+ * @param {Function} [context.apiRequest] - API request function
306
+ * @param {Function} [context.spawnHealthCheck] - Spawn a health check Claude session
304
307
  */
305
- async function executeAction(job, logFn) {
308
+ async function executeAction(job, logFn, context = {}) {
306
309
  const log = logFn || (() => {});
307
310
 
308
311
  switch (job.action.type) {
@@ -312,17 +315,69 @@ async function executeAction(job, logFn) {
312
315
  break;
313
316
 
314
317
  case 'create-todo':
315
- // Phase 2: notification-based (no Supabase edge function yet)
316
- sendMacNotification('Push: Scheduled Todo', job.action.content || job.name);
317
- log(`Cron "${job.name}": todo reminder sent (notification)`);
318
+ if (context.apiRequest) {
319
+ try {
320
+ const response = await context.apiRequest('create-todo', {
321
+ method: 'POST',
322
+ body: JSON.stringify({
323
+ title: job.action.content || job.name,
324
+ normalizedContent: job.action.detail || null,
325
+ isBacklog: job.action.backlog || false,
326
+ createdByClient: 'daemon-cron',
327
+ }),
328
+ });
329
+ if (response.ok) {
330
+ const data = await response.json();
331
+ log(`Cron "${job.name}": created todo #${data.todo?.displayNumber || '?'}`);
332
+ } else {
333
+ log(`Cron "${job.name}": create-todo failed (HTTP ${response.status})`);
334
+ // Fall back to notification
335
+ sendMacNotification('Push: Scheduled Todo', job.action.content || job.name);
336
+ }
337
+ } catch (error) {
338
+ log(`Cron "${job.name}": create-todo error: ${error.message}`);
339
+ sendMacNotification('Push: Scheduled Todo', job.action.content || job.name);
340
+ }
341
+ } else {
342
+ sendMacNotification('Push: Scheduled Todo', job.action.content || job.name);
343
+ log(`Cron "${job.name}": todo reminder sent (notification, no API context)`);
344
+ }
318
345
  break;
319
346
 
320
347
  case 'queue-execution':
321
- // Requires todoId — log warning if missing
322
348
  if (!job.action.todoId) {
323
349
  log(`Cron "${job.name}": queue-execution requires todoId, skipping`);
350
+ } else if (context.apiRequest) {
351
+ try {
352
+ const response = await context.apiRequest('update-task-execution', {
353
+ method: 'PATCH',
354
+ body: JSON.stringify({
355
+ todoId: job.action.todoId,
356
+ status: 'queued',
357
+ }),
358
+ });
359
+ if (response.ok) {
360
+ log(`Cron "${job.name}": queued todo ${job.action.todoId} for execution`);
361
+ } else {
362
+ log(`Cron "${job.name}": queue-execution failed (HTTP ${response.status})`);
363
+ }
364
+ } catch (error) {
365
+ log(`Cron "${job.name}": queue-execution error: ${error.message}`);
366
+ }
367
+ } else {
368
+ log(`Cron "${job.name}": queue-execution not available (no API context)`);
369
+ }
370
+ break;
371
+
372
+ case 'health-check':
373
+ if (context.spawnHealthCheck) {
374
+ try {
375
+ await context.spawnHealthCheck(job, log);
376
+ } catch (error) {
377
+ log(`Cron "${job.name}": health-check error: ${error.message}`);
378
+ }
324
379
  } else {
325
- log(`Cron "${job.name}": queue-execution for todo ${job.action.todoId} (not yet implemented)`);
380
+ log(`Cron "${job.name}": health-check not available (no daemon context)`);
326
381
  }
327
382
  break;
328
383
 
@@ -336,8 +391,9 @@ async function executeAction(job, logFn) {
336
391
  * Called from daemon poll loop on every cycle.
337
392
  *
338
393
  * @param {Function} [logFn] - Optional log function
394
+ * @param {Object} [context] - Injected dependencies (apiRequest, spawnHealthCheck)
339
395
  */
340
- export async function checkAndRunDueJobs(logFn) {
396
+ export async function checkAndRunDueJobs(logFn, context = {}) {
341
397
  const jobs = loadJobs();
342
398
  if (jobs.length === 0) return;
343
399
 
@@ -353,7 +409,7 @@ export async function checkAndRunDueJobs(logFn) {
353
409
 
354
410
  // Job is due — execute
355
411
  try {
356
- await executeAction(job, logFn);
412
+ await executeAction(job, logFn, context);
357
413
  } catch (error) {
358
414
  if (logFn) logFn(`Cron "${job.name}" execution failed: ${error.message}`);
359
415
  }
package/lib/daemon.js CHANGED
@@ -1514,6 +1514,295 @@ function handleCancellation(displayNumber, source) {
1514
1514
  });
1515
1515
  }
1516
1516
 
1517
+ // ==================== Message Injection (Phase 4) ====================
1518
+
1519
+ const MESSAGE_POLL_INTERVAL_MS = 30000; // Check for messages every 30s
1520
+ let lastMessagePoll = 0;
1521
+
1522
+ /**
1523
+ * Poll for urgent messages directed to running tasks.
1524
+ * Called from checkTimeouts() on each poll cycle.
1525
+ *
1526
+ * Normal messages: agent picks up via push-todo --check-messages at natural pauses.
1527
+ * Urgent messages: daemon kills the session and respawns with --continue + injected message.
1528
+ */
1529
+ async function checkUrgentMessages() {
1530
+ const now = Date.now();
1531
+ if (now - lastMessagePoll < MESSAGE_POLL_INTERVAL_MS) return;
1532
+ lastMessagePoll = now;
1533
+
1534
+ if (runningTasks.size === 0) return;
1535
+
1536
+ for (const [displayNumber, taskInfo] of runningTasks) {
1537
+ const info = taskDetails.get(displayNumber) || {};
1538
+ const taskId = info.taskId;
1539
+ if (!taskId) continue;
1540
+
1541
+ // Skip tasks already being cancelled or injected
1542
+ if (info.phase === 'cancelled' || info.phase === 'injecting_message') continue;
1543
+
1544
+ try {
1545
+ const params = new URLSearchParams({
1546
+ todo_id: taskId,
1547
+ direction: 'to_agent',
1548
+ });
1549
+
1550
+ const response = await apiRequest(`task-messages?${params}`, {}, false);
1551
+ if (!response.ok) continue;
1552
+
1553
+ const data = await response.json();
1554
+ const messages = data.messages || [];
1555
+
1556
+ // Only process urgent messages via kill+continue
1557
+ const urgentMessages = messages.filter(m => m.is_urgent || m.type === 'urgent');
1558
+ if (urgentMessages.length === 0) continue;
1559
+
1560
+ // Combine all urgent messages into one injection
1561
+ const combinedMessage = urgentMessages
1562
+ .map(m => m.message)
1563
+ .join('\n\n');
1564
+
1565
+ log(`Task #${displayNumber}: ${urgentMessages.length} urgent message(s) detected, initiating kill+continue`);
1566
+
1567
+ // Mark messages as read
1568
+ for (const msg of urgentMessages) {
1569
+ apiRequest('task-messages', {
1570
+ method: 'PATCH',
1571
+ body: JSON.stringify({ message_id: msg.id }),
1572
+ }).catch(() => {}); // fire-and-forget
1573
+ }
1574
+
1575
+ // Kill the current session and respawn with injected message
1576
+ await injectMessageViaKillContinue(displayNumber, combinedMessage);
1577
+ } catch (err) {
1578
+ log(`Task #${displayNumber}: message poll failed (non-fatal): ${err.message}`);
1579
+ }
1580
+ }
1581
+ }
1582
+
1583
+ /**
1584
+ * Kill the current Claude session and respawn with --continue, injecting a human message.
1585
+ *
1586
+ * Flow:
1587
+ * 1. SIGTERM the running process
1588
+ * 2. Wait for it to exit (handleTaskCompletion sees phase='injecting_message' and skips cleanup)
1589
+ * 3. Respawn with `claude --continue <sessionId> -p "Human sent: <message>"`
1590
+ *
1591
+ * The ~12s penalty is for Claude to reload context from the session transcript.
1592
+ */
1593
+ async function injectMessageViaKillContinue(displayNumber, message) {
1594
+ const taskInfo = runningTasks.get(displayNumber);
1595
+ if (!taskInfo) return;
1596
+
1597
+ const sessionId = taskInfo.sessionId;
1598
+ const projectPath = taskInfo.projectPath || taskProjectPaths.get(displayNumber);
1599
+ const info = taskDetails.get(displayNumber) || {};
1600
+ const taskId = info.taskId;
1601
+
1602
+ // Mark phase so handleTaskCompletion knows to respawn instead of cleaning up
1603
+ updateTaskDetail(displayNumber, {
1604
+ phase: 'injecting_message',
1605
+ detail: `Injecting message: ${message.slice(0, 50)}...`
1606
+ });
1607
+
1608
+ // Report the injection event to Supabase
1609
+ apiRequest('update-task-execution', {
1610
+ method: 'PATCH',
1611
+ body: JSON.stringify({
1612
+ todoId: taskId,
1613
+ displayNumber,
1614
+ event: {
1615
+ type: 'message_injected',
1616
+ timestamp: new Date().toISOString(),
1617
+ machineName: getMachineName() || undefined,
1618
+ summary: `Human message injected: ${message.slice(0, 100)}`,
1619
+ }
1620
+ })
1621
+ }).catch(() => {});
1622
+
1623
+ // Store the message for respawn (handleTaskCompletion will read this)
1624
+ taskInfo.pendingInjection = { message, sessionId, projectPath, taskId };
1625
+
1626
+ // Kill the current process
1627
+ try {
1628
+ taskInfo.process.kill('SIGTERM');
1629
+ } catch (err) {
1630
+ log(`Task #${displayNumber}: SIGTERM for injection failed: ${err.message}`);
1631
+ // Reset phase if kill failed
1632
+ updateTaskDetail(displayNumber, { phase: 'executing' });
1633
+ delete taskInfo.pendingInjection;
1634
+ }
1635
+ }
1636
+
1637
+ /**
1638
+ * Respawn a Claude session after kill, with the human message injected.
1639
+ * Called from handleTaskCompletion when phase === 'injecting_message'.
1640
+ */
1641
+ function respawnWithInjectedMessage(displayNumber) {
1642
+ const taskInfo = runningTasks.get(displayNumber);
1643
+ if (!taskInfo || !taskInfo.pendingInjection) {
1644
+ log(`Task #${displayNumber}: no pending injection found, cannot respawn`);
1645
+ return;
1646
+ }
1647
+
1648
+ const { message, sessionId, projectPath, taskId } = taskInfo.pendingInjection;
1649
+ delete taskInfo.pendingInjection;
1650
+
1651
+ const injectionPrompt = `IMPORTANT: The human sent you an urgent message while you were working:\n\n---\n${message}\n---\n\nPlease address this message and then continue with your task.`;
1652
+
1653
+ const allowedTools = [
1654
+ 'Read', 'Edit', 'Write', 'Glob', 'Grep',
1655
+ 'Bash(git *)',
1656
+ 'Bash(npm *)', 'Bash(npx *)', 'Bash(yarn *)',
1657
+ 'Bash(python *)', 'Bash(python3 *)', 'Bash(pip *)', 'Bash(pip3 *)',
1658
+ 'Bash(push-todo *)',
1659
+ 'Task'
1660
+ ].join(',');
1661
+
1662
+ // Generate new session ID for the respawned session
1663
+ const newSessionId = randomUUID();
1664
+
1665
+ const claudeArgs = [
1666
+ '--continue', sessionId,
1667
+ '-p', injectionPrompt,
1668
+ '--allowedTools', allowedTools,
1669
+ '--output-format', 'stream-json',
1670
+ '--permission-mode', 'bypassPermissions',
1671
+ '--session-id', newSessionId,
1672
+ ];
1673
+
1674
+ log(`Task #${displayNumber}: respawning with injected message (session: ${sessionId} -> ${newSessionId})`);
1675
+
1676
+ try {
1677
+ const child = spawn('claude', claudeArgs, {
1678
+ cwd: projectPath || process.cwd(),
1679
+ stdio: ['ignore', 'pipe', 'pipe'],
1680
+ env: (() => {
1681
+ const env = { ...process.env, PUSH_TASK_ID: taskId, PUSH_DISPLAY_NUMBER: String(displayNumber) };
1682
+ delete env.CLAUDECODE;
1683
+ delete env.CLAUDE_CODE_ENTRYPOINT;
1684
+ return env;
1685
+ })()
1686
+ });
1687
+
1688
+ // Replace the old process info
1689
+ taskInfo.process = child;
1690
+ taskInfo.sessionId = newSessionId;
1691
+ taskInfo.startTime = taskInfo.startTime; // Keep original start time
1692
+
1693
+ // Re-initialize stream parsing state
1694
+ taskStreamLineBuffer.set(displayNumber, '');
1695
+ taskActivityState.set(displayNumber, {
1696
+ filesRead: new Set(), filesEdited: new Set(),
1697
+ currentTool: null, lastText: '', sessionId: null
1698
+ });
1699
+ taskLastStreamProgress.set(displayNumber, Date.now());
1700
+ taskLastOutput.set(displayNumber, Date.now());
1701
+ taskStdoutBuffer.set(displayNumber, []);
1702
+ taskStderrBuffer.set(displayNumber, []);
1703
+ taskLastHeartbeat.set(displayNumber, Date.now());
1704
+
1705
+ // Re-attach stdout handler (same as executeTask)
1706
+ child.stdout.on('data', (data) => {
1707
+ taskLastOutput.set(displayNumber, Date.now());
1708
+
1709
+ const pending = (taskStreamLineBuffer.get(displayNumber) || '') + data.toString();
1710
+ const lines = pending.split('\n');
1711
+ taskStreamLineBuffer.set(displayNumber, lines.pop() || '');
1712
+
1713
+ for (const line of lines) {
1714
+ if (!line.trim()) continue;
1715
+
1716
+ const buffer = taskStdoutBuffer.get(displayNumber) || [];
1717
+ buffer.push(line);
1718
+ if (buffer.length > 50) buffer.shift();
1719
+ taskStdoutBuffer.set(displayNumber, buffer);
1720
+
1721
+ const event = parseStreamJsonLine(line);
1722
+ if (event) {
1723
+ processStreamEvent(displayNumber, event);
1724
+ } else {
1725
+ if (line.includes('[push-confirm] Waiting for')) {
1726
+ updateTaskDetail(displayNumber, {
1727
+ phase: 'awaiting_confirmation',
1728
+ detail: 'Waiting for user confirmation on iPhone'
1729
+ });
1730
+ }
1731
+ const stuckReason = checkStuckPatterns(displayNumber, line);
1732
+ if (stuckReason) {
1733
+ log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
1734
+ updateTaskDetail(displayNumber, { phase: 'stuck', detail: `Waiting for input: ${stuckReason}` });
1735
+ }
1736
+ }
1737
+ }
1738
+ });
1739
+
1740
+ child.stderr.on('data', (data) => {
1741
+ const lines = data.toString().split('\n');
1742
+ for (const line of lines) {
1743
+ if (line.trim()) {
1744
+ const buffer = taskStderrBuffer.get(displayNumber) || [];
1745
+ buffer.push(line);
1746
+ if (buffer.length > 20) buffer.shift();
1747
+ taskStderrBuffer.set(displayNumber, buffer);
1748
+ }
1749
+ }
1750
+ });
1751
+
1752
+ child.on('close', (code) => {
1753
+ handleTaskCompletion(displayNumber, code);
1754
+ });
1755
+
1756
+ child.on('error', async (error) => {
1757
+ logError(`Task #${displayNumber} respawn error: ${error.message}`);
1758
+ runningTasks.delete(displayNumber);
1759
+ await updateTaskStatus(displayNumber, 'failed', { error: `Respawn failed: ${error.message}` }, taskId);
1760
+ taskDetails.delete(displayNumber);
1761
+ taskLastHeartbeat.delete(displayNumber);
1762
+ taskStreamLineBuffer.delete(displayNumber);
1763
+ taskActivityState.delete(displayNumber);
1764
+ taskLastStreamProgress.delete(displayNumber);
1765
+ updateStatusFile();
1766
+ });
1767
+
1768
+ updateTaskDetail(displayNumber, {
1769
+ phase: 'executing',
1770
+ detail: 'Resumed with injected message',
1771
+ claudePid: child.pid
1772
+ });
1773
+
1774
+ // Update session ID in Supabase
1775
+ apiRequest('update-task-execution', {
1776
+ method: 'PATCH',
1777
+ body: JSON.stringify({
1778
+ todoId: taskId,
1779
+ displayNumber,
1780
+ sessionId: newSessionId,
1781
+ event: {
1782
+ type: 'progress',
1783
+ timestamp: new Date().toISOString(),
1784
+ machineName: getMachineName() || undefined,
1785
+ summary: `Respawned with injected human message (new session: ${newSessionId.slice(0, 8)})`,
1786
+ }
1787
+ })
1788
+ }).catch(() => {});
1789
+
1790
+ log(`Task #${displayNumber}: respawned successfully (PID: ${child.pid})`);
1791
+ } catch (error) {
1792
+ logError(`Task #${displayNumber}: respawn failed: ${error.message}`);
1793
+ // Clean up — task is effectively dead
1794
+ runningTasks.delete(displayNumber);
1795
+ updateTaskStatus(displayNumber, 'failed', {
1796
+ error: `Message injection respawn failed: ${error.message}`
1797
+ }, taskId).catch(() => {});
1798
+ taskDetails.delete(displayNumber);
1799
+ taskStreamLineBuffer.delete(displayNumber);
1800
+ taskActivityState.delete(displayNumber);
1801
+ taskLastStreamProgress.delete(displayNumber);
1802
+ updateStatusFile();
1803
+ }
1804
+ }
1805
+
1517
1806
  function monitorTaskStdout(displayNumber, proc) {
1518
1807
  if (!proc.stdout) return;
1519
1808
 
@@ -2188,10 +2477,19 @@ async function handleTaskCompletion(displayNumber, exitCode) {
2188
2477
  const taskInfo = runningTasks.get(displayNumber);
2189
2478
  if (!taskInfo) return;
2190
2479
 
2480
+ const info = taskDetails.get(displayNumber) || {};
2481
+
2482
+ // Message injection: process was killed to inject a human message.
2483
+ // Don't clean up — respawn with --continue instead.
2484
+ if (info.phase === 'injecting_message' && taskInfo.pendingInjection) {
2485
+ log(`Task #${displayNumber}: process exited for message injection (code ${exitCode}), respawning...`);
2486
+ respawnWithInjectedMessage(displayNumber);
2487
+ return;
2488
+ }
2489
+
2191
2490
  runningTasks.delete(displayNumber);
2192
2491
 
2193
2492
  const duration = Math.floor((Date.now() - taskInfo.startTime) / 1000);
2194
- const info = taskDetails.get(displayNumber) || {};
2195
2493
  const summary = info.summary || 'Unknown task';
2196
2494
  const projectPath = taskProjectPaths.get(displayNumber);
2197
2495
 
@@ -2502,6 +2800,13 @@ async function checkTimeouts() {
2502
2800
  // Level A: Send progress heartbeats for long-running tasks
2503
2801
  await sendProgressHeartbeats();
2504
2802
 
2803
+ // Phase 4: Check for urgent human messages to inject into running tasks
2804
+ try {
2805
+ await checkUrgentMessages();
2806
+ } catch (error) {
2807
+ logError(`Urgent message check failed (non-fatal): ${error.message}`);
2808
+ }
2809
+
2505
2810
  // Level B: Kill tasks that have been idle too long (fires at 15 min, before 60 min absolute)
2506
2811
  await killIdleTasks();
2507
2812
 
@@ -2684,6 +2989,127 @@ function logVersionParityWarnings() {
2684
2989
  }
2685
2990
  }
2686
2991
 
2992
+ // ==================== Health Check (Phase 5) ====================
2993
+
2994
+ /**
2995
+ * Spawn a health check Claude session for a project.
2996
+ * Called by the cron module when a health-check job fires.
2997
+ *
2998
+ * The session runs in the project directory with a special prompt that asks
2999
+ * Claude to review the codebase and suggest tasks. Results are created as
3000
+ * draft todos via the create-todo API.
3001
+ *
3002
+ * @param {Object} job - Cron job object
3003
+ * @param {Function} logFn - Log function
3004
+ */
3005
+ async function spawnHealthCheck(job, logFn) {
3006
+ const projectPath = job.action.projectPath;
3007
+ if (!projectPath || !existsSync(projectPath)) {
3008
+ logFn(`Health check: project path not found: ${projectPath}`);
3009
+ return;
3010
+ }
3011
+
3012
+ // Don't run if task slots are full
3013
+ if (runningTasks.size >= MAX_CONCURRENT_TASKS) {
3014
+ logFn(`Health check: all ${MAX_CONCURRENT_TASKS} slots in use, deferring`);
3015
+ return;
3016
+ }
3017
+
3018
+ const scope = job.action.scope || 'general';
3019
+ const customPrompt = job.action.prompt || '';
3020
+
3021
+ const healthPrompts = {
3022
+ general: `Review this codebase briefly. Check for:
3023
+ 1. Failing tests (run the test suite if one exists)
3024
+ 2. Obvious bugs or issues in recently modified files (last 7 days)
3025
+ 3. Outdated dependencies worth updating
3026
+
3027
+ For each issue found, create a todo using: push-todo create "<clear description of the issue>"
3028
+ Only create todos for real, actionable issues — not style preferences or minor improvements.
3029
+ If everything looks good, just say "No issues found" and don't create any todos.`,
3030
+ tests: `Run the test suite for this project. If any tests fail, create a todo for each failure:
3031
+ push-todo create "Fix failing test: <test name> - <brief reason>"
3032
+ If all tests pass, say "All tests pass" and don't create any todos.`,
3033
+ dependencies: `Check for outdated dependencies in this project. Only flag dependencies with:
3034
+ - Known security vulnerabilities
3035
+ - Major version bumps (not minor/patch)
3036
+ For each, create a todo: push-todo create "Update <dep> from <old> to <new> (<reason>)"
3037
+ If dependencies are current, say "All dependencies up to date."`,
3038
+ };
3039
+
3040
+ const prompt = customPrompt || healthPrompts[scope] || healthPrompts.general;
3041
+
3042
+ const allowedTools = [
3043
+ 'Read', 'Glob', 'Grep',
3044
+ 'Bash(git *)',
3045
+ 'Bash(npm *)', 'Bash(npx *)',
3046
+ 'Bash(python *)', 'Bash(python3 *)',
3047
+ 'Bash(push-todo create *)',
3048
+ ].join(',');
3049
+
3050
+ const claudeArgs = [
3051
+ '-p', prompt,
3052
+ '--allowedTools', allowedTools,
3053
+ '--output-format', 'stream-json',
3054
+ '--permission-mode', 'bypassPermissions',
3055
+ ];
3056
+
3057
+ logFn(`Health check "${job.name}": spawning Claude in ${projectPath} (scope: ${scope})`);
3058
+
3059
+ try {
3060
+ const child = spawn('claude', claudeArgs, {
3061
+ cwd: projectPath,
3062
+ stdio: ['ignore', 'pipe', 'pipe'],
3063
+ env: (() => {
3064
+ const env = { ...process.env };
3065
+ delete env.CLAUDECODE;
3066
+ delete env.CLAUDE_CODE_ENTRYPOINT;
3067
+ return env;
3068
+ })(),
3069
+ timeout: 300000, // 5 min max for health checks
3070
+ });
3071
+
3072
+ // Simple output tracking — health checks are lightweight, no full task tracking
3073
+ let output = '';
3074
+ child.stdout.on('data', (data) => {
3075
+ output += data.toString();
3076
+ });
3077
+
3078
+ child.stderr.on('data', (data) => {
3079
+ const errLine = data.toString().trim();
3080
+ if (errLine) logFn(`Health check "${job.name}" stderr: ${errLine}`);
3081
+ });
3082
+
3083
+ await new Promise((resolve) => {
3084
+ child.on('close', (code) => {
3085
+ if (code === 0) {
3086
+ logFn(`Health check "${job.name}": completed successfully`);
3087
+ } else {
3088
+ logFn(`Health check "${job.name}": exited with code ${code}`);
3089
+ }
3090
+ resolve();
3091
+ });
3092
+ child.on('error', (err) => {
3093
+ logFn(`Health check "${job.name}": spawn error: ${err.message}`);
3094
+ resolve();
3095
+ });
3096
+ });
3097
+
3098
+ // Extract any text summary from stream-json output
3099
+ const lines = output.split('\n').filter(l => l.trim());
3100
+ for (const line of lines) {
3101
+ try {
3102
+ const event = JSON.parse(line);
3103
+ if (event.type === 'result' && event.result) {
3104
+ logFn(`Health check "${job.name}" result: ${event.result.slice(0, 200)}`);
3105
+ }
3106
+ } catch { /* ignore non-JSON lines */ }
3107
+ }
3108
+ } catch (error) {
3109
+ logFn(`Health check "${job.name}": error: ${error.message}`);
3110
+ }
3111
+ }
3112
+
2687
3113
  // ==================== Main Loop ====================
2688
3114
 
2689
3115
  async function pollAndExecute() {
@@ -2845,7 +3271,7 @@ async function mainLoop() {
2845
3271
 
2846
3272
  // Cron jobs (check every poll cycle, execution throttled by nextRunAt)
2847
3273
  try {
2848
- await checkAndRunDueJobs(log);
3274
+ await checkAndRunDueJobs(log, { apiRequest, spawnHealthCheck });
2849
3275
  } catch (error) {
2850
3276
  logError(`Cron check error: ${error.message}`);
2851
3277
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.1.9",
3
+ "version": "4.2.0",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {