@masslessai/push-todo 4.1.5 → 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
@@ -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);
@@ -282,6 +282,11 @@ function getAutoUpdateEnabled() {
282
282
  return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
283
283
  }
284
284
 
285
+ function getAutoUpdateAgentsEnabled() {
286
+ const v = getConfigValueFromFile('AUTO_UPDATE_AGENTS', 'false');
287
+ return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
288
+ }
289
+
285
290
  // ==================== Capabilities Detection ====================
286
291
 
287
292
  let cachedCapabilities = null;
@@ -2355,6 +2360,55 @@ function checkAndApplyUpdate() {
2355
2360
  }
2356
2361
  }
2357
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
+
2358
2412
  // ==================== Main Loop ====================
2359
2413
 
2360
2414
  async function pollAndExecute() {
@@ -2457,10 +2511,12 @@ async function mainLoop() {
2457
2511
  log(`Max concurrent tasks: ${MAX_CONCURRENT_TASKS}`);
2458
2512
  log(`E2EE: ${e2eeAvailable ? 'Available' : 'Not available'}`);
2459
2513
  log(`Auto-update: ${getAutoUpdateEnabled() ? 'Enabled' : 'Disabled'}`);
2514
+ log(`Auto-update-agents: ${getAutoUpdateAgentsEnabled() ? 'Enabled' : 'Disabled'}`);
2460
2515
  const caps = getCapabilities();
2461
2516
  log(`Capabilities: gh=${caps.gh_cli}, auto-merge=${caps.auto_merge}, auto-complete=${caps.auto_complete}`);
2462
2517
  const agentVersions = getAgentVersions({ force: true });
2463
2518
  log(`Agent versions: ${formatAgentVersionSummary(agentVersions)}`);
2519
+ logVersionParityWarnings();
2464
2520
  log(`Log file: ${LOG_FILE}`);
2465
2521
 
2466
2522
  // Show registered projects
@@ -2559,6 +2615,15 @@ async function mainLoop() {
2559
2615
  if (getAutoUpdateEnabled()) {
2560
2616
  checkAndApplyUpdate();
2561
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
+ }
2562
2627
  } catch (error) {
2563
2628
  logError(`Poll error: ${error.message}`);
2564
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.5",
3
+ "version": "4.1.6",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {