@masslessai/push-todo 4.2.4 → 4.2.6

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.
@@ -279,6 +279,8 @@ export function checkForAgentUpdate(agentType) {
279
279
 
280
280
  /**
281
281
  * Install a specific version of an agent CLI globally.
282
+ * For claude-code: tries npm first, falls back to `claude update` which
283
+ * handles non-npm installations (brew, app installer, happy-coder wrapper).
282
284
  *
283
285
  * @param {string} agentType
284
286
  * @param {string} targetVersion
@@ -288,6 +290,7 @@ export function performAgentUpdate(agentType, targetVersion) {
288
290
  const agent = AGENTS[agentType];
289
291
  if (!agent?.npmPackage) return false;
290
292
 
293
+ // Try npm install first (works for standard npm global installs)
291
294
  try {
292
295
  execFileSync('npm', ['install', '-g', `${agent.npmPackage}@${targetVersion}`], {
293
296
  timeout: 120000,
@@ -295,8 +298,29 @@ export function performAgentUpdate(agentType, targetVersion) {
295
298
  });
296
299
  return true;
297
300
  } catch {
298
- return false;
301
+ // npm failed — fall through to agent-specific fallbacks
299
302
  }
303
+
304
+ // Fallback: use the agent's own update command (handles non-npm installs)
305
+ if (agentType === 'claude-code') {
306
+ try {
307
+ const env = { ...process.env };
308
+ delete env.CLAUDECODE;
309
+ delete env.CLAUDE_CODE_ENTRYPOINT;
310
+ execFileSync('claude', ['update'], {
311
+ timeout: 120000,
312
+ stdio: 'pipe',
313
+ env,
314
+ });
315
+ // Verify the update actually worked
316
+ const after = detectAgentVersion('claude-code');
317
+ return after.installed && after.version != null;
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+
323
+ return false;
300
324
  }
301
325
 
302
326
  /**
@@ -333,6 +357,51 @@ export function checkAllAgentUpdates({ force = false } = {}) {
333
357
  return results;
334
358
  }
335
359
 
360
+ /**
361
+ * Pre-flight check: verify an agent meets minimum version before task execution.
362
+ * If below minimum, attempts an immediate update and rechecks.
363
+ *
364
+ * @param {string} agentType
365
+ * @returns {{ ok: boolean, version: string|null, error: string|null }}
366
+ */
367
+ export function ensureAgentReady(agentType) {
368
+ const agent = AGENTS[agentType];
369
+ if (!agent) {
370
+ return { ok: false, version: null, error: `Unknown agent type: ${agentType}` };
371
+ }
372
+
373
+ const info = detectAgentVersion(agentType);
374
+ if (!info.installed) {
375
+ return { ok: false, version: null, error: `${agentType} CLI not found` };
376
+ }
377
+ if (!info.version) {
378
+ return { ok: false, version: null, error: `${agentType} installed but version unknown` };
379
+ }
380
+
381
+ // Check minimum version
382
+ if (agent.minVersion && compareSemver(info.version, agent.minVersion) < 0) {
383
+ // Attempt immediate update
384
+ const latest = fetchLatestAgentVersion(agentType);
385
+ if (latest?.version) {
386
+ const updated = performAgentUpdate(agentType, latest.version);
387
+ if (updated) {
388
+ // Recheck after update
389
+ const after = detectAgentVersion(agentType);
390
+ if (after.version && compareSemver(after.version, agent.minVersion) >= 0) {
391
+ return { ok: true, version: after.version, error: null };
392
+ }
393
+ }
394
+ }
395
+ return {
396
+ ok: false,
397
+ version: info.version,
398
+ error: `${agentType} v${info.version} is below minimum v${agent.minVersion} (needs --worktree support). Update with: claude update`,
399
+ };
400
+ }
401
+
402
+ return { ok: true, version: info.version, error: null };
403
+ }
404
+
336
405
  // ==================== Version Parity ====================
337
406
 
338
407
  /**
package/lib/daemon.js CHANGED
@@ -25,7 +25,7 @@ import { getProjectContext, buildSmartPrompt, invalidateCache } from './context-
25
25
  import { sendMacNotification } from './utils/notify.js';
26
26
  import { checkAndRunDueJobs } from './cron.js';
27
27
  import { runHeartbeatChecks } from './heartbeat.js';
28
- import { getAgentVersions, formatAgentVersionSummary, checkAllAgentUpdates, performAgentUpdate, checkVersionParity } from './agent-versions.js';
28
+ import { getAgentVersions, formatAgentVersionSummary, checkAllAgentUpdates, performAgentUpdate, checkVersionParity, ensureAgentReady } from './agent-versions.js';
29
29
  import { checkAllProjectsFreshness } from './project-freshness.js';
30
30
  import { getStatus as getLaunchAgentStatus, install as refreshLaunchAgent } from './launchagent.js';
31
31
 
@@ -91,6 +91,7 @@ const LOG_FILE = join(PUSH_DIR, 'daemon.log');
91
91
  const STATUS_FILE = join(PUSH_DIR, 'daemon_status.json');
92
92
  const VERSION_FILE = join(PUSH_DIR, 'daemon.version');
93
93
  const LOCK_FILE = join(PUSH_DIR, 'daemon.lock');
94
+ const COMPLETED_FILE = join(PUSH_DIR, 'completed_tasks.json');
94
95
  const CONFIG_FILE = join(CONFIG_DIR, 'config');
95
96
  const MACHINE_ID_FILE = join(CONFIG_DIR, 'machine_id');
96
97
  const REGISTRY_FILE = join(CONFIG_DIR, 'projects.json');
@@ -110,6 +111,25 @@ function trackCompleted(entry) {
110
111
  if (completedToday.length > COMPLETED_TODAY_MAX) {
111
112
  completedToday.splice(0, completedToday.length - COMPLETED_TODAY_MAX);
112
113
  }
114
+ // Persist to disk so new daemon instances skip already-completed tasks
115
+ try {
116
+ writeFileSync(COMPLETED_FILE, JSON.stringify(completedToday));
117
+ } catch {}
118
+ }
119
+
120
+ function loadCompletedTasks() {
121
+ try {
122
+ if (!existsSync(COMPLETED_FILE)) return;
123
+ const data = JSON.parse(readFileSync(COMPLETED_FILE, 'utf8'));
124
+ if (!Array.isArray(data)) return;
125
+ // Only load entries from the last 24 hours
126
+ const cutoff = Date.now() - 86400000;
127
+ for (const entry of data) {
128
+ if (entry.completedAt && new Date(entry.completedAt).getTime() > cutoff) {
129
+ completedToday.push(entry);
130
+ }
131
+ }
132
+ } catch {}
113
133
  }
114
134
  const taskLastOutput = new Map(); // displayNumber -> timestamp
115
135
  const taskStdoutBuffer = new Map(); // displayNumber -> lines[]
@@ -886,6 +906,16 @@ function createPRForTask(displayNumber, summary, projectPath) {
886
906
  const gitCwd = projectPath || process.cwd();
887
907
 
888
908
  try {
909
+ // Verify branch exists before comparing (worktree may have been auto-cleaned)
910
+ try {
911
+ execFileSync('git', ['rev-parse', '--verify', branch], {
912
+ cwd: gitCwd, timeout: 5000, stdio: 'pipe'
913
+ });
914
+ } catch {
915
+ log(`Branch ${branch} does not exist, skipping PR creation`);
916
+ return null;
917
+ }
918
+
889
919
  // Check if branch has commits
890
920
  const logResult = execSync(`git log HEAD..${branch} --oneline`, {
891
921
  cwd: gitCwd,
@@ -1667,6 +1697,7 @@ function respawnWithInjectedMessage(displayNumber) {
1667
1697
  '--continue', sessionId,
1668
1698
  '-p', injectionPrompt,
1669
1699
  '--verbose',
1700
+ '--worktree', getWorktreeName(displayNumber),
1670
1701
  '--allowedTools', allowedTools,
1671
1702
  '--output-format', 'stream-json',
1672
1703
  '--permission-mode', 'bypassPermissions',
@@ -2230,6 +2261,17 @@ async function executeTask(task) {
2230
2261
  log(`Task #${displayNumber}: Project ${gitRemote} -> ${projectPath}`);
2231
2262
  }
2232
2263
 
2264
+ // Pre-flight: verify agent CLI meets minimum version (attempts auto-update if not)
2265
+ const agentType = taskActionType || 'claude-code';
2266
+ const readiness = ensureAgentReady(agentType);
2267
+ if (!readiness.ok) {
2268
+ logError(`Task #${displayNumber}: ${readiness.error}`);
2269
+ await updateTaskStatus(displayNumber, 'failed', {
2270
+ error: readiness.error
2271
+ }, taskId);
2272
+ return null;
2273
+ }
2274
+
2233
2275
  // Pre-assign session ID so we can store it at claim time (not rely on parsing stdout)
2234
2276
  const preAssignedSessionId = randomUUID();
2235
2277
 
@@ -2332,6 +2374,7 @@ async function executeTask(task) {
2332
2374
  '--continue', previousSessionId,
2333
2375
  '-p', prompt,
2334
2376
  '--verbose',
2377
+ '--worktree', worktreeName,
2335
2378
  '--allowedTools', allowedTools,
2336
2379
  '--output-format', 'stream-json',
2337
2380
  '--permission-mode', 'bypassPermissions',
@@ -2340,6 +2383,7 @@ async function executeTask(task) {
2340
2383
  : [
2341
2384
  '-p', prompt,
2342
2385
  '--verbose',
2386
+ '--worktree', worktreeName,
2343
2387
  '--allowedTools', allowedTools,
2344
2388
  '--output-format', 'stream-json',
2345
2389
  '--permission-mode', 'bypassPermissions',
@@ -3200,6 +3244,14 @@ async function recoverOrphanedTasks() {
3200
3244
  for (const task of orphaned) {
3201
3245
  const dn = task.displayNumber || task.display_number;
3202
3246
  const tid = task.id || task.todo_id || null;
3247
+
3248
+ // Skip tasks already completed by the previous daemon instance
3249
+ // (race condition: API may return stale 'running' status after session_finished update)
3250
+ if (completedToday.some(c => c.displayNumber === dn)) {
3251
+ log(`Task #${dn}: skipping orphan recovery — already completed by previous daemon`);
3252
+ continue;
3253
+ }
3254
+
3203
3255
  log(`Task #${dn}: resetting from 'running' to 'queued' (orphaned by restart)`);
3204
3256
  await updateTaskStatus(dn, 'queued', {
3205
3257
  event: {
@@ -3259,6 +3311,9 @@ async function mainLoop() {
3259
3311
  writeFileSync(VERSION_FILE, getVersion());
3260
3312
  } catch {}
3261
3313
 
3314
+ // Load completed tasks from previous daemon instance (prevents re-execution)
3315
+ loadCompletedTasks();
3316
+
3262
3317
  // Recover orphaned tasks from previous daemon instance
3263
3318
  // When the daemon restarts (self-update, crash, reboot), tasks may be stuck
3264
3319
  // in 'running' with no process actually working on them. Reset them to 'queued'
@@ -68,7 +68,7 @@ function generatePlist() {
68
68
  <key>PUSH_DAEMON</key>
69
69
  <string>1</string>
70
70
  <key>PATH</key>
71
- <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${dirname(nodeBin)}</string>
71
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${dirname(nodeBin)}:${join(homedir(), '.local', 'bin')}</string>
72
72
  </dict>
73
73
 
74
74
  <key>RunAtLoad</key>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.2.4",
3
+ "version": "4.2.6",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {