@masslessai/push-todo 3.10.8 → 4.0.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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
11
- import { spawn } from 'child_process';
11
+ import { spawn, execFileSync } from 'child_process';
12
12
  import { homedir } from 'os';
13
13
  import { join, dirname } from 'path';
14
14
  import { fileURLToPath } from 'url';
@@ -116,6 +116,30 @@ function formatUptime(startedAt) {
116
116
  }
117
117
  }
118
118
 
119
+ /**
120
+ * Kill any orphaned daemon processes not tracked by PID file.
121
+ * Prevents zombie daemons from racing the real daemon for tasks.
122
+ */
123
+ function killOrphanedDaemons() {
124
+ try {
125
+ const trackedPid = existsSync(PID_FILE)
126
+ ? parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10)
127
+ : null;
128
+ const output = execFileSync('pgrep', ['-f', 'push-todo.*daemon\\.js'], {
129
+ encoding: 'utf8',
130
+ timeout: 5000
131
+ }).trim();
132
+ for (const line of output.split('\n')) {
133
+ const pid = parseInt(line.trim(), 10);
134
+ if (!isNaN(pid) && pid !== trackedPid && pid !== process.pid) {
135
+ try { process.kill(pid, 'SIGTERM'); } catch {}
136
+ }
137
+ }
138
+ } catch {
139
+ // pgrep not found or no matches — both fine
140
+ }
141
+ }
142
+
119
143
  /**
120
144
  * Start the daemon process.
121
145
  * Same as Python's start_daemon().
@@ -126,6 +150,9 @@ export function startDaemon() {
126
150
  return true;
127
151
  }
128
152
 
153
+ // Kill any zombie daemons before starting fresh
154
+ killOrphanedDaemons();
155
+
129
156
  mkdirSync(PUSH_DIR, { recursive: true });
130
157
 
131
158
  const daemonScript = join(__dirname, 'daemon.js');
@@ -141,6 +168,7 @@ export function startDaemon() {
141
168
  env: (() => {
142
169
  const env = { ...process.env, PUSH_DAEMON: '1' };
143
170
  delete env.CLAUDECODE; // Strip to avoid leaking into Claude child processes
171
+ delete env.CLAUDE_CODE_ENTRYPOINT; // Strip to avoid any entrypoint-based guards
144
172
  return env;
145
173
  })()
146
174
  });
package/lib/daemon.js CHANGED
@@ -78,6 +78,7 @@ const PID_FILE = join(PUSH_DIR, 'daemon.pid');
78
78
  const LOG_FILE = join(PUSH_DIR, 'daemon.log');
79
79
  const STATUS_FILE = join(PUSH_DIR, 'daemon_status.json');
80
80
  const VERSION_FILE = join(PUSH_DIR, 'daemon.version');
81
+ const LOCK_FILE = join(PUSH_DIR, 'daemon.lock');
81
82
  const CONFIG_FILE = join(CONFIG_DIR, 'config');
82
83
  const MACHINE_ID_FILE = join(CONFIG_DIR, 'machine_id');
83
84
  const REGISTRY_FILE = join(CONFIG_DIR, 'projects.json');
@@ -90,8 +91,17 @@ const LOG_BACKUP_COUNT = 3;
90
91
  const runningTasks = new Map(); // displayNumber -> taskInfo
91
92
  const taskDetails = new Map(); // displayNumber -> details
92
93
  const completedToday = [];
94
+ const COMPLETED_TODAY_MAX = 100;
95
+
96
+ function trackCompleted(entry) {
97
+ completedToday.push(entry);
98
+ if (completedToday.length > COMPLETED_TODAY_MAX) {
99
+ completedToday.splice(0, completedToday.length - COMPLETED_TODAY_MAX);
100
+ }
101
+ }
93
102
  const taskLastOutput = new Map(); // displayNumber -> timestamp
94
103
  const taskStdoutBuffer = new Map(); // displayNumber -> lines[]
104
+ const taskStderrBuffer = new Map(); // displayNumber -> lines[]
95
105
  const taskProjectPaths = new Map(); // displayNumber -> projectPath
96
106
  let daemonStartTime = null;
97
107
 
@@ -125,7 +135,7 @@ function rotateLogs() {
125
135
 
126
136
  function log(message, level = 'INFO') {
127
137
  const timestamp = new Date().toISOString();
128
- const line = `[${timestamp}] [${level}] ${message}\n`;
138
+ const line = `[${timestamp}] [PID:${process.pid}] [${level}] ${message}\n`;
129
139
 
130
140
  if (process.env.PUSH_DAEMON !== '1') {
131
141
  process.stdout.write(line);
@@ -140,6 +150,38 @@ function logError(message) {
140
150
  log(message, 'ERROR');
141
151
  }
142
152
 
153
+ // ==================== Lock File ====================
154
+
155
+ function isProcessRunning(pid) {
156
+ try {
157
+ process.kill(pid, 0);
158
+ return true;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ function acquireLock() {
165
+ try {
166
+ if (existsSync(LOCK_FILE)) {
167
+ const holderPid = parseInt(readFileSync(LOCK_FILE, 'utf8').trim(), 10);
168
+ if (!isNaN(holderPid) && holderPid !== process.pid && isProcessRunning(holderPid)) {
169
+ return false;
170
+ }
171
+ // Holder is dead or is us — clean up stale lock
172
+ try { unlinkSync(LOCK_FILE); } catch {}
173
+ }
174
+ writeFileSync(LOCK_FILE, String(process.pid));
175
+ return true;
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ function releaseLock() {
182
+ try { unlinkSync(LOCK_FILE); } catch {}
183
+ }
184
+
143
185
  // ==================== Mac Notifications ====================
144
186
 
145
187
  function sendMacNotification(title, message, sound = 'default') {
@@ -1024,7 +1066,7 @@ async function autoHealExistingWork(displayNumber, summary, projectPath, taskId)
1024
1066
  }
1025
1067
  }
1026
1068
 
1027
- completedToday.push({
1069
+ trackCompleted({
1028
1070
  displayNumber, summary,
1029
1071
  completedAt: new Date().toISOString(),
1030
1072
  duration: 0, status, prUrl
@@ -1061,7 +1103,7 @@ async function autoHealExistingWork(displayNumber, summary, projectPath, taskId)
1061
1103
  }
1062
1104
  }
1063
1105
 
1064
- completedToday.push({
1106
+ trackCompleted({
1065
1107
  displayNumber, summary,
1066
1108
  completedAt: new Date().toISOString(),
1067
1109
  duration: 0, status, prUrl
@@ -1078,7 +1120,7 @@ async function autoHealExistingWork(displayNumber, summary, projectPath, taskId)
1078
1120
  await updateTaskStatus(displayNumber, 'session_finished', {
1079
1121
  summary: executionSummary
1080
1122
  });
1081
- completedToday.push({
1123
+ trackCompleted({
1082
1124
  displayNumber, summary,
1083
1125
  completedAt: new Date().toISOString(),
1084
1126
  duration: 0, status: 'session_finished', prUrl: newPrUrl
@@ -1380,6 +1422,7 @@ IMPORTANT:
1380
1422
  env: (() => {
1381
1423
  const env = { ...process.env, PUSH_TASK_ID: task.id, PUSH_DISPLAY_NUMBER: String(displayNumber) };
1382
1424
  delete env.CLAUDECODE; // Strip to avoid "nested session" guard in Claude Code
1425
+ delete env.CLAUDE_CODE_ENTRYPOINT; // Strip to avoid any entrypoint-based guards
1383
1426
  return env;
1384
1427
  })()
1385
1428
  });
@@ -1395,6 +1438,20 @@ IMPORTANT:
1395
1438
  runningTasks.set(displayNumber, taskInfo);
1396
1439
  taskLastOutput.set(displayNumber, Date.now());
1397
1440
  taskStdoutBuffer.set(displayNumber, []);
1441
+ taskStderrBuffer.set(displayNumber, []);
1442
+
1443
+ // Monitor stderr (critical for diagnosing fast exits)
1444
+ child.stderr.on('data', (data) => {
1445
+ const lines = data.toString().split('\n');
1446
+ for (const line of lines) {
1447
+ if (line.trim()) {
1448
+ const buffer = taskStderrBuffer.get(displayNumber) || [];
1449
+ buffer.push(line);
1450
+ if (buffer.length > 20) buffer.shift();
1451
+ taskStderrBuffer.set(displayNumber, buffer);
1452
+ }
1453
+ }
1454
+ });
1398
1455
 
1399
1456
  // Monitor stdout
1400
1457
  child.stdout.on('data', (data) => {
@@ -1569,7 +1626,7 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1569
1626
  }
1570
1627
  }
1571
1628
 
1572
- completedToday.push({
1629
+ trackCompleted({
1573
1630
  displayNumber,
1574
1631
  summary,
1575
1632
  completedAt: new Date().toISOString(),
@@ -1579,7 +1636,11 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1579
1636
  sessionId
1580
1637
  });
1581
1638
  } else {
1582
- const stderr = taskInfo.process.stderr?.read()?.toString() || '';
1639
+ const stderrLines = taskStderrBuffer.get(displayNumber) || [];
1640
+ const stderr = stderrLines.join('\n');
1641
+ if (stderr) {
1642
+ log(`Task #${displayNumber} stderr: ${stderr.slice(0, 500)}`);
1643
+ }
1583
1644
 
1584
1645
  // Ask Claude to explain what went wrong (needs worktree path)
1585
1646
  const failureSummary = extractSemanticSummary(worktreePath, sessionId);
@@ -1601,7 +1662,7 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1601
1662
  );
1602
1663
  }
1603
1664
 
1604
- completedToday.push({
1665
+ trackCompleted({
1605
1666
  displayNumber,
1606
1667
  summary,
1607
1668
  completedAt: new Date().toISOString(),
@@ -1614,6 +1675,7 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1614
1675
  taskDetails.delete(displayNumber);
1615
1676
  taskLastOutput.delete(displayNumber);
1616
1677
  taskStdoutBuffer.delete(displayNumber);
1678
+ taskStderrBuffer.delete(displayNumber);
1617
1679
  taskProjectPaths.delete(displayNumber);
1618
1680
  updateStatusFile();
1619
1681
  }
@@ -1744,7 +1806,7 @@ async function checkTimeouts() {
1744
1806
  );
1745
1807
  }
1746
1808
 
1747
- completedToday.push({
1809
+ trackCompleted({
1748
1810
  displayNumber,
1749
1811
  summary: info.summary || 'Unknown task',
1750
1812
  completedAt: new Date().toISOString(),
@@ -1757,6 +1819,7 @@ async function checkTimeouts() {
1757
1819
  taskDetails.delete(displayNumber);
1758
1820
  taskLastOutput.delete(displayNumber);
1759
1821
  taskStdoutBuffer.delete(displayNumber);
1822
+ taskStderrBuffer.delete(displayNumber);
1760
1823
  taskProjectPaths.delete(displayNumber);
1761
1824
  cleanupWorktree(displayNumber, projectPath);
1762
1825
  }
@@ -1799,6 +1862,7 @@ function checkAndApplyUpdate() {
1799
1862
  const daemonScript = join(__dirname, 'daemon.js');
1800
1863
  const selfUpdateEnv = { ...process.env, PUSH_DAEMON: '1' };
1801
1864
  delete selfUpdateEnv.CLAUDECODE; // Strip to avoid leaking into Claude child processes
1865
+ delete selfUpdateEnv.CLAUDE_CODE_ENTRYPOINT; // Strip to avoid any entrypoint-based guards
1802
1866
  const child = spawn(process.execPath, [daemonScript], {
1803
1867
  detached: true,
1804
1868
  stdio: ['ignore', 'ignore', 'ignore'],
@@ -2020,6 +2084,7 @@ async function cleanup() {
2020
2084
  }
2021
2085
 
2022
2086
  // Clean up files
2087
+ releaseLock();
2023
2088
  try { unlinkSync(PID_FILE); } catch {}
2024
2089
 
2025
2090
  // Update status
@@ -2049,6 +2114,15 @@ mkdirSync(CONFIG_DIR, { recursive: true });
2049
2114
  // Rotate logs
2050
2115
  rotateLogs();
2051
2116
 
2117
+ // Acquire lock (prevents zombie daemons from racing)
2118
+ if (!acquireLock()) {
2119
+ const holderPid = parseInt(readFileSync(LOCK_FILE, 'utf8').trim(), 10);
2120
+ // Log to file directly since log() may not work yet
2121
+ const msg = `[${new Date().toISOString()}] [PID:${process.pid}] [INFO] Another daemon (PID ${holderPid}) holds the lock. Exiting.\n`;
2122
+ try { appendFileSync(LOG_FILE, msg); } catch {}
2123
+ process.exit(0);
2124
+ }
2125
+
2052
2126
  // Write PID file
2053
2127
  writeFileSync(PID_FILE, String(process.pid));
2054
2128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "3.10.8",
3
+ "version": "4.0.0",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {