@masslessai/push-todo 4.1.9 → 4.2.1

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.
@@ -20,6 +20,38 @@ import { discoverAllProjects } from './discovery.js';
20
20
  import { createSpinner } from './utils/spinner.js';
21
21
  import { bold, green, red, dim, cyan } from './utils/colors.js';
22
22
  import { ensureDaemonRunning } from './daemon-health.js';
23
+ import { install as installLaunchAgent, getStatus as getLaunchAgentStatus } from './launchagent.js';
24
+
25
+ /**
26
+ * Install or update LaunchAgent (macOS only).
27
+ * Called at the end of auto-connect, even for early-exit paths.
28
+ */
29
+ function installLaunchAgentIfNeeded() {
30
+ if (process.platform !== 'darwin') return;
31
+
32
+ const laStatus = getLaunchAgentStatus();
33
+ if (!laStatus.installed) {
34
+ const laSpinner = createSpinner();
35
+ laSpinner.start('Installing LaunchAgent for auto-start on login...');
36
+
37
+ const laResult = installLaunchAgent();
38
+ if (laResult.success) {
39
+ laSpinner.succeed('LaunchAgent installed — daemon starts automatically on login');
40
+ } else {
41
+ laSpinner.fail(`LaunchAgent: ${laResult.message}`);
42
+ console.log(` ${dim('Daemon will still self-heal via push-todo commands.')}`);
43
+ }
44
+ } else {
45
+ // Already installed — ensure it's loaded and up to date
46
+ const laResult = installLaunchAgent();
47
+ if (laResult.alreadyInstalled) {
48
+ console.log(` ${green('✓')} LaunchAgent already configured`);
49
+ } else if (laResult.success) {
50
+ console.log(` ${green('✓')} LaunchAgent updated`);
51
+ }
52
+ }
53
+ console.log('');
54
+ }
23
55
 
24
56
  /**
25
57
  * Run the auto-connect flow.
@@ -98,6 +130,8 @@ export async function runAutoConnect(options = {}) {
98
130
  console.log(' No projects with git remotes found.');
99
131
  console.log(` ${dim('Projects must have a git remote to be registered.')}`);
100
132
  console.log('');
133
+ // Still install LaunchAgent even with no projects
134
+ installLaunchAgentIfNeeded();
101
135
  return;
102
136
  }
103
137
 
@@ -127,6 +161,8 @@ export async function runAutoConnect(options = {}) {
127
161
  console.log(` ${'='.repeat(40)}`);
128
162
  console.log(` All ${alreadyConnected} projects already connected.`);
129
163
  console.log('');
164
+ // Still install LaunchAgent even when all projects connected
165
+ installLaunchAgentIfNeeded();
130
166
  return;
131
167
  }
132
168
 
@@ -192,4 +228,7 @@ export async function runAutoConnect(options = {}) {
192
228
  console.log(` Run ${cyan("'push-todo'")} to see your tasks.`);
193
229
  }
194
230
  console.log('');
231
+
232
+ // Phase 6: Install LaunchAgent (macOS only)
233
+ installLaunchAgentIfNeeded();
195
234
  }
package/lib/cli.js CHANGED
@@ -15,6 +15,7 @@ import { runConnect } from './connect.js';
15
15
  import { startWatch } from './watch.js';
16
16
  import { showSettings, toggleSetting, setMaxBatchSize } from './config.js';
17
17
  import { ensureDaemonRunning, getDaemonStatus, startDaemon, stopDaemon } from './daemon-health.js';
18
+ import { install as installLaunchAgent, uninstall as uninstallLaunchAgent, getStatus as getLaunchAgentStatus } from './launchagent.js';
18
19
  import { getScreenshotPath, screenshotExists, openScreenshot } from './utils/screenshots.js';
19
20
  import { bold, red, cyan, dim, green } from './utils/colors.js';
20
21
  import { getMachineId } from './machine-id.js';
@@ -79,6 +80,8 @@ ${bold('OPTIONS:')}
79
80
  --daemon-status Show daemon status
80
81
  --daemon-start Start daemon manually
81
82
  --daemon-stop Stop daemon
83
+ --daemon-install Install LaunchAgent (auto-start on login)
84
+ --daemon-uninstall Remove LaunchAgent
82
85
  --commands Show available user commands
83
86
  --json Output as JSON
84
87
  --version, -v Show version
@@ -121,6 +124,7 @@ ${bold('CRON (scheduled jobs):')}
121
124
  --cron <expression> 5-field cron expression
122
125
  --notify <message> Send Mac notification
123
126
  --create-todo <content> Create todo reminder
127
+ --health-check <path> Run codebase health check (scope: general|tests|deps)
124
128
  push-todo cron list List all cron jobs
125
129
  push-todo cron remove <id> Remove a cron job by ID
126
130
 
@@ -154,6 +158,8 @@ const options = {
154
158
  'daemon-status': { type: 'boolean' },
155
159
  'daemon-start': { type: 'boolean' },
156
160
  'daemon-stop': { type: 'boolean' },
161
+ 'daemon-install': { type: 'boolean' },
162
+ 'daemon-uninstall': { type: 'boolean' },
157
163
  'commands': { type: 'boolean' },
158
164
  'json': { type: 'boolean' },
159
165
  'version': { type: 'boolean', short: 'v' },
@@ -182,6 +188,8 @@ const options = {
182
188
  'create-todo': { type: 'string' },
183
189
  'notify': { type: 'string' },
184
190
  'queue-execution': { type: 'string' },
191
+ 'health-check': { type: 'string' },
192
+ 'scope': { type: 'string' },
185
193
  // Skill CLI options (Phase 3)
186
194
  'report-progress': { type: 'string' },
187
195
  'phase': { type: 'string' },
@@ -251,6 +259,14 @@ export async function run(argv) {
251
259
  } else {
252
260
  console.log(`${bold('Daemon:')} Not running`);
253
261
  }
262
+ // Show LaunchAgent status
263
+ const laStatus = getLaunchAgentStatus();
264
+ if (laStatus.installed) {
265
+ console.log(`${bold('LaunchAgent:')} Installed${laStatus.loaded ? ' (loaded)' : ' (not loaded)'}`);
266
+ } else {
267
+ console.log(`${bold('LaunchAgent:')} Not installed`);
268
+ console.log(dim(' Run: push-todo --daemon-install'));
269
+ }
254
270
  return;
255
271
  }
256
272
 
@@ -278,6 +294,11 @@ export async function run(argv) {
278
294
  const success = stopDaemon();
279
295
  if (success) {
280
296
  console.log('Daemon stopped');
297
+ const laStatus = getLaunchAgentStatus();
298
+ if (laStatus.installed && laStatus.loaded) {
299
+ console.log(dim('Note: LaunchAgent will restart the daemon automatically.'));
300
+ console.log(dim('To fully stop: push-todo --daemon-uninstall'));
301
+ }
281
302
  } else {
282
303
  console.error(red('Failed to stop daemon'));
283
304
  process.exit(1);
@@ -286,6 +307,35 @@ export async function run(argv) {
286
307
  return;
287
308
  }
288
309
 
310
+ if (values['daemon-install']) {
311
+ const result = installLaunchAgent();
312
+ if (result.success) {
313
+ if (result.alreadyInstalled) {
314
+ console.log('LaunchAgent already installed');
315
+ } else {
316
+ console.log('LaunchAgent installed — daemon will start automatically on login');
317
+ }
318
+ const laStatus = getLaunchAgentStatus();
319
+ console.log(dim(`Plist: ${laStatus.plistPath}`));
320
+ } else {
321
+ console.error(red(result.message));
322
+ process.exit(1);
323
+ }
324
+ return;
325
+ }
326
+
327
+ if (values['daemon-uninstall']) {
328
+ const result = uninstallLaunchAgent();
329
+ if (result.success) {
330
+ console.log(result.message);
331
+ console.log(dim('Daemon will still self-heal via push-todo commands.'));
332
+ } else {
333
+ console.error(red(result.message));
334
+ process.exit(1);
335
+ }
336
+ return;
337
+ }
338
+
289
339
  // Handle --commands (simple user help)
290
340
  if (values.commands) {
291
341
  console.log(`
@@ -688,8 +738,14 @@ export async function run(argv) {
688
738
  action = { type: 'notify', content: values.notify };
689
739
  } else if (values['queue-execution']) {
690
740
  action = { type: 'queue-execution', todoId: values['queue-execution'] };
741
+ } else if (values['health-check']) {
742
+ action = {
743
+ type: 'health-check',
744
+ projectPath: values['health-check'],
745
+ scope: values.scope || 'general',
746
+ };
691
747
  } else {
692
- console.error(red('Action required: --create-todo, --notify, or --queue-execution'));
748
+ console.error(red('Action required: --create-todo, --notify, --queue-execution, or --health-check'));
693
749
  process.exit(1);
694
750
  }
695
751
 
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
@@ -27,6 +27,7 @@ import { checkAndRunDueJobs } from './cron.js';
27
27
  import { runHeartbeatChecks } from './heartbeat.js';
28
28
  import { getAgentVersions, formatAgentVersionSummary, checkAllAgentUpdates, performAgentUpdate, checkVersionParity } from './agent-versions.js';
29
29
  import { checkAllProjectsFreshness } from './project-freshness.js';
30
+ import { getStatus as getLaunchAgentStatus, install as refreshLaunchAgent } from './launchagent.js';
30
31
 
31
32
  const __filename = fileURLToPath(import.meta.url);
32
33
  const __dirname = dirname(__filename);
@@ -1514,6 +1515,295 @@ function handleCancellation(displayNumber, source) {
1514
1515
  });
1515
1516
  }
1516
1517
 
1518
+ // ==================== Message Injection (Phase 4) ====================
1519
+
1520
+ const MESSAGE_POLL_INTERVAL_MS = 30000; // Check for messages every 30s
1521
+ let lastMessagePoll = 0;
1522
+
1523
+ /**
1524
+ * Poll for urgent messages directed to running tasks.
1525
+ * Called from checkTimeouts() on each poll cycle.
1526
+ *
1527
+ * Normal messages: agent picks up via push-todo --check-messages at natural pauses.
1528
+ * Urgent messages: daemon kills the session and respawns with --continue + injected message.
1529
+ */
1530
+ async function checkUrgentMessages() {
1531
+ const now = Date.now();
1532
+ if (now - lastMessagePoll < MESSAGE_POLL_INTERVAL_MS) return;
1533
+ lastMessagePoll = now;
1534
+
1535
+ if (runningTasks.size === 0) return;
1536
+
1537
+ for (const [displayNumber, taskInfo] of runningTasks) {
1538
+ const info = taskDetails.get(displayNumber) || {};
1539
+ const taskId = info.taskId;
1540
+ if (!taskId) continue;
1541
+
1542
+ // Skip tasks already being cancelled or injected
1543
+ if (info.phase === 'cancelled' || info.phase === 'injecting_message') continue;
1544
+
1545
+ try {
1546
+ const params = new URLSearchParams({
1547
+ todo_id: taskId,
1548
+ direction: 'to_agent',
1549
+ });
1550
+
1551
+ const response = await apiRequest(`task-messages?${params}`, {}, false);
1552
+ if (!response.ok) continue;
1553
+
1554
+ const data = await response.json();
1555
+ const messages = data.messages || [];
1556
+
1557
+ // Only process urgent messages via kill+continue
1558
+ const urgentMessages = messages.filter(m => m.is_urgent || m.type === 'urgent');
1559
+ if (urgentMessages.length === 0) continue;
1560
+
1561
+ // Combine all urgent messages into one injection
1562
+ const combinedMessage = urgentMessages
1563
+ .map(m => m.message)
1564
+ .join('\n\n');
1565
+
1566
+ log(`Task #${displayNumber}: ${urgentMessages.length} urgent message(s) detected, initiating kill+continue`);
1567
+
1568
+ // Mark messages as read
1569
+ for (const msg of urgentMessages) {
1570
+ apiRequest('task-messages', {
1571
+ method: 'PATCH',
1572
+ body: JSON.stringify({ message_id: msg.id }),
1573
+ }).catch(() => {}); // fire-and-forget
1574
+ }
1575
+
1576
+ // Kill the current session and respawn with injected message
1577
+ await injectMessageViaKillContinue(displayNumber, combinedMessage);
1578
+ } catch (err) {
1579
+ log(`Task #${displayNumber}: message poll failed (non-fatal): ${err.message}`);
1580
+ }
1581
+ }
1582
+ }
1583
+
1584
+ /**
1585
+ * Kill the current Claude session and respawn with --continue, injecting a human message.
1586
+ *
1587
+ * Flow:
1588
+ * 1. SIGTERM the running process
1589
+ * 2. Wait for it to exit (handleTaskCompletion sees phase='injecting_message' and skips cleanup)
1590
+ * 3. Respawn with `claude --continue <sessionId> -p "Human sent: <message>"`
1591
+ *
1592
+ * The ~12s penalty is for Claude to reload context from the session transcript.
1593
+ */
1594
+ async function injectMessageViaKillContinue(displayNumber, message) {
1595
+ const taskInfo = runningTasks.get(displayNumber);
1596
+ if (!taskInfo) return;
1597
+
1598
+ const sessionId = taskInfo.sessionId;
1599
+ const projectPath = taskInfo.projectPath || taskProjectPaths.get(displayNumber);
1600
+ const info = taskDetails.get(displayNumber) || {};
1601
+ const taskId = info.taskId;
1602
+
1603
+ // Mark phase so handleTaskCompletion knows to respawn instead of cleaning up
1604
+ updateTaskDetail(displayNumber, {
1605
+ phase: 'injecting_message',
1606
+ detail: `Injecting message: ${message.slice(0, 50)}...`
1607
+ });
1608
+
1609
+ // Report the injection event to Supabase
1610
+ apiRequest('update-task-execution', {
1611
+ method: 'PATCH',
1612
+ body: JSON.stringify({
1613
+ todoId: taskId,
1614
+ displayNumber,
1615
+ event: {
1616
+ type: 'message_injected',
1617
+ timestamp: new Date().toISOString(),
1618
+ machineName: getMachineName() || undefined,
1619
+ summary: `Human message injected: ${message.slice(0, 100)}`,
1620
+ }
1621
+ })
1622
+ }).catch(() => {});
1623
+
1624
+ // Store the message for respawn (handleTaskCompletion will read this)
1625
+ taskInfo.pendingInjection = { message, sessionId, projectPath, taskId };
1626
+
1627
+ // Kill the current process
1628
+ try {
1629
+ taskInfo.process.kill('SIGTERM');
1630
+ } catch (err) {
1631
+ log(`Task #${displayNumber}: SIGTERM for injection failed: ${err.message}`);
1632
+ // Reset phase if kill failed
1633
+ updateTaskDetail(displayNumber, { phase: 'executing' });
1634
+ delete taskInfo.pendingInjection;
1635
+ }
1636
+ }
1637
+
1638
+ /**
1639
+ * Respawn a Claude session after kill, with the human message injected.
1640
+ * Called from handleTaskCompletion when phase === 'injecting_message'.
1641
+ */
1642
+ function respawnWithInjectedMessage(displayNumber) {
1643
+ const taskInfo = runningTasks.get(displayNumber);
1644
+ if (!taskInfo || !taskInfo.pendingInjection) {
1645
+ log(`Task #${displayNumber}: no pending injection found, cannot respawn`);
1646
+ return;
1647
+ }
1648
+
1649
+ const { message, sessionId, projectPath, taskId } = taskInfo.pendingInjection;
1650
+ delete taskInfo.pendingInjection;
1651
+
1652
+ 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.`;
1653
+
1654
+ const allowedTools = [
1655
+ 'Read', 'Edit', 'Write', 'Glob', 'Grep',
1656
+ 'Bash(git *)',
1657
+ 'Bash(npm *)', 'Bash(npx *)', 'Bash(yarn *)',
1658
+ 'Bash(python *)', 'Bash(python3 *)', 'Bash(pip *)', 'Bash(pip3 *)',
1659
+ 'Bash(push-todo *)',
1660
+ 'Task'
1661
+ ].join(',');
1662
+
1663
+ // Generate new session ID for the respawned session
1664
+ const newSessionId = randomUUID();
1665
+
1666
+ const claudeArgs = [
1667
+ '--continue', sessionId,
1668
+ '-p', injectionPrompt,
1669
+ '--allowedTools', allowedTools,
1670
+ '--output-format', 'stream-json',
1671
+ '--permission-mode', 'bypassPermissions',
1672
+ '--session-id', newSessionId,
1673
+ ];
1674
+
1675
+ log(`Task #${displayNumber}: respawning with injected message (session: ${sessionId} -> ${newSessionId})`);
1676
+
1677
+ try {
1678
+ const child = spawn('claude', claudeArgs, {
1679
+ cwd: projectPath || process.cwd(),
1680
+ stdio: ['ignore', 'pipe', 'pipe'],
1681
+ env: (() => {
1682
+ const env = { ...process.env, PUSH_TASK_ID: taskId, PUSH_DISPLAY_NUMBER: String(displayNumber) };
1683
+ delete env.CLAUDECODE;
1684
+ delete env.CLAUDE_CODE_ENTRYPOINT;
1685
+ return env;
1686
+ })()
1687
+ });
1688
+
1689
+ // Replace the old process info
1690
+ taskInfo.process = child;
1691
+ taskInfo.sessionId = newSessionId;
1692
+ taskInfo.startTime = taskInfo.startTime; // Keep original start time
1693
+
1694
+ // Re-initialize stream parsing state
1695
+ taskStreamLineBuffer.set(displayNumber, '');
1696
+ taskActivityState.set(displayNumber, {
1697
+ filesRead: new Set(), filesEdited: new Set(),
1698
+ currentTool: null, lastText: '', sessionId: null
1699
+ });
1700
+ taskLastStreamProgress.set(displayNumber, Date.now());
1701
+ taskLastOutput.set(displayNumber, Date.now());
1702
+ taskStdoutBuffer.set(displayNumber, []);
1703
+ taskStderrBuffer.set(displayNumber, []);
1704
+ taskLastHeartbeat.set(displayNumber, Date.now());
1705
+
1706
+ // Re-attach stdout handler (same as executeTask)
1707
+ child.stdout.on('data', (data) => {
1708
+ taskLastOutput.set(displayNumber, Date.now());
1709
+
1710
+ const pending = (taskStreamLineBuffer.get(displayNumber) || '') + data.toString();
1711
+ const lines = pending.split('\n');
1712
+ taskStreamLineBuffer.set(displayNumber, lines.pop() || '');
1713
+
1714
+ for (const line of lines) {
1715
+ if (!line.trim()) continue;
1716
+
1717
+ const buffer = taskStdoutBuffer.get(displayNumber) || [];
1718
+ buffer.push(line);
1719
+ if (buffer.length > 50) buffer.shift();
1720
+ taskStdoutBuffer.set(displayNumber, buffer);
1721
+
1722
+ const event = parseStreamJsonLine(line);
1723
+ if (event) {
1724
+ processStreamEvent(displayNumber, event);
1725
+ } else {
1726
+ if (line.includes('[push-confirm] Waiting for')) {
1727
+ updateTaskDetail(displayNumber, {
1728
+ phase: 'awaiting_confirmation',
1729
+ detail: 'Waiting for user confirmation on iPhone'
1730
+ });
1731
+ }
1732
+ const stuckReason = checkStuckPatterns(displayNumber, line);
1733
+ if (stuckReason) {
1734
+ log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
1735
+ updateTaskDetail(displayNumber, { phase: 'stuck', detail: `Waiting for input: ${stuckReason}` });
1736
+ }
1737
+ }
1738
+ }
1739
+ });
1740
+
1741
+ child.stderr.on('data', (data) => {
1742
+ const lines = data.toString().split('\n');
1743
+ for (const line of lines) {
1744
+ if (line.trim()) {
1745
+ const buffer = taskStderrBuffer.get(displayNumber) || [];
1746
+ buffer.push(line);
1747
+ if (buffer.length > 20) buffer.shift();
1748
+ taskStderrBuffer.set(displayNumber, buffer);
1749
+ }
1750
+ }
1751
+ });
1752
+
1753
+ child.on('close', (code) => {
1754
+ handleTaskCompletion(displayNumber, code);
1755
+ });
1756
+
1757
+ child.on('error', async (error) => {
1758
+ logError(`Task #${displayNumber} respawn error: ${error.message}`);
1759
+ runningTasks.delete(displayNumber);
1760
+ await updateTaskStatus(displayNumber, 'failed', { error: `Respawn failed: ${error.message}` }, taskId);
1761
+ taskDetails.delete(displayNumber);
1762
+ taskLastHeartbeat.delete(displayNumber);
1763
+ taskStreamLineBuffer.delete(displayNumber);
1764
+ taskActivityState.delete(displayNumber);
1765
+ taskLastStreamProgress.delete(displayNumber);
1766
+ updateStatusFile();
1767
+ });
1768
+
1769
+ updateTaskDetail(displayNumber, {
1770
+ phase: 'executing',
1771
+ detail: 'Resumed with injected message',
1772
+ claudePid: child.pid
1773
+ });
1774
+
1775
+ // Update session ID in Supabase
1776
+ apiRequest('update-task-execution', {
1777
+ method: 'PATCH',
1778
+ body: JSON.stringify({
1779
+ todoId: taskId,
1780
+ displayNumber,
1781
+ sessionId: newSessionId,
1782
+ event: {
1783
+ type: 'progress',
1784
+ timestamp: new Date().toISOString(),
1785
+ machineName: getMachineName() || undefined,
1786
+ summary: `Respawned with injected human message (new session: ${newSessionId.slice(0, 8)})`,
1787
+ }
1788
+ })
1789
+ }).catch(() => {});
1790
+
1791
+ log(`Task #${displayNumber}: respawned successfully (PID: ${child.pid})`);
1792
+ } catch (error) {
1793
+ logError(`Task #${displayNumber}: respawn failed: ${error.message}`);
1794
+ // Clean up — task is effectively dead
1795
+ runningTasks.delete(displayNumber);
1796
+ updateTaskStatus(displayNumber, 'failed', {
1797
+ error: `Message injection respawn failed: ${error.message}`
1798
+ }, taskId).catch(() => {});
1799
+ taskDetails.delete(displayNumber);
1800
+ taskStreamLineBuffer.delete(displayNumber);
1801
+ taskActivityState.delete(displayNumber);
1802
+ taskLastStreamProgress.delete(displayNumber);
1803
+ updateStatusFile();
1804
+ }
1805
+ }
1806
+
1517
1807
  function monitorTaskStdout(displayNumber, proc) {
1518
1808
  if (!proc.stdout) return;
1519
1809
 
@@ -2188,10 +2478,19 @@ async function handleTaskCompletion(displayNumber, exitCode) {
2188
2478
  const taskInfo = runningTasks.get(displayNumber);
2189
2479
  if (!taskInfo) return;
2190
2480
 
2481
+ const info = taskDetails.get(displayNumber) || {};
2482
+
2483
+ // Message injection: process was killed to inject a human message.
2484
+ // Don't clean up — respawn with --continue instead.
2485
+ if (info.phase === 'injecting_message' && taskInfo.pendingInjection) {
2486
+ log(`Task #${displayNumber}: process exited for message injection (code ${exitCode}), respawning...`);
2487
+ respawnWithInjectedMessage(displayNumber);
2488
+ return;
2489
+ }
2490
+
2191
2491
  runningTasks.delete(displayNumber);
2192
2492
 
2193
2493
  const duration = Math.floor((Date.now() - taskInfo.startTime) / 1000);
2194
- const info = taskDetails.get(displayNumber) || {};
2195
2494
  const summary = info.summary || 'Unknown task';
2196
2495
  const projectPath = taskProjectPaths.get(displayNumber);
2197
2496
 
@@ -2502,6 +2801,13 @@ async function checkTimeouts() {
2502
2801
  // Level A: Send progress heartbeats for long-running tasks
2503
2802
  await sendProgressHeartbeats();
2504
2803
 
2804
+ // Phase 4: Check for urgent human messages to inject into running tasks
2805
+ try {
2806
+ await checkUrgentMessages();
2807
+ } catch (error) {
2808
+ logError(`Urgent message check failed (non-fatal): ${error.message}`);
2809
+ }
2810
+
2505
2811
  // Level B: Kill tasks that have been idle too long (fires at 15 min, before 60 min absolute)
2506
2812
  await killIdleTasks();
2507
2813
 
@@ -2613,7 +2919,16 @@ function checkAndApplyUpdate() {
2613
2919
  if (success) {
2614
2920
  log(`Update to v${pendingUpdateVersion} successful. Restarting daemon...`);
2615
2921
 
2616
- // Spawn new daemon from updated code, then exit
2922
+ // If LaunchAgent is installed, refresh plist (paths may have changed)
2923
+ // and let launchd handle the restart instead of spawning manually
2924
+ const laStatus = getLaunchAgentStatus();
2925
+ if (laStatus.installed && laStatus.loaded) {
2926
+ refreshLaunchAgent(); // Update plist with current node/daemon paths
2927
+ log('LaunchAgent installed — letting launchd restart daemon.');
2928
+ process.exit(0);
2929
+ }
2930
+
2931
+ // No LaunchAgent — spawn new daemon manually, then exit
2617
2932
  const daemonScript = join(__dirname, 'daemon.js');
2618
2933
  const selfUpdateEnv = { ...process.env, PUSH_DAEMON: '1' };
2619
2934
  delete selfUpdateEnv.CLAUDECODE; // Strip to avoid leaking into Claude child processes
@@ -2684,6 +2999,127 @@ function logVersionParityWarnings() {
2684
2999
  }
2685
3000
  }
2686
3001
 
3002
+ // ==================== Health Check (Phase 5) ====================
3003
+
3004
+ /**
3005
+ * Spawn a health check Claude session for a project.
3006
+ * Called by the cron module when a health-check job fires.
3007
+ *
3008
+ * The session runs in the project directory with a special prompt that asks
3009
+ * Claude to review the codebase and suggest tasks. Results are created as
3010
+ * draft todos via the create-todo API.
3011
+ *
3012
+ * @param {Object} job - Cron job object
3013
+ * @param {Function} logFn - Log function
3014
+ */
3015
+ async function spawnHealthCheck(job, logFn) {
3016
+ const projectPath = job.action.projectPath;
3017
+ if (!projectPath || !existsSync(projectPath)) {
3018
+ logFn(`Health check: project path not found: ${projectPath}`);
3019
+ return;
3020
+ }
3021
+
3022
+ // Don't run if task slots are full
3023
+ if (runningTasks.size >= MAX_CONCURRENT_TASKS) {
3024
+ logFn(`Health check: all ${MAX_CONCURRENT_TASKS} slots in use, deferring`);
3025
+ return;
3026
+ }
3027
+
3028
+ const scope = job.action.scope || 'general';
3029
+ const customPrompt = job.action.prompt || '';
3030
+
3031
+ const healthPrompts = {
3032
+ general: `Review this codebase briefly. Check for:
3033
+ 1. Failing tests (run the test suite if one exists)
3034
+ 2. Obvious bugs or issues in recently modified files (last 7 days)
3035
+ 3. Outdated dependencies worth updating
3036
+
3037
+ For each issue found, create a todo using: push-todo create "<clear description of the issue>"
3038
+ Only create todos for real, actionable issues — not style preferences or minor improvements.
3039
+ If everything looks good, just say "No issues found" and don't create any todos.`,
3040
+ tests: `Run the test suite for this project. If any tests fail, create a todo for each failure:
3041
+ push-todo create "Fix failing test: <test name> - <brief reason>"
3042
+ If all tests pass, say "All tests pass" and don't create any todos.`,
3043
+ dependencies: `Check for outdated dependencies in this project. Only flag dependencies with:
3044
+ - Known security vulnerabilities
3045
+ - Major version bumps (not minor/patch)
3046
+ For each, create a todo: push-todo create "Update <dep> from <old> to <new> (<reason>)"
3047
+ If dependencies are current, say "All dependencies up to date."`,
3048
+ };
3049
+
3050
+ const prompt = customPrompt || healthPrompts[scope] || healthPrompts.general;
3051
+
3052
+ const allowedTools = [
3053
+ 'Read', 'Glob', 'Grep',
3054
+ 'Bash(git *)',
3055
+ 'Bash(npm *)', 'Bash(npx *)',
3056
+ 'Bash(python *)', 'Bash(python3 *)',
3057
+ 'Bash(push-todo create *)',
3058
+ ].join(',');
3059
+
3060
+ const claudeArgs = [
3061
+ '-p', prompt,
3062
+ '--allowedTools', allowedTools,
3063
+ '--output-format', 'stream-json',
3064
+ '--permission-mode', 'bypassPermissions',
3065
+ ];
3066
+
3067
+ logFn(`Health check "${job.name}": spawning Claude in ${projectPath} (scope: ${scope})`);
3068
+
3069
+ try {
3070
+ const child = spawn('claude', claudeArgs, {
3071
+ cwd: projectPath,
3072
+ stdio: ['ignore', 'pipe', 'pipe'],
3073
+ env: (() => {
3074
+ const env = { ...process.env };
3075
+ delete env.CLAUDECODE;
3076
+ delete env.CLAUDE_CODE_ENTRYPOINT;
3077
+ return env;
3078
+ })(),
3079
+ timeout: 300000, // 5 min max for health checks
3080
+ });
3081
+
3082
+ // Simple output tracking — health checks are lightweight, no full task tracking
3083
+ let output = '';
3084
+ child.stdout.on('data', (data) => {
3085
+ output += data.toString();
3086
+ });
3087
+
3088
+ child.stderr.on('data', (data) => {
3089
+ const errLine = data.toString().trim();
3090
+ if (errLine) logFn(`Health check "${job.name}" stderr: ${errLine}`);
3091
+ });
3092
+
3093
+ await new Promise((resolve) => {
3094
+ child.on('close', (code) => {
3095
+ if (code === 0) {
3096
+ logFn(`Health check "${job.name}": completed successfully`);
3097
+ } else {
3098
+ logFn(`Health check "${job.name}": exited with code ${code}`);
3099
+ }
3100
+ resolve();
3101
+ });
3102
+ child.on('error', (err) => {
3103
+ logFn(`Health check "${job.name}": spawn error: ${err.message}`);
3104
+ resolve();
3105
+ });
3106
+ });
3107
+
3108
+ // Extract any text summary from stream-json output
3109
+ const lines = output.split('\n').filter(l => l.trim());
3110
+ for (const line of lines) {
3111
+ try {
3112
+ const event = JSON.parse(line);
3113
+ if (event.type === 'result' && event.result) {
3114
+ logFn(`Health check "${job.name}" result: ${event.result.slice(0, 200)}`);
3115
+ }
3116
+ } catch { /* ignore non-JSON lines */ }
3117
+ }
3118
+ } catch (error) {
3119
+ logFn(`Health check "${job.name}": error: ${error.message}`);
3120
+ }
3121
+ }
3122
+
2687
3123
  // ==================== Main Loop ====================
2688
3124
 
2689
3125
  async function pollAndExecute() {
@@ -2845,7 +3281,7 @@ async function mainLoop() {
2845
3281
 
2846
3282
  // Cron jobs (check every poll cycle, execution throttled by nextRunAt)
2847
3283
  try {
2848
- await checkAndRunDueJobs(log);
3284
+ await checkAndRunDueJobs(log, { apiRequest, spawnHealthCheck });
2849
3285
  } catch (error) {
2850
3286
  logError(`Cron check error: ${error.message}`);
2851
3287
  }
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.1",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -407,11 +407,11 @@ async function main() {
407
407
  }
408
408
  console.log('');
409
409
  console.log('[push-todo] Quick start:');
410
- console.log('[push-todo] push-todo connect Set up authentication');
411
- console.log('[push-todo] push-todo List your tasks');
412
- if (claudeSuccess) console.log('[push-todo] /push-todo Use in Claude Code');
413
- if (codexSuccess) console.log('[push-todo] $push-todo Use in OpenAI Codex');
414
- if (openclawSuccess) console.log('[push-todo] /push-todo Use in OpenClaw');
410
+ console.log('[push-todo] push-todo connect --auto One-command setup (auth + projects + daemon)');
411
+ console.log('[push-todo] push-todo List your tasks');
412
+ if (claudeSuccess) console.log('[push-todo] /push-todo Use in Claude Code');
413
+ if (codexSuccess) console.log('[push-todo] $push-todo Use in OpenAI Codex');
414
+ if (openclawSuccess) console.log('[push-todo] /push-todo Use in OpenClaw');
415
415
  return;
416
416
  }
417
417
 
@@ -440,16 +440,16 @@ async function main() {
440
440
  }
441
441
  console.log('');
442
442
  console.log('[push-todo] Quick start:');
443
- console.log('[push-todo] push-todo connect Set up authentication');
444
- console.log('[push-todo] push-todo List your tasks');
443
+ console.log('[push-todo] push-todo connect --auto One-command setup (auth + projects + daemon)');
444
+ console.log('[push-todo] push-todo List your tasks');
445
445
  if (claudeSuccess) {
446
- console.log('[push-todo] /push-todo Use in Claude Code');
446
+ console.log('[push-todo] /push-todo Use in Claude Code');
447
447
  }
448
448
  if (codexSuccess) {
449
- console.log('[push-todo] $push-todo Use in OpenAI Codex');
449
+ console.log('[push-todo] $push-todo Use in OpenAI Codex');
450
450
  }
451
451
  if (openclawSuccess) {
452
- console.log('[push-todo] /push-todo Use in OpenClaw');
452
+ console.log('[push-todo] /push-todo Use in OpenClaw');
453
453
  }
454
454
  }
455
455