@masslessai/push-todo 4.1.4 → 4.1.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.
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Agent version detection and tracking for Push daemon.
2
+ * Agent version detection, tracking, and auto-update for Push daemon.
3
3
  *
4
4
  * Detects installed versions of Claude Code, OpenAI Codex, and OpenClaw CLIs.
5
5
  * Reports version parity with the push-todo CLI and flags outdated agents.
6
+ * Can auto-update agents via npm when enabled.
6
7
  *
7
8
  * Pattern: follows heartbeat.js — pure functions, internally throttled, non-fatal.
8
9
  */
@@ -11,25 +12,33 @@ import { execFileSync } from 'child_process';
11
12
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
12
13
  import { homedir } from 'os';
13
14
  import { join } from 'path';
15
+ import { compareSemver } from './self-update.js';
14
16
 
15
17
  const PUSH_DIR = join(homedir(), '.push');
16
18
  const VERSIONS_CACHE_FILE = join(PUSH_DIR, 'agent_versions.json');
19
+ const LAST_AGENT_UPDATE_FILE = join(PUSH_DIR, 'last_agent_update_check');
17
20
  const CHECK_INTERVAL = 3600000; // 1 hour
21
+ const AGENT_UPDATE_CHECK_INTERVAL = 3600000; // 1 hour
22
+ const AGENT_VERSION_AGE_GATE = 3600000; // 1 hour — only install versions >1hr old
18
23
 
19
24
  // ==================== Agent Definitions ====================
20
25
 
21
26
  /**
22
- * Agent CLI definitions: command name, version flag, and how to parse output.
27
+ * Agent CLI definitions: command name, version flag, npm package, and how to parse output.
23
28
  *
24
29
  * Each agent has:
25
30
  * - cmd: the CLI binary name
26
31
  * - versionArgs: args to get version string
32
+ * - npmPackage: the npm package name for install/update
27
33
  * - parseVersion: extracts semver from command output
34
+ * - minVersion: minimum version required for push-todo compatibility (null = no minimum)
28
35
  */
29
36
  const AGENTS = {
30
37
  'claude-code': {
31
38
  cmd: 'claude',
32
39
  versionArgs: ['--version'],
40
+ npmPackage: '@anthropic-ai/claude-code',
41
+ minVersion: '2.0.0', // --worktree support
33
42
  parseVersion(output) {
34
43
  // "claude v2.1.41" or just "2.1.41"
35
44
  const match = output.match(/(\d+\.\d+\.\d+)/);
@@ -39,6 +48,8 @@ const AGENTS = {
39
48
  'openai-codex': {
40
49
  cmd: 'codex',
41
50
  versionArgs: ['--version'],
51
+ npmPackage: '@openai/codex',
52
+ minVersion: null,
42
53
  parseVersion(output) {
43
54
  const match = output.match(/(\d+\.\d+\.\d+)/);
44
55
  return match ? match[1] : null;
@@ -47,6 +58,8 @@ const AGENTS = {
47
58
  'openclaw': {
48
59
  cmd: 'openclaw',
49
60
  versionArgs: ['--version'],
61
+ npmPackage: 'openclaw',
62
+ minVersion: null,
50
63
  parseVersion(output) {
51
64
  const match = output.match(/(\d+\.\d+\.\d+)/);
52
65
  return match ? match[1] : null;
@@ -202,3 +215,147 @@ export function formatAgentVersionSummary(versions) {
202
215
  export function getKnownAgentTypes() {
203
216
  return Object.keys(AGENTS);
204
217
  }
218
+
219
+ // ==================== Agent Update ====================
220
+
221
+ /**
222
+ * Fetch latest version info for an agent from npm.
223
+ *
224
+ * @param {string} agentType
225
+ * @returns {{ version: string, publishedAt: string|null }|null}
226
+ */
227
+ function fetchLatestAgentVersion(agentType) {
228
+ const agent = AGENTS[agentType];
229
+ if (!agent?.npmPackage) return null;
230
+
231
+ try {
232
+ const result = execFileSync('npm', ['view', agent.npmPackage, '--json'], {
233
+ timeout: 15000,
234
+ encoding: 'utf8',
235
+ stdio: ['ignore', 'pipe', 'pipe'],
236
+ });
237
+ const data = JSON.parse(result);
238
+ const latest = data['dist-tags']?.latest || data.version;
239
+ return {
240
+ version: latest,
241
+ publishedAt: data.time?.[latest] || null,
242
+ };
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Check if an agent has an update available.
250
+ *
251
+ * @param {string} agentType
252
+ * @returns {{ available: boolean, current: string|null, latest: string|null, reason: string }}
253
+ */
254
+ export function checkForAgentUpdate(agentType) {
255
+ const currentInfo = detectAgentVersion(agentType);
256
+ if (!currentInfo.installed || !currentInfo.version) {
257
+ return { available: false, current: null, latest: null, reason: 'not_installed' };
258
+ }
259
+
260
+ const latestInfo = fetchLatestAgentVersion(agentType);
261
+ if (!latestInfo) {
262
+ return { available: false, current: currentInfo.version, latest: null, reason: 'registry_unreachable' };
263
+ }
264
+
265
+ if (compareSemver(currentInfo.version, latestInfo.version) >= 0) {
266
+ return { available: false, current: currentInfo.version, latest: latestInfo.version, reason: 'up_to_date' };
267
+ }
268
+
269
+ // Safety: only update to versions published >1 hour ago
270
+ if (latestInfo.publishedAt) {
271
+ const publishedAge = Date.now() - new Date(latestInfo.publishedAt).getTime();
272
+ if (publishedAge < AGENT_VERSION_AGE_GATE) {
273
+ return { available: false, current: currentInfo.version, latest: latestInfo.version, reason: 'too_recent' };
274
+ }
275
+ }
276
+
277
+ return { available: true, current: currentInfo.version, latest: latestInfo.version, reason: 'update_available' };
278
+ }
279
+
280
+ /**
281
+ * Install a specific version of an agent CLI globally.
282
+ *
283
+ * @param {string} agentType
284
+ * @param {string} targetVersion
285
+ * @returns {boolean} true if update succeeded
286
+ */
287
+ export function performAgentUpdate(agentType, targetVersion) {
288
+ const agent = AGENTS[agentType];
289
+ if (!agent?.npmPackage) return false;
290
+
291
+ try {
292
+ execFileSync('npm', ['install', '-g', `${agent.npmPackage}@${targetVersion}`], {
293
+ timeout: 120000,
294
+ stdio: 'pipe',
295
+ });
296
+ return true;
297
+ } catch {
298
+ return false;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Check all installed agents for updates (throttled).
304
+ * Returns update results or null if throttled.
305
+ *
306
+ * @param {{ force?: boolean }} options
307
+ * @returns {Object.<string, { available: boolean, current: string|null, latest: string|null, reason: string }>|null}
308
+ */
309
+ export function checkAllAgentUpdates({ force = false } = {}) {
310
+ // Throttle check
311
+ if (!force && existsSync(LAST_AGENT_UPDATE_FILE)) {
312
+ try {
313
+ const lastCheck = parseInt(readFileSync(LAST_AGENT_UPDATE_FILE, 'utf8').trim(), 10);
314
+ if (Date.now() - lastCheck < AGENT_UPDATE_CHECK_INTERVAL) {
315
+ return null;
316
+ }
317
+ } catch {}
318
+ }
319
+
320
+ // Record check time
321
+ try {
322
+ mkdirSync(PUSH_DIR, { recursive: true });
323
+ writeFileSync(LAST_AGENT_UPDATE_FILE, String(Date.now()));
324
+ } catch {}
325
+
326
+ const results = {};
327
+ for (const agentType of Object.keys(AGENTS)) {
328
+ const info = detectAgentVersion(agentType);
329
+ if (info.installed) {
330
+ results[agentType] = checkForAgentUpdate(agentType);
331
+ }
332
+ }
333
+ return results;
334
+ }
335
+
336
+ // ==================== Version Parity ====================
337
+
338
+ /**
339
+ * Check version parity between installed agents and push-todo requirements.
340
+ * Returns warnings for agents that are below minimum required versions.
341
+ *
342
+ * @returns {{ agentType: string, installed: string, required: string }[]}
343
+ */
344
+ export function checkVersionParity() {
345
+ const warnings = [];
346
+ for (const [agentType, agent] of Object.entries(AGENTS)) {
347
+ if (!agent.minVersion) continue;
348
+
349
+ const info = detectAgentVersion(agentType);
350
+ if (info.installed && info.version) {
351
+ if (compareSemver(info.version, agent.minVersion) < 0) {
352
+ warnings.push({
353
+ agentType,
354
+ installed: info.version,
355
+ required: agent.minVersion,
356
+ });
357
+ }
358
+ }
359
+ }
360
+ return warnings;
361
+ }
package/lib/config.js CHANGED
@@ -213,6 +213,27 @@ export function setAutoUpdateEnabled(enabled) {
213
213
  return setConfigValue('AUTO_UPDATE', enabled ? 'true' : 'false');
214
214
  }
215
215
 
216
+ /**
217
+ * Check if auto-update-agents is enabled for daemon agent CLI updates.
218
+ * Default: false (agent updates are opt-in since they're third-party CLIs)
219
+ *
220
+ * @returns {boolean}
221
+ */
222
+ export function getAutoUpdateAgentsEnabled() {
223
+ const value = getConfigValue('AUTO_UPDATE_AGENTS', 'false');
224
+ return value.toLowerCase() === 'true' || value === '1' || value.toLowerCase() === 'yes';
225
+ }
226
+
227
+ /**
228
+ * Set auto-update-agents setting.
229
+ *
230
+ * @param {boolean} enabled
231
+ * @returns {boolean} True if successful
232
+ */
233
+ export function setAutoUpdateAgentsEnabled(enabled) {
234
+ return setConfigValue('AUTO_UPDATE_AGENTS', enabled ? 'true' : 'false');
235
+ }
236
+
216
237
  /**
217
238
  * Get the maximum batch size for queuing tasks.
218
239
  * Default: 5
@@ -292,6 +313,7 @@ export function showSettings() {
292
313
  const autoMerge = getAutoMergeEnabled();
293
314
  const autoComplete = getAutoCompleteEnabled();
294
315
  const autoUpdate = getAutoUpdateEnabled();
316
+ const autoUpdateAgents = getAutoUpdateAgentsEnabled();
295
317
  const batchSize = getMaxBatchSize();
296
318
 
297
319
  console.log(` auto-commit: ${autoCommit ? 'ON' : 'OFF'}`);
@@ -304,7 +326,10 @@ export function showSettings() {
304
326
  console.log(' Mark task completed after successful merge');
305
327
  console.log();
306
328
  console.log(` auto-update: ${autoUpdate ? 'ON' : 'OFF'}`);
307
- console.log(' Daemon auto-updates from npm when idle');
329
+ console.log(' Daemon auto-updates push-todo from npm when idle');
330
+ console.log();
331
+ console.log(` auto-update-agents: ${autoUpdateAgents ? 'ON' : 'OFF'}`);
332
+ console.log(' Daemon auto-updates agent CLIs (Claude, Codex, OpenClaw)');
308
333
  console.log();
309
334
  console.log(` batch-size: ${batchSize}`);
310
335
  console.log(' Max tasks for batch queue (1-20)');
@@ -387,6 +412,22 @@ export function toggleSetting(settingName) {
387
412
  return false;
388
413
  }
389
414
 
415
+ if (normalized === 'auto-update-agents') {
416
+ const current = getAutoUpdateAgentsEnabled();
417
+ const newValue = !current;
418
+ if (setAutoUpdateAgentsEnabled(newValue)) {
419
+ console.log(`Auto-update-agents is now ${newValue ? 'ON' : 'OFF'}`);
420
+ if (newValue) {
421
+ console.log('Daemon will auto-update agent CLIs (Claude Code, Codex, OpenClaw) when idle.');
422
+ } else {
423
+ console.log('Agent CLIs will NOT be auto-updated. Use "push-todo update" for manual updates.');
424
+ }
425
+ return true;
426
+ }
427
+ console.error('Failed to update setting');
428
+ return false;
429
+ }
430
+
390
431
  if (normalized === 'batch-size') {
391
432
  const batchSize = getMaxBatchSize();
392
433
  console.log(`Current batch size: ${batchSize}`);
@@ -395,7 +436,7 @@ export function toggleSetting(settingName) {
395
436
  }
396
437
 
397
438
  console.error(`Unknown setting: ${settingName}`);
398
- console.error('Available settings: auto-commit, auto-merge, auto-complete, auto-update, batch-size');
439
+ console.error('Available settings: auto-commit, auto-merge, auto-complete, auto-update, auto-update-agents, batch-size');
399
440
  return false;
400
441
  }
401
442
 
package/lib/daemon.js CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { randomUUID } from 'crypto';
17
17
  import { spawn, execSync, execFileSync } from 'child_process';
18
- import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, unlinkSync, statSync, renameSync } from 'fs';
18
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, unlinkSync, statSync, renameSync, readdirSync } from 'fs';
19
19
  import { homedir, hostname, platform } from 'os';
20
20
  import { join, dirname } from 'path';
21
21
  import { fileURLToPath } from 'url';
@@ -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 } from './agent-versions.js';
28
+ import { getAgentVersions, formatAgentVersionSummary, checkAllAgentUpdates, performAgentUpdate, checkVersionParity } from './agent-versions.js';
29
29
  import { checkAllProjectsFreshness } from './project-freshness.js';
30
30
 
31
31
  const __filename = fileURLToPath(import.meta.url);
@@ -48,6 +48,10 @@ const RETRY_BACKOFF_FACTOR = 2;
48
48
  const CERTAINTY_HIGH_THRESHOLD = 0.7;
49
49
  const CERTAINTY_LOW_THRESHOLD = 0.4;
50
50
 
51
+ // Idle auto-recovery (Level B)
52
+ const IDLE_TIMEOUT_MS = 900000; // 15 min no stdout → kill (smarter than absolute timeout)
53
+ const HEARTBEAT_INTERVAL_MS = 300000; // 5 min progress heartbeat to Supabase (Level A)
54
+
51
55
  // Stuck detection
52
56
  const STUCK_IDLE_THRESHOLD = 600000; // 10 min
53
57
  const STUCK_WARNING_THRESHOLD = 300000; // 5 min
@@ -110,6 +114,7 @@ const taskLastOutput = new Map(); // displayNumber -> timestamp
110
114
  const taskStdoutBuffer = new Map(); // displayNumber -> lines[]
111
115
  const taskStderrBuffer = new Map(); // displayNumber -> lines[]
112
116
  const taskProjectPaths = new Map(); // displayNumber -> projectPath
117
+ const taskLastHeartbeat = new Map(); // displayNumber -> timestamp of last progress heartbeat
113
118
  let daemonStartTime = null;
114
119
 
115
120
  // ==================== Utilities ====================
@@ -277,12 +282,70 @@ function getAutoUpdateEnabled() {
277
282
  return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
278
283
  }
279
284
 
285
+ function getAutoUpdateAgentsEnabled() {
286
+ const v = getConfigValueFromFile('AUTO_UPDATE_AGENTS', 'false');
287
+ return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
288
+ }
289
+
280
290
  // ==================== Capabilities Detection ====================
281
291
 
282
292
  let cachedCapabilities = null;
283
293
  let lastCapabilityCheck = 0;
284
294
  const CAPABILITY_CHECK_INTERVAL = 3600000; // 1 hour
285
295
 
296
+ /**
297
+ * Discover skills for all registered projects.
298
+ * Scans ~/.claude/skills/ (global) and <projectPath>/.claude/skills/ (per-project).
299
+ * Returns: { "github.com/user/repo": ["skill1", "skill2"], ... }
300
+ */
301
+ function discoverProjectSkills() {
302
+ const globalSkillsDir = join(homedir(), '.claude', 'skills');
303
+ const globalSkills = [];
304
+
305
+ // Enumerate global skills
306
+ if (existsSync(globalSkillsDir)) {
307
+ try {
308
+ for (const entry of readdirSync(globalSkillsDir, { withFileTypes: true })) {
309
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
310
+ globalSkills.push(entry.name);
311
+ }
312
+ }
313
+ } catch { /* ignore */ }
314
+ }
315
+
316
+ // For each registered project, enumerate project-local skills and merge with global
317
+ const result = {};
318
+ if (!existsSync(REGISTRY_FILE)) return result;
319
+
320
+ try {
321
+ const data = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
322
+ for (const [, info] of Object.entries(data.projects || {})) {
323
+ const remote = info.gitRemote;
324
+ const localPath = info.localPath || info.local_path;
325
+ if (!remote || !localPath) continue;
326
+
327
+ const projectSkillsDir = join(localPath, '.claude', 'skills');
328
+ const projectSkills = new Set(globalSkills);
329
+
330
+ if (existsSync(projectSkillsDir)) {
331
+ try {
332
+ for (const entry of readdirSync(projectSkillsDir, { withFileTypes: true })) {
333
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
334
+ projectSkills.add(entry.name);
335
+ }
336
+ }
337
+ } catch { /* ignore */ }
338
+ }
339
+
340
+ if (projectSkills.size > 0) {
341
+ result[remote] = [...projectSkills].sort();
342
+ }
343
+ }
344
+ } catch { /* ignore */ }
345
+
346
+ return result;
347
+ }
348
+
286
349
  function detectCapabilities() {
287
350
  const caps = {
288
351
  auto_merge: getAutoMergeEnabled(),
@@ -302,6 +365,8 @@ function detectCapabilities() {
302
365
  caps.gh_cli = 'not_installed';
303
366
  }
304
367
 
368
+ caps.project_skills = discoverProjectSkills();
369
+
305
370
  return caps;
306
371
  }
307
372
 
@@ -1340,6 +1405,151 @@ function checkTaskIdle(displayNumber) {
1340
1405
  return false;
1341
1406
  }
1342
1407
 
1408
+ // ==================== Progress Heartbeat (Level A) ====================
1409
+
1410
+ async function sendProgressHeartbeats() {
1411
+ const now = Date.now();
1412
+
1413
+ for (const [displayNumber, taskInfo] of runningTasks) {
1414
+ const info = taskDetails.get(displayNumber) || {};
1415
+
1416
+ // Skip tasks awaiting user confirmation (not truly hanging)
1417
+ if (info.phase === 'awaiting_confirmation') continue;
1418
+
1419
+ // Throttle: only send every HEARTBEAT_INTERVAL_MS
1420
+ const lastHeartbeat = taskLastHeartbeat.get(displayNumber) || taskInfo.startTime;
1421
+ if (now - lastHeartbeat < HEARTBEAT_INTERVAL_MS) continue;
1422
+
1423
+ // Compute metrics
1424
+ const elapsedSec = Math.floor((now - taskInfo.startTime) / 1000);
1425
+ const elapsedMin = Math.floor(elapsedSec / 60);
1426
+ const lastOutputTs = taskLastOutput.get(displayNumber);
1427
+ const idleSec = lastOutputTs ? Math.floor((now - lastOutputTs) / 1000) : elapsedSec;
1428
+ const idleMin = Math.floor(idleSec / 60);
1429
+
1430
+ const activityDesc = idleSec < 60 ? 'active' : `idle ${idleMin}m`;
1431
+ const phase = info.phase || 'executing';
1432
+ const eventSummary = `Running for ${elapsedMin}m. Last activity: ${activityDesc}. Phase: ${phase}.`;
1433
+
1434
+ log(`Task #${displayNumber}: sending progress heartbeat (${eventSummary})`);
1435
+
1436
+ // Update throttle timestamp BEFORE the async call to prevent concurrent sends
1437
+ taskLastHeartbeat.set(displayNumber, now);
1438
+
1439
+ // Send event-only update (no status field) — non-fatal if it fails
1440
+ const taskId = info.taskId || null;
1441
+ apiRequest('update-task-execution', {
1442
+ method: 'PATCH',
1443
+ body: JSON.stringify({
1444
+ todoId: taskId,
1445
+ displayNumber,
1446
+ event: {
1447
+ type: 'progress',
1448
+ timestamp: new Date().toISOString(),
1449
+ machineName: getMachineName() || undefined,
1450
+ summary: eventSummary
1451
+ }
1452
+ // No status field — event-only update, won't change execution_status
1453
+ })
1454
+ }).catch(err => {
1455
+ log(`Task #${displayNumber}: heartbeat failed (non-fatal): ${err.message}`);
1456
+ });
1457
+ // NOTE: intentionally not awaited — heartbeats are fire-and-forget
1458
+ // to avoid blocking the poll loop when Supabase is slow
1459
+ }
1460
+ }
1461
+
1462
+ // ==================== Idle Auto-Recovery (Level B) ====================
1463
+
1464
+ async function killIdleTasks() {
1465
+ const now = Date.now();
1466
+ const idleTimedOut = [];
1467
+
1468
+ for (const [displayNumber, taskInfo] of runningTasks) {
1469
+ const info = taskDetails.get(displayNumber) || {};
1470
+
1471
+ // Exempt: tasks awaiting user confirmation
1472
+ if (info.phase === 'awaiting_confirmation') continue;
1473
+
1474
+ const lastOutput = taskLastOutput.get(displayNumber);
1475
+ if (!lastOutput) continue; // No output tracking yet — not idle, just starting
1476
+
1477
+ const idleMs = now - lastOutput;
1478
+ if (idleMs > IDLE_TIMEOUT_MS) {
1479
+ log(`Task #${displayNumber} IDLE TIMEOUT: ${Math.floor(idleMs / 1000)}s since last output`);
1480
+ idleTimedOut.push(displayNumber);
1481
+ }
1482
+ }
1483
+
1484
+ for (const displayNumber of idleTimedOut) {
1485
+ const taskInfo = runningTasks.get(displayNumber);
1486
+ if (!taskInfo) continue;
1487
+
1488
+ const info = taskDetails.get(displayNumber) || {};
1489
+ const projectPath = taskProjectPaths.get(displayNumber);
1490
+ const elapsedSec = Math.floor((now - taskInfo.startTime) / 1000);
1491
+ const idleSec = Math.floor((now - (taskLastOutput.get(displayNumber) || taskInfo.startTime)) / 1000);
1492
+ const durationStr = elapsedSec < 60 ? `${elapsedSec}s` : `${Math.floor(elapsedSec / 60)}m ${elapsedSec % 60}s`;
1493
+ const machineName = getMachineName() || 'Mac';
1494
+
1495
+ // Extract semantic summary WHILE session is still alive
1496
+ // (a live session produces a better "what have you done so far" answer)
1497
+ const sessionId = taskInfo.sessionId;
1498
+ const worktreePath = getWorktreePath(displayNumber, projectPath);
1499
+ const summaryPath = existsSync(worktreePath) ? worktreePath : (projectPath || process.cwd());
1500
+ log(`Task #${displayNumber}: extracting pre-kill summary...`);
1501
+ const idleSummary = extractSemanticSummary(summaryPath, sessionId);
1502
+
1503
+ // Now kill the process
1504
+ log(`Task #${displayNumber}: killing idle process (PID: ${taskInfo.process.pid})`);
1505
+ try {
1506
+ taskInfo.process.kill('SIGTERM');
1507
+ await new Promise(r => setTimeout(r, 5000));
1508
+ taskInfo.process.kill('SIGKILL');
1509
+ } catch {}
1510
+
1511
+ runningTasks.delete(displayNumber);
1512
+ cleanupWorktree(displayNumber, projectPath);
1513
+
1514
+ const idleError = idleSummary
1515
+ ? `${idleSummary}\nSession went idle for ${Math.floor(idleSec / 60)}m with no output. Killed after ${durationStr} on ${machineName}.`
1516
+ : `Session went idle for ${Math.floor(idleSec / 60)}m with no output (limit: ${IDLE_TIMEOUT_MS / 60000}m). Killed after ${durationStr} on ${machineName}.`;
1517
+
1518
+ await updateTaskStatus(displayNumber, 'failed', {
1519
+ error: idleError,
1520
+ sessionId
1521
+ }, info.taskId);
1522
+
1523
+ if (NOTIFY_ON_FAILURE) {
1524
+ sendMacNotification(
1525
+ `Task #${displayNumber} idle timeout`,
1526
+ `${(info.summary || 'Unknown').slice(0, 40)}... idle ${Math.floor(idleSec / 60)}m`,
1527
+ 'Basso'
1528
+ );
1529
+ }
1530
+
1531
+ trackCompleted({
1532
+ displayNumber,
1533
+ summary: info.summary || 'Unknown task',
1534
+ completedAt: new Date().toISOString(),
1535
+ duration: elapsedSec,
1536
+ status: 'idle_timeout'
1537
+ });
1538
+
1539
+ // Full cleanup of all tracking Maps
1540
+ taskDetails.delete(displayNumber);
1541
+ taskLastOutput.delete(displayNumber);
1542
+ taskStdoutBuffer.delete(displayNumber);
1543
+ taskStderrBuffer.delete(displayNumber);
1544
+ taskProjectPaths.delete(displayNumber);
1545
+ taskLastHeartbeat.delete(displayNumber);
1546
+ }
1547
+
1548
+ if (idleTimedOut.length > 0) {
1549
+ updateStatusFile();
1550
+ }
1551
+ }
1552
+
1343
1553
  // ==================== Session ID Extraction ====================
1344
1554
 
1345
1555
  function extractSessionIdFromStdout(proc, buffer) {
@@ -1616,7 +1826,6 @@ async function executeTask(task) {
1616
1826
  ? [
1617
1827
  '--continue', previousSessionId,
1618
1828
  '-p', prompt,
1619
- '--worktree', worktreeName,
1620
1829
  '--allowedTools', allowedTools,
1621
1830
  '--output-format', 'json',
1622
1831
  '--permission-mode', 'bypassPermissions',
@@ -1624,7 +1833,6 @@ async function executeTask(task) {
1624
1833
  ]
1625
1834
  : [
1626
1835
  '-p', prompt,
1627
- '--worktree', worktreeName,
1628
1836
  '--allowedTools', allowedTools,
1629
1837
  '--output-format', 'json',
1630
1838
  '--permission-mode', 'bypassPermissions',
@@ -1660,6 +1868,7 @@ async function executeTask(task) {
1660
1868
  taskLastOutput.set(displayNumber, Date.now());
1661
1869
  taskStdoutBuffer.set(displayNumber, []);
1662
1870
  taskStderrBuffer.set(displayNumber, []);
1871
+ taskLastHeartbeat.set(displayNumber, Date.now());
1663
1872
 
1664
1873
  // Monitor stderr (critical for diagnosing fast exits)
1665
1874
  child.stderr.on('data', (data) => {
@@ -1715,6 +1924,7 @@ async function executeTask(task) {
1715
1924
  runningTasks.delete(displayNumber);
1716
1925
  await updateTaskStatus(displayNumber, 'failed', { error: error.message }, taskId);
1717
1926
  taskDetails.delete(displayNumber);
1927
+ taskLastHeartbeat.delete(displayNumber);
1718
1928
  updateStatusFile();
1719
1929
  });
1720
1930
 
@@ -1938,6 +2148,7 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1938
2148
  taskStdoutBuffer.delete(displayNumber);
1939
2149
  taskStderrBuffer.delete(displayNumber);
1940
2150
  taskProjectPaths.delete(displayNumber);
2151
+ taskLastHeartbeat.delete(displayNumber);
1941
2152
  updateStatusFile();
1942
2153
  }
1943
2154
 
@@ -2016,6 +2227,13 @@ function updateStatusFile() {
2016
2227
  // ==================== Task Checking ====================
2017
2228
 
2018
2229
  async function checkTimeouts() {
2230
+ // Level A: Send progress heartbeats for long-running tasks
2231
+ await sendProgressHeartbeats();
2232
+
2233
+ // Level B: Kill tasks that have been idle too long (fires at 15 min, before 60 min absolute)
2234
+ await killIdleTasks();
2235
+
2236
+ // Absolute timeout (safety net — 60 min wall clock)
2019
2237
  const now = Date.now();
2020
2238
  const timedOut = [];
2021
2239
 
@@ -2082,6 +2300,7 @@ async function checkTimeouts() {
2082
2300
  taskStdoutBuffer.delete(displayNumber);
2083
2301
  taskStderrBuffer.delete(displayNumber);
2084
2302
  taskProjectPaths.delete(displayNumber);
2303
+ taskLastHeartbeat.delete(displayNumber);
2085
2304
  cleanupWorktree(displayNumber, projectPath);
2086
2305
  }
2087
2306
 
@@ -2141,6 +2360,55 @@ function checkAndApplyUpdate() {
2141
2360
  }
2142
2361
  }
2143
2362
 
2363
+ // ==================== Agent Auto-Update ====================
2364
+
2365
+ let pendingAgentUpdates = null; // { agentType: targetVersion, ... }
2366
+
2367
+ function checkAndApplyAgentUpdates() {
2368
+ // Check for updates (throttled internally to once per hour)
2369
+ if (!pendingAgentUpdates) {
2370
+ const results = checkAllAgentUpdates();
2371
+ if (!results) return; // throttled
2372
+
2373
+ const available = {};
2374
+ for (const [agentType, info] of Object.entries(results)) {
2375
+ if (info.available) {
2376
+ available[agentType] = info.latest;
2377
+ log(`Agent update available: ${agentType} v${info.current} -> v${info.latest}`);
2378
+ }
2379
+ }
2380
+ if (Object.keys(available).length > 0) {
2381
+ pendingAgentUpdates = available;
2382
+ }
2383
+ }
2384
+
2385
+ // Only apply when no tasks are running
2386
+ if (pendingAgentUpdates && runningTasks.size === 0) {
2387
+ for (const [agentType, targetVersion] of Object.entries(pendingAgentUpdates)) {
2388
+ log(`Updating ${agentType} to v${targetVersion}...`);
2389
+ const success = performAgentUpdate(agentType, targetVersion);
2390
+ if (success) {
2391
+ log(`${agentType} updated to v${targetVersion}`);
2392
+ sendMacNotification(
2393
+ 'Push: Agent Updated',
2394
+ `${agentType} updated to v${targetVersion}`,
2395
+ 'Glass'
2396
+ );
2397
+ } else {
2398
+ logError(`${agentType} update to v${targetVersion} failed`);
2399
+ }
2400
+ }
2401
+ pendingAgentUpdates = null;
2402
+ }
2403
+ }
2404
+
2405
+ function logVersionParityWarnings() {
2406
+ const warnings = checkVersionParity();
2407
+ for (const w of warnings) {
2408
+ log(`WARNING: ${w.agentType} v${w.installed} is below minimum v${w.required} — some features may not work`);
2409
+ }
2410
+ }
2411
+
2144
2412
  // ==================== Main Loop ====================
2145
2413
 
2146
2414
  async function pollAndExecute() {
@@ -2243,10 +2511,12 @@ async function mainLoop() {
2243
2511
  log(`Max concurrent tasks: ${MAX_CONCURRENT_TASKS}`);
2244
2512
  log(`E2EE: ${e2eeAvailable ? 'Available' : 'Not available'}`);
2245
2513
  log(`Auto-update: ${getAutoUpdateEnabled() ? 'Enabled' : 'Disabled'}`);
2514
+ log(`Auto-update-agents: ${getAutoUpdateAgentsEnabled() ? 'Enabled' : 'Disabled'}`);
2246
2515
  const caps = getCapabilities();
2247
2516
  log(`Capabilities: gh=${caps.gh_cli}, auto-merge=${caps.auto_merge}, auto-complete=${caps.auto_complete}`);
2248
2517
  const agentVersions = getAgentVersions({ force: true });
2249
2518
  log(`Agent versions: ${formatAgentVersionSummary(agentVersions)}`);
2519
+ logVersionParityWarnings();
2250
2520
  log(`Log file: ${LOG_FILE}`);
2251
2521
 
2252
2522
  // Show registered projects
@@ -2345,6 +2615,15 @@ async function mainLoop() {
2345
2615
  if (getAutoUpdateEnabled()) {
2346
2616
  checkAndApplyUpdate();
2347
2617
  }
2618
+
2619
+ // Agent CLI auto-update (throttled to once per hour, only applies when idle)
2620
+ if (getAutoUpdateAgentsEnabled()) {
2621
+ try {
2622
+ checkAndApplyAgentUpdates();
2623
+ } catch (error) {
2624
+ logError(`Agent auto-update error: ${error.message}`);
2625
+ }
2626
+ }
2348
2627
  } catch (error) {
2349
2628
  logError(`Poll error: ${error.message}`);
2350
2629
  }
@@ -17,12 +17,16 @@ const LAST_UPDATE_CHECK_FILE = join(PUSH_DIR, 'last_update_check');
17
17
  const UPDATE_CHECK_INTERVAL = 3600000; // 1 hour
18
18
 
19
19
  /**
20
- * Compare semver strings.
20
+ * Compare semver strings (strips pre-release/build metadata before comparing).
21
+ * Handles formats like "2.1.41", "2026.2.22-2", "1.0.0+build.123".
21
22
  * @returns -1 if a < b, 0 if equal, 1 if a > b
22
23
  */
23
24
  export function compareSemver(a, b) {
24
- const pa = a.split('.').map(Number);
25
- const pb = b.split('.').map(Number);
25
+ // Strip pre-release (-beta.1) and build metadata (+build.123)
26
+ const cleanA = a.split('-')[0].split('+')[0];
27
+ const cleanB = b.split('-')[0].split('+')[0];
28
+ const pa = cleanA.split('.').map(Number);
29
+ const pb = cleanB.split('.').map(Number);
26
30
  for (let i = 0; i < 3; i++) {
27
31
  if ((pa[i] || 0) < (pb[i] || 0)) return -1;
28
32
  if ((pa[i] || 0) > (pb[i] || 0)) return 1;
package/lib/update.js CHANGED
@@ -1,25 +1,30 @@
1
1
  /**
2
2
  * Manual update orchestrator for Push CLI.
3
3
  *
4
- * `push-todo update` performs three actions:
4
+ * `push-todo update` performs four actions:
5
5
  * 1. Self-update: check and install latest push-todo from npm
6
- * 2. Agent versions: detect and display installed agent CLI versions
7
- * 3. Project freshness: fetch and rebase registered projects that are behind
6
+ * 2. Agent CLIs: detect versions, check for updates, and install if available
7
+ * 3. Version parity: warn if agents are below minimum required versions
8
+ * 4. Project freshness: fetch and rebase registered projects that are behind
8
9
  *
9
10
  * Separation of concerns:
10
- * - Daemon: runs all three checks periodically (hourly, throttled, non-interactive)
11
+ * - Daemon: runs all checks periodically (hourly, throttled, non-interactive)
12
+ * - Self-update: always (gated by auto-update setting)
13
+ * - Agent updates: opt-in (gated by auto-update-agents setting, default OFF)
14
+ * - Project freshness: always (gated by auto-update setting)
11
15
  * - This module: runs on explicit user request (immediate, verbose, interactive)
16
+ * - Always checks and updates everything, no settings gate
12
17
  */
13
18
 
14
19
  import { readFileSync } from 'fs';
15
20
  import { join, dirname } from 'path';
16
21
  import { fileURLToPath } from 'url';
17
22
 
18
- import { checkForUpdate, performUpdate, compareSemver } from './self-update.js';
19
- import { getAgentVersions, getKnownAgentTypes } from './agent-versions.js';
23
+ import { checkForUpdate, performUpdate } from './self-update.js';
24
+ import { getAgentVersions, getKnownAgentTypes, checkForAgentUpdate, performAgentUpdate, checkVersionParity } from './agent-versions.js';
20
25
  import { checkProjectFreshness } from './project-freshness.js';
21
26
  import { getRegistry } from './project-registry.js';
22
- import { bold, green, yellow, red, cyan, dim } from './utils/colors.js';
27
+ import { bold, green, yellow, red, dim } from './utils/colors.js';
23
28
 
24
29
  const __filename = fileURLToPath(import.meta.url);
25
30
  const __dirname = dirname(__filename);
@@ -76,7 +81,7 @@ export async function runManualUpdate(values) {
76
81
  }
77
82
  console.log();
78
83
 
79
- // ── 2. Agent versions ──────────────────────────────────
84
+ // ── 2. Agent CLIs ──────────────────────────────────────
80
85
  console.log(bold(' Agent CLIs'));
81
86
 
82
87
  const agentVersions = getAgentVersions({ force: true });
@@ -84,12 +89,41 @@ export async function runManualUpdate(values) {
84
89
  const info = agentVersions[type];
85
90
  const label = AGENT_LABELS[type] || type;
86
91
 
87
- if (info.installed && info.version) {
88
- console.log(` ${label}: ${green('v' + info.version)}`);
89
- } else if (info.installed) {
92
+ if (!info.installed) {
93
+ console.log(` ${label}: ${dim('not installed')}`);
94
+ continue;
95
+ }
96
+
97
+ if (!info.version) {
90
98
  console.log(` ${label}: ${yellow('installed')} ${dim('(version unknown)')}`);
99
+ continue;
100
+ }
101
+
102
+ // Check for update
103
+ const updateInfo = checkForAgentUpdate(type);
104
+ if (updateInfo.available) {
105
+ console.log(` ${label}: v${info.version} -> ${green('v' + updateInfo.latest)} available`);
106
+ console.log(` Updating ${label} to v${updateInfo.latest}...`);
107
+ const success = performAgentUpdate(type, updateInfo.latest);
108
+ if (success) {
109
+ console.log(` ${green('Updated successfully')}`);
110
+ } else {
111
+ console.log(` ${red('Update failed')}`);
112
+ }
113
+ } else if (updateInfo.reason === 'too_recent') {
114
+ console.log(` ${label}: ${green('v' + info.version)} ${dim(`(v${updateInfo.latest} published <1hr ago)`)}`);
91
115
  } else {
92
- console.log(` ${label}: ${dim('not installed')}`);
116
+ console.log(` ${label}: ${green('v' + info.version)}`);
117
+ }
118
+ }
119
+
120
+ // Version parity warnings
121
+ const parityWarnings = checkVersionParity();
122
+ if (parityWarnings.length > 0) {
123
+ console.log();
124
+ for (const w of parityWarnings) {
125
+ const label = AGENT_LABELS[w.agentType] || w.agentType;
126
+ console.log(` ${yellow('Warning')}: ${label} v${w.installed} is below minimum v${w.required}`);
93
127
  }
94
128
  }
95
129
  console.log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.1.4",
3
+ "version": "4.1.6",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {