@masslessai/push-todo 4.1.3 → 4.1.5

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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Agent version detection and tracking for Push daemon.
3
+ *
4
+ * Detects installed versions of Claude Code, OpenAI Codex, and OpenClaw CLIs.
5
+ * Reports version parity with the push-todo CLI and flags outdated agents.
6
+ *
7
+ * Pattern: follows heartbeat.js — pure functions, internally throttled, non-fatal.
8
+ */
9
+
10
+ import { execFileSync } from 'child_process';
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
12
+ import { homedir } from 'os';
13
+ import { join } from 'path';
14
+
15
+ const PUSH_DIR = join(homedir(), '.push');
16
+ const VERSIONS_CACHE_FILE = join(PUSH_DIR, 'agent_versions.json');
17
+ const CHECK_INTERVAL = 3600000; // 1 hour
18
+
19
+ // ==================== Agent Definitions ====================
20
+
21
+ /**
22
+ * Agent CLI definitions: command name, version flag, and how to parse output.
23
+ *
24
+ * Each agent has:
25
+ * - cmd: the CLI binary name
26
+ * - versionArgs: args to get version string
27
+ * - parseVersion: extracts semver from command output
28
+ */
29
+ const AGENTS = {
30
+ 'claude-code': {
31
+ cmd: 'claude',
32
+ versionArgs: ['--version'],
33
+ parseVersion(output) {
34
+ // "claude v2.1.41" or just "2.1.41"
35
+ const match = output.match(/(\d+\.\d+\.\d+)/);
36
+ return match ? match[1] : null;
37
+ },
38
+ },
39
+ 'openai-codex': {
40
+ cmd: 'codex',
41
+ versionArgs: ['--version'],
42
+ parseVersion(output) {
43
+ const match = output.match(/(\d+\.\d+\.\d+)/);
44
+ return match ? match[1] : null;
45
+ },
46
+ },
47
+ 'openclaw': {
48
+ cmd: 'openclaw',
49
+ versionArgs: ['--version'],
50
+ parseVersion(output) {
51
+ const match = output.match(/(\d+\.\d+\.\d+)/);
52
+ return match ? match[1] : null;
53
+ },
54
+ },
55
+ };
56
+
57
+ // ==================== Version Detection ====================
58
+
59
+ /**
60
+ * Detect the installed version of a single agent CLI.
61
+ *
62
+ * @param {string} agentType - One of 'claude-code', 'openai-codex', 'openclaw'
63
+ * @returns {{ installed: boolean, version: string|null, error: string|null }}
64
+ */
65
+ export function detectAgentVersion(agentType) {
66
+ const agent = AGENTS[agentType];
67
+ if (!agent) {
68
+ return { installed: false, version: null, error: `Unknown agent type: ${agentType}` };
69
+ }
70
+
71
+ try {
72
+ const output = execFileSync(agent.cmd, agent.versionArgs, {
73
+ timeout: 10000,
74
+ encoding: 'utf8',
75
+ stdio: ['ignore', 'pipe', 'pipe'],
76
+ }).trim();
77
+
78
+ const version = agent.parseVersion(output);
79
+ if (version) {
80
+ return { installed: true, version, error: null };
81
+ }
82
+ return { installed: true, version: null, error: `Could not parse version from: ${output}` };
83
+ } catch (err) {
84
+ // ENOENT = not installed; other errors = installed but broken
85
+ if (err.code === 'ENOENT') {
86
+ return { installed: false, version: null, error: null };
87
+ }
88
+ return { installed: false, version: null, error: err.message };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Detect versions of all known agent CLIs.
94
+ *
95
+ * @returns {Object.<string, { installed: boolean, version: string|null, error: string|null }>}
96
+ */
97
+ export function detectAllAgentVersions() {
98
+ const results = {};
99
+ for (const agentType of Object.keys(AGENTS)) {
100
+ results[agentType] = detectAgentVersion(agentType);
101
+ }
102
+ return results;
103
+ }
104
+
105
+ // ==================== Cache ====================
106
+
107
+ /**
108
+ * Load cached agent version data.
109
+ *
110
+ * @returns {{ versions: Object, checkedAt: string|null }|null}
111
+ */
112
+ function loadCache() {
113
+ try {
114
+ if (existsSync(VERSIONS_CACHE_FILE)) {
115
+ return JSON.parse(readFileSync(VERSIONS_CACHE_FILE, 'utf8'));
116
+ }
117
+ } catch {}
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Save agent version data to cache.
123
+ *
124
+ * @param {Object} versions - Agent version results
125
+ */
126
+ function saveCache(versions) {
127
+ try {
128
+ mkdirSync(PUSH_DIR, { recursive: true });
129
+ writeFileSync(VERSIONS_CACHE_FILE, JSON.stringify({
130
+ versions,
131
+ checkedAt: new Date().toISOString(),
132
+ }, null, 2));
133
+ } catch {}
134
+ }
135
+
136
+ // ==================== Throttled Check ====================
137
+
138
+ let lastCheckTime = 0;
139
+ let cachedResults = null;
140
+
141
+ /**
142
+ * Get agent versions (throttled to once per hour).
143
+ * Returns cached results if within the check interval.
144
+ *
145
+ * @param {{ force?: boolean }} options
146
+ * @returns {Object.<string, { installed: boolean, version: string|null, error: string|null }>}
147
+ */
148
+ export function getAgentVersions({ force = false } = {}) {
149
+ const now = Date.now();
150
+
151
+ // Return in-memory cache if fresh
152
+ if (!force && cachedResults && (now - lastCheckTime < CHECK_INTERVAL)) {
153
+ return cachedResults;
154
+ }
155
+
156
+ // Try disk cache if no in-memory cache
157
+ if (!force && !cachedResults) {
158
+ const diskCache = loadCache();
159
+ if (diskCache?.checkedAt) {
160
+ const cacheAge = now - new Date(diskCache.checkedAt).getTime();
161
+ if (cacheAge < CHECK_INTERVAL) {
162
+ cachedResults = diskCache.versions;
163
+ lastCheckTime = now - (CHECK_INTERVAL - cacheAge); // preserve remaining TTL
164
+ return cachedResults;
165
+ }
166
+ }
167
+ }
168
+
169
+ // Fresh detection
170
+ cachedResults = detectAllAgentVersions();
171
+ lastCheckTime = now;
172
+ saveCache(cachedResults);
173
+ return cachedResults;
174
+ }
175
+
176
+ /**
177
+ * Get a human-readable summary of agent versions for logging.
178
+ *
179
+ * @param {Object} versions - From getAgentVersions()
180
+ * @returns {string}
181
+ */
182
+ export function formatAgentVersionSummary(versions) {
183
+ const parts = [];
184
+ for (const [type, info] of Object.entries(versions)) {
185
+ const label = type.replace(/-/g, ' ');
186
+ if (info.installed && info.version) {
187
+ parts.push(`${label}=v${info.version}`);
188
+ } else if (info.installed) {
189
+ parts.push(`${label}=installed (unknown version)`);
190
+ } else {
191
+ parts.push(`${label}=not found`);
192
+ }
193
+ }
194
+ return parts.join(', ');
195
+ }
196
+
197
+ /**
198
+ * Get the list of known agent types.
199
+ *
200
+ * @returns {string[]}
201
+ */
202
+ export function getKnownAgentTypes() {
203
+ return Object.keys(AGENTS);
204
+ }
package/lib/cli.js CHANGED
@@ -47,6 +47,7 @@ ${bold('USAGE:')}
47
47
  push-todo connect Run connection doctor
48
48
  push-todo search <query> Search tasks
49
49
  push-todo review Review completed tasks
50
+ push-todo update Update CLI, check agents, refresh projects
50
51
 
51
52
  ${bold('OPTIONS:')}
52
53
  --all-projects, -a List tasks from all projects
@@ -655,6 +656,12 @@ export async function run(argv) {
655
656
  return runConnect(values);
656
657
  }
657
658
 
659
+ // Update command - manual update of CLI, agents, and project freshness
660
+ if (command === 'update') {
661
+ const { runManualUpdate } = await import('./update.js');
662
+ return runManualUpdate(values);
663
+ }
664
+
658
665
  // Review command
659
666
  if (command === 'review') {
660
667
  return fetch.runReview(values);
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,6 +25,8 @@ 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';
29
+ import { checkAllProjectsFreshness } from './project-freshness.js';
28
30
 
29
31
  const __filename = fileURLToPath(import.meta.url);
30
32
  const __dirname = dirname(__filename);
@@ -46,6 +48,10 @@ const RETRY_BACKOFF_FACTOR = 2;
46
48
  const CERTAINTY_HIGH_THRESHOLD = 0.7;
47
49
  const CERTAINTY_LOW_THRESHOLD = 0.4;
48
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
+
49
55
  // Stuck detection
50
56
  const STUCK_IDLE_THRESHOLD = 600000; // 10 min
51
57
  const STUCK_WARNING_THRESHOLD = 300000; // 5 min
@@ -108,6 +114,7 @@ const taskLastOutput = new Map(); // displayNumber -> timestamp
108
114
  const taskStdoutBuffer = new Map(); // displayNumber -> lines[]
109
115
  const taskStderrBuffer = new Map(); // displayNumber -> lines[]
110
116
  const taskProjectPaths = new Map(); // displayNumber -> projectPath
117
+ const taskLastHeartbeat = new Map(); // displayNumber -> timestamp of last progress heartbeat
111
118
  let daemonStartTime = null;
112
119
 
113
120
  // ==================== Utilities ====================
@@ -281,6 +288,59 @@ let cachedCapabilities = null;
281
288
  let lastCapabilityCheck = 0;
282
289
  const CAPABILITY_CHECK_INTERVAL = 3600000; // 1 hour
283
290
 
291
+ /**
292
+ * Discover skills for all registered projects.
293
+ * Scans ~/.claude/skills/ (global) and <projectPath>/.claude/skills/ (per-project).
294
+ * Returns: { "github.com/user/repo": ["skill1", "skill2"], ... }
295
+ */
296
+ function discoverProjectSkills() {
297
+ const globalSkillsDir = join(homedir(), '.claude', 'skills');
298
+ const globalSkills = [];
299
+
300
+ // Enumerate global skills
301
+ if (existsSync(globalSkillsDir)) {
302
+ try {
303
+ for (const entry of readdirSync(globalSkillsDir, { withFileTypes: true })) {
304
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
305
+ globalSkills.push(entry.name);
306
+ }
307
+ }
308
+ } catch { /* ignore */ }
309
+ }
310
+
311
+ // For each registered project, enumerate project-local skills and merge with global
312
+ const result = {};
313
+ if (!existsSync(REGISTRY_FILE)) return result;
314
+
315
+ try {
316
+ const data = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
317
+ for (const [, info] of Object.entries(data.projects || {})) {
318
+ const remote = info.gitRemote;
319
+ const localPath = info.localPath || info.local_path;
320
+ if (!remote || !localPath) continue;
321
+
322
+ const projectSkillsDir = join(localPath, '.claude', 'skills');
323
+ const projectSkills = new Set(globalSkills);
324
+
325
+ if (existsSync(projectSkillsDir)) {
326
+ try {
327
+ for (const entry of readdirSync(projectSkillsDir, { withFileTypes: true })) {
328
+ if (entry.isDirectory() || entry.isSymbolicLink()) {
329
+ projectSkills.add(entry.name);
330
+ }
331
+ }
332
+ } catch { /* ignore */ }
333
+ }
334
+
335
+ if (projectSkills.size > 0) {
336
+ result[remote] = [...projectSkills].sort();
337
+ }
338
+ }
339
+ } catch { /* ignore */ }
340
+
341
+ return result;
342
+ }
343
+
284
344
  function detectCapabilities() {
285
345
  const caps = {
286
346
  auto_merge: getAutoMergeEnabled(),
@@ -300,6 +360,8 @@ function detectCapabilities() {
300
360
  caps.gh_cli = 'not_installed';
301
361
  }
302
362
 
363
+ caps.project_skills = discoverProjectSkills();
364
+
303
365
  return caps;
304
366
  }
305
367
 
@@ -1338,6 +1400,151 @@ function checkTaskIdle(displayNumber) {
1338
1400
  return false;
1339
1401
  }
1340
1402
 
1403
+ // ==================== Progress Heartbeat (Level A) ====================
1404
+
1405
+ async function sendProgressHeartbeats() {
1406
+ const now = Date.now();
1407
+
1408
+ for (const [displayNumber, taskInfo] of runningTasks) {
1409
+ const info = taskDetails.get(displayNumber) || {};
1410
+
1411
+ // Skip tasks awaiting user confirmation (not truly hanging)
1412
+ if (info.phase === 'awaiting_confirmation') continue;
1413
+
1414
+ // Throttle: only send every HEARTBEAT_INTERVAL_MS
1415
+ const lastHeartbeat = taskLastHeartbeat.get(displayNumber) || taskInfo.startTime;
1416
+ if (now - lastHeartbeat < HEARTBEAT_INTERVAL_MS) continue;
1417
+
1418
+ // Compute metrics
1419
+ const elapsedSec = Math.floor((now - taskInfo.startTime) / 1000);
1420
+ const elapsedMin = Math.floor(elapsedSec / 60);
1421
+ const lastOutputTs = taskLastOutput.get(displayNumber);
1422
+ const idleSec = lastOutputTs ? Math.floor((now - lastOutputTs) / 1000) : elapsedSec;
1423
+ const idleMin = Math.floor(idleSec / 60);
1424
+
1425
+ const activityDesc = idleSec < 60 ? 'active' : `idle ${idleMin}m`;
1426
+ const phase = info.phase || 'executing';
1427
+ const eventSummary = `Running for ${elapsedMin}m. Last activity: ${activityDesc}. Phase: ${phase}.`;
1428
+
1429
+ log(`Task #${displayNumber}: sending progress heartbeat (${eventSummary})`);
1430
+
1431
+ // Update throttle timestamp BEFORE the async call to prevent concurrent sends
1432
+ taskLastHeartbeat.set(displayNumber, now);
1433
+
1434
+ // Send event-only update (no status field) — non-fatal if it fails
1435
+ const taskId = info.taskId || null;
1436
+ apiRequest('update-task-execution', {
1437
+ method: 'PATCH',
1438
+ body: JSON.stringify({
1439
+ todoId: taskId,
1440
+ displayNumber,
1441
+ event: {
1442
+ type: 'progress',
1443
+ timestamp: new Date().toISOString(),
1444
+ machineName: getMachineName() || undefined,
1445
+ summary: eventSummary
1446
+ }
1447
+ // No status field — event-only update, won't change execution_status
1448
+ })
1449
+ }).catch(err => {
1450
+ log(`Task #${displayNumber}: heartbeat failed (non-fatal): ${err.message}`);
1451
+ });
1452
+ // NOTE: intentionally not awaited — heartbeats are fire-and-forget
1453
+ // to avoid blocking the poll loop when Supabase is slow
1454
+ }
1455
+ }
1456
+
1457
+ // ==================== Idle Auto-Recovery (Level B) ====================
1458
+
1459
+ async function killIdleTasks() {
1460
+ const now = Date.now();
1461
+ const idleTimedOut = [];
1462
+
1463
+ for (const [displayNumber, taskInfo] of runningTasks) {
1464
+ const info = taskDetails.get(displayNumber) || {};
1465
+
1466
+ // Exempt: tasks awaiting user confirmation
1467
+ if (info.phase === 'awaiting_confirmation') continue;
1468
+
1469
+ const lastOutput = taskLastOutput.get(displayNumber);
1470
+ if (!lastOutput) continue; // No output tracking yet — not idle, just starting
1471
+
1472
+ const idleMs = now - lastOutput;
1473
+ if (idleMs > IDLE_TIMEOUT_MS) {
1474
+ log(`Task #${displayNumber} IDLE TIMEOUT: ${Math.floor(idleMs / 1000)}s since last output`);
1475
+ idleTimedOut.push(displayNumber);
1476
+ }
1477
+ }
1478
+
1479
+ for (const displayNumber of idleTimedOut) {
1480
+ const taskInfo = runningTasks.get(displayNumber);
1481
+ if (!taskInfo) continue;
1482
+
1483
+ const info = taskDetails.get(displayNumber) || {};
1484
+ const projectPath = taskProjectPaths.get(displayNumber);
1485
+ const elapsedSec = Math.floor((now - taskInfo.startTime) / 1000);
1486
+ const idleSec = Math.floor((now - (taskLastOutput.get(displayNumber) || taskInfo.startTime)) / 1000);
1487
+ const durationStr = elapsedSec < 60 ? `${elapsedSec}s` : `${Math.floor(elapsedSec / 60)}m ${elapsedSec % 60}s`;
1488
+ const machineName = getMachineName() || 'Mac';
1489
+
1490
+ // Extract semantic summary WHILE session is still alive
1491
+ // (a live session produces a better "what have you done so far" answer)
1492
+ const sessionId = taskInfo.sessionId;
1493
+ const worktreePath = getWorktreePath(displayNumber, projectPath);
1494
+ const summaryPath = existsSync(worktreePath) ? worktreePath : (projectPath || process.cwd());
1495
+ log(`Task #${displayNumber}: extracting pre-kill summary...`);
1496
+ const idleSummary = extractSemanticSummary(summaryPath, sessionId);
1497
+
1498
+ // Now kill the process
1499
+ log(`Task #${displayNumber}: killing idle process (PID: ${taskInfo.process.pid})`);
1500
+ try {
1501
+ taskInfo.process.kill('SIGTERM');
1502
+ await new Promise(r => setTimeout(r, 5000));
1503
+ taskInfo.process.kill('SIGKILL');
1504
+ } catch {}
1505
+
1506
+ runningTasks.delete(displayNumber);
1507
+ cleanupWorktree(displayNumber, projectPath);
1508
+
1509
+ const idleError = idleSummary
1510
+ ? `${idleSummary}\nSession went idle for ${Math.floor(idleSec / 60)}m with no output. Killed after ${durationStr} on ${machineName}.`
1511
+ : `Session went idle for ${Math.floor(idleSec / 60)}m with no output (limit: ${IDLE_TIMEOUT_MS / 60000}m). Killed after ${durationStr} on ${machineName}.`;
1512
+
1513
+ await updateTaskStatus(displayNumber, 'failed', {
1514
+ error: idleError,
1515
+ sessionId
1516
+ }, info.taskId);
1517
+
1518
+ if (NOTIFY_ON_FAILURE) {
1519
+ sendMacNotification(
1520
+ `Task #${displayNumber} idle timeout`,
1521
+ `${(info.summary || 'Unknown').slice(0, 40)}... idle ${Math.floor(idleSec / 60)}m`,
1522
+ 'Basso'
1523
+ );
1524
+ }
1525
+
1526
+ trackCompleted({
1527
+ displayNumber,
1528
+ summary: info.summary || 'Unknown task',
1529
+ completedAt: new Date().toISOString(),
1530
+ duration: elapsedSec,
1531
+ status: 'idle_timeout'
1532
+ });
1533
+
1534
+ // Full cleanup of all tracking Maps
1535
+ taskDetails.delete(displayNumber);
1536
+ taskLastOutput.delete(displayNumber);
1537
+ taskStdoutBuffer.delete(displayNumber);
1538
+ taskStderrBuffer.delete(displayNumber);
1539
+ taskProjectPaths.delete(displayNumber);
1540
+ taskLastHeartbeat.delete(displayNumber);
1541
+ }
1542
+
1543
+ if (idleTimedOut.length > 0) {
1544
+ updateStatusFile();
1545
+ }
1546
+ }
1547
+
1341
1548
  // ==================== Session ID Extraction ====================
1342
1549
 
1343
1550
  function extractSessionIdFromStdout(proc, buffer) {
@@ -1614,7 +1821,6 @@ async function executeTask(task) {
1614
1821
  ? [
1615
1822
  '--continue', previousSessionId,
1616
1823
  '-p', prompt,
1617
- '--worktree', worktreeName,
1618
1824
  '--allowedTools', allowedTools,
1619
1825
  '--output-format', 'json',
1620
1826
  '--permission-mode', 'bypassPermissions',
@@ -1622,7 +1828,6 @@ async function executeTask(task) {
1622
1828
  ]
1623
1829
  : [
1624
1830
  '-p', prompt,
1625
- '--worktree', worktreeName,
1626
1831
  '--allowedTools', allowedTools,
1627
1832
  '--output-format', 'json',
1628
1833
  '--permission-mode', 'bypassPermissions',
@@ -1658,6 +1863,7 @@ async function executeTask(task) {
1658
1863
  taskLastOutput.set(displayNumber, Date.now());
1659
1864
  taskStdoutBuffer.set(displayNumber, []);
1660
1865
  taskStderrBuffer.set(displayNumber, []);
1866
+ taskLastHeartbeat.set(displayNumber, Date.now());
1661
1867
 
1662
1868
  // Monitor stderr (critical for diagnosing fast exits)
1663
1869
  child.stderr.on('data', (data) => {
@@ -1713,6 +1919,7 @@ async function executeTask(task) {
1713
1919
  runningTasks.delete(displayNumber);
1714
1920
  await updateTaskStatus(displayNumber, 'failed', { error: error.message }, taskId);
1715
1921
  taskDetails.delete(displayNumber);
1922
+ taskLastHeartbeat.delete(displayNumber);
1716
1923
  updateStatusFile();
1717
1924
  });
1718
1925
 
@@ -1936,6 +2143,7 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1936
2143
  taskStdoutBuffer.delete(displayNumber);
1937
2144
  taskStderrBuffer.delete(displayNumber);
1938
2145
  taskProjectPaths.delete(displayNumber);
2146
+ taskLastHeartbeat.delete(displayNumber);
1939
2147
  updateStatusFile();
1940
2148
  }
1941
2149
 
@@ -2014,6 +2222,13 @@ function updateStatusFile() {
2014
2222
  // ==================== Task Checking ====================
2015
2223
 
2016
2224
  async function checkTimeouts() {
2225
+ // Level A: Send progress heartbeats for long-running tasks
2226
+ await sendProgressHeartbeats();
2227
+
2228
+ // Level B: Kill tasks that have been idle too long (fires at 15 min, before 60 min absolute)
2229
+ await killIdleTasks();
2230
+
2231
+ // Absolute timeout (safety net — 60 min wall clock)
2017
2232
  const now = Date.now();
2018
2233
  const timedOut = [];
2019
2234
 
@@ -2080,6 +2295,7 @@ async function checkTimeouts() {
2080
2295
  taskStdoutBuffer.delete(displayNumber);
2081
2296
  taskStderrBuffer.delete(displayNumber);
2082
2297
  taskProjectPaths.delete(displayNumber);
2298
+ taskLastHeartbeat.delete(displayNumber);
2083
2299
  cleanupWorktree(displayNumber, projectPath);
2084
2300
  }
2085
2301
 
@@ -2243,6 +2459,8 @@ async function mainLoop() {
2243
2459
  log(`Auto-update: ${getAutoUpdateEnabled() ? 'Enabled' : 'Disabled'}`);
2244
2460
  const caps = getCapabilities();
2245
2461
  log(`Capabilities: gh=${caps.gh_cli}, auto-merge=${caps.auto_merge}, auto-complete=${caps.auto_complete}`);
2462
+ const agentVersions = getAgentVersions({ force: true });
2463
+ log(`Agent versions: ${formatAgentVersionSummary(agentVersions)}`);
2246
2464
  log(`Log file: ${LOG_FILE}`);
2247
2465
 
2248
2466
  // Show registered projects
@@ -2313,6 +2531,30 @@ async function mainLoop() {
2313
2531
  logError(`Heartbeat error: ${error.message}`);
2314
2532
  }
2315
2533
 
2534
+ // Project freshness check (internally throttled to once per hour)
2535
+ try {
2536
+ const projects = getListedProjects();
2537
+ const busyPaths = new Set();
2538
+ for (const [, info] of runningTasks) {
2539
+ if (info.projectPath) busyPaths.add(info.projectPath);
2540
+ }
2541
+ checkAllProjectsFreshness({
2542
+ projects,
2543
+ autoRebase: getAutoUpdateEnabled(),
2544
+ busyPaths,
2545
+ log,
2546
+ });
2547
+ } catch (error) {
2548
+ logError(`Freshness check error: ${error.message}`);
2549
+ }
2550
+
2551
+ // Agent version check (internally throttled to once per hour)
2552
+ try {
2553
+ getAgentVersions();
2554
+ } catch (error) {
2555
+ logError(`Agent version check error: ${error.message}`);
2556
+ }
2557
+
2316
2558
  // Self-update check (throttled to once per hour, only applies when idle)
2317
2559
  if (getAutoUpdateEnabled()) {
2318
2560
  checkAndApplyUpdate();
package/lib/fetch.js CHANGED
@@ -12,6 +12,7 @@ import { formatTaskForDisplay, formatSearchResult } from './utils/format.js';
12
12
  import { bold, green, yellow, red, cyan, dim, muted } from './utils/colors.js';
13
13
  import { decryptTodoField, isE2EEAvailable } from './encryption.js';
14
14
  import { getAutoCommitEnabled, getAutoMergeEnabled, getAutoCompleteEnabled, getAutoUpdateEnabled, getMaxBatchSize } from './config.js';
15
+ import { getAgentVersions } from './agent-versions.js';
15
16
  import { writeFileSync, mkdirSync } from 'fs';
16
17
  import { homedir } from 'os';
17
18
  import { join } from 'path';
@@ -445,6 +446,26 @@ export async function showStatus(options = {}) {
445
446
  console.log(`${bold('Auto-update:')} ${autoUpdate ? 'Enabled' : 'Disabled'}`);
446
447
  console.log(`${bold('Max batch size:')} ${maxBatch}`);
447
448
  console.log(`${bold('Registered projects:')} ${status.registeredProjects}`);
449
+ console.log('');
450
+
451
+ // Agent versions
452
+ const agentVersions = getAgentVersions();
453
+ console.log(bold('Agent Versions:'));
454
+ const agentLabels = {
455
+ 'claude-code': 'Claude Code',
456
+ 'openai-codex': 'Codex',
457
+ 'openclaw': 'OpenClaw',
458
+ };
459
+ for (const [type, info] of Object.entries(agentVersions)) {
460
+ const label = agentLabels[type] || type;
461
+ if (info.installed && info.version) {
462
+ console.log(` ${bold(label + ':')} ${green('v' + info.version)}`);
463
+ } else if (info.installed) {
464
+ console.log(` ${bold(label + ':')} ${yellow('installed (unknown version)')}`);
465
+ } else {
466
+ console.log(` ${bold(label + ':')} ${dim('not installed')}`);
467
+ }
468
+ }
448
469
  }
449
470
 
450
471
  /**
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Project freshness checker for Push daemon.
3
+ *
4
+ * Checks if registered projects are behind their remote and optionally
5
+ * pulls updates via rebase. Only updates when the working tree is clean
6
+ * and no daemon tasks are running for that project.
7
+ *
8
+ * Pattern: follows heartbeat.js — pure functions, internally throttled, non-fatal.
9
+ */
10
+
11
+ import { execFileSync } from 'child_process';
12
+ import { existsSync } from 'fs';
13
+ import { join } from 'path';
14
+
15
+ // ==================== Configuration ====================
16
+
17
+ const FRESHNESS_CHECK_INTERVAL = 3600000; // 1 hour
18
+
19
+ // ==================== Internal State ====================
20
+
21
+ let lastFreshnessCheck = 0;
22
+
23
+ // ==================== Git Helpers ====================
24
+
25
+ /**
26
+ * Get the default branch for a repository (main or master).
27
+ *
28
+ * @param {string} projectPath
29
+ * @returns {string|null}
30
+ */
31
+ function getDefaultBranch(projectPath) {
32
+ try {
33
+ // Try symbolic-ref to origin/HEAD first
34
+ const ref = execFileSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], {
35
+ cwd: projectPath,
36
+ timeout: 5000,
37
+ encoding: 'utf8',
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ }).trim();
40
+ // "refs/remotes/origin/main" → "main"
41
+ return ref.split('/').pop();
42
+ } catch {
43
+ // Fallback: check if main or master exists
44
+ for (const branch of ['main', 'master']) {
45
+ try {
46
+ execFileSync('git', ['rev-parse', '--verify', `refs/remotes/origin/${branch}`], {
47
+ cwd: projectPath,
48
+ timeout: 5000,
49
+ encoding: 'utf8',
50
+ stdio: ['ignore', 'pipe', 'pipe'],
51
+ });
52
+ return branch;
53
+ } catch {}
54
+ }
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Get the currently checked-out branch.
61
+ *
62
+ * @param {string} projectPath
63
+ * @returns {string|null}
64
+ */
65
+ function getCurrentBranch(projectPath) {
66
+ try {
67
+ return execFileSync('git', ['branch', '--show-current'], {
68
+ cwd: projectPath,
69
+ timeout: 5000,
70
+ encoding: 'utf8',
71
+ stdio: ['ignore', 'pipe', 'pipe'],
72
+ }).trim() || null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Check if the working tree is clean (no uncommitted changes).
80
+ *
81
+ * @param {string} projectPath
82
+ * @returns {boolean}
83
+ */
84
+ function isWorkingTreeClean(projectPath) {
85
+ try {
86
+ const status = execFileSync('git', ['status', '--porcelain'], {
87
+ cwd: projectPath,
88
+ timeout: 10000,
89
+ encoding: 'utf8',
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ }).trim();
92
+ return status === '';
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Fetch from remote (quiet, non-interactive).
100
+ *
101
+ * @param {string} projectPath
102
+ * @returns {boolean} true if fetch succeeded
103
+ */
104
+ function gitFetch(projectPath) {
105
+ try {
106
+ execFileSync('git', ['fetch', '--quiet'], {
107
+ cwd: projectPath,
108
+ timeout: 30000,
109
+ encoding: 'utf8',
110
+ stdio: ['ignore', 'pipe', 'pipe'],
111
+ });
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Count how many commits the local branch is behind the remote.
120
+ *
121
+ * @param {string} projectPath
122
+ * @param {string} branch
123
+ * @returns {number} commits behind, or -1 on error
124
+ */
125
+ function commitsBehind(projectPath, branch) {
126
+ try {
127
+ const output = execFileSync('git', [
128
+ 'rev-list', '--count', `${branch}..origin/${branch}`
129
+ ], {
130
+ cwd: projectPath,
131
+ timeout: 5000,
132
+ encoding: 'utf8',
133
+ stdio: ['ignore', 'pipe', 'pipe'],
134
+ }).trim();
135
+ return parseInt(output, 10) || 0;
136
+ } catch {
137
+ return -1;
138
+ }
139
+ }
140
+
141
+ // ==================== Freshness Check ====================
142
+
143
+ /**
144
+ * Check and optionally update a single project.
145
+ *
146
+ * @param {string} projectPath - Absolute path to the project
147
+ * @param {Object} options
148
+ * @param {boolean} options.autoRebase - If true, pull --rebase when behind
149
+ * @param {Set<string>} options.busyPaths - Paths with running tasks (skip these)
150
+ * @param {Function} options.log - Logging function
151
+ * @returns {{ status: string, behind?: number, updated?: boolean, error?: string }}
152
+ */
153
+ export function checkProjectFreshness(projectPath, { autoRebase = false, busyPaths = new Set(), log = () => {} } = {}) {
154
+ if (!existsSync(join(projectPath, '.git'))) {
155
+ return { status: 'not_a_repo' };
156
+ }
157
+
158
+ const defaultBranch = getDefaultBranch(projectPath);
159
+ if (!defaultBranch) {
160
+ return { status: 'no_default_branch' };
161
+ }
162
+
163
+ const currentBranch = getCurrentBranch(projectPath);
164
+
165
+ // Fetch latest from remote
166
+ if (!gitFetch(projectPath)) {
167
+ return { status: 'fetch_failed' };
168
+ }
169
+
170
+ // Check how far behind the default branch is
171
+ const behind = commitsBehind(projectPath, defaultBranch);
172
+ if (behind < 0) {
173
+ return { status: 'compare_failed' };
174
+ }
175
+
176
+ if (behind === 0) {
177
+ return { status: 'up_to_date', behind: 0 };
178
+ }
179
+
180
+ // Project is behind — decide whether to auto-update
181
+ if (!autoRebase) {
182
+ return { status: 'behind', behind };
183
+ }
184
+
185
+ // Skip if tasks are running in this project
186
+ if (busyPaths.has(projectPath)) {
187
+ log(`Freshness: ${projectPath} is ${behind} commit(s) behind but has running tasks — skipping rebase`);
188
+ return { status: 'behind_busy', behind };
189
+ }
190
+
191
+ // Skip if not on default branch (user is on a feature branch)
192
+ if (currentBranch !== defaultBranch) {
193
+ log(`Freshness: ${projectPath} is ${behind} commit(s) behind but on branch '${currentBranch}' — skipping rebase`);
194
+ return { status: 'behind_wrong_branch', behind, currentBranch };
195
+ }
196
+
197
+ // Skip if working tree is dirty
198
+ if (!isWorkingTreeClean(projectPath)) {
199
+ log(`Freshness: ${projectPath} is ${behind} commit(s) behind but working tree is dirty — skipping rebase`);
200
+ return { status: 'behind_dirty', behind };
201
+ }
202
+
203
+ // Safe to rebase
204
+ try {
205
+ execFileSync('git', ['pull', '--rebase', '--quiet'], {
206
+ cwd: projectPath,
207
+ timeout: 60000,
208
+ encoding: 'utf8',
209
+ stdio: ['ignore', 'pipe', 'pipe'],
210
+ });
211
+ log(`Freshness: ${projectPath} updated (${behind} commit(s) rebased)`);
212
+ return { status: 'updated', behind, updated: true };
213
+ } catch (err) {
214
+ // Abort failed rebase to leave tree clean
215
+ try {
216
+ execFileSync('git', ['rebase', '--abort'], {
217
+ cwd: projectPath,
218
+ timeout: 5000,
219
+ stdio: ['ignore', 'pipe', 'pipe'],
220
+ });
221
+ } catch {}
222
+ log(`Freshness: ${projectPath} rebase failed — aborted. Error: ${err.message}`);
223
+ return { status: 'rebase_failed', behind, error: err.message };
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Check freshness of all registered projects (internally throttled).
229
+ *
230
+ * @param {Object} config
231
+ * @param {Object.<string, string>} config.projects - gitRemote → localPath mapping
232
+ * @param {boolean} config.autoRebase - Whether to auto-pull when behind
233
+ * @param {Set<string>} config.busyPaths - Paths with running tasks
234
+ * @param {Function} config.log - Logging function
235
+ * @param {boolean} [config.force] - Skip throttle
236
+ * @returns {Object.<string, Object>|null} Results per project path, or null if throttled
237
+ */
238
+ export function checkAllProjectsFreshness(config) {
239
+ const { projects, autoRebase = false, busyPaths = new Set(), log = () => {}, force = false } = config;
240
+ const now = Date.now();
241
+
242
+ if (!force && (now - lastFreshnessCheck < FRESHNESS_CHECK_INTERVAL)) {
243
+ return null; // throttled
244
+ }
245
+ lastFreshnessCheck = now;
246
+
247
+ const results = {};
248
+ const paths = Object.values(projects);
249
+
250
+ for (const projectPath of paths) {
251
+ results[projectPath] = checkProjectFreshness(projectPath, {
252
+ autoRebase,
253
+ busyPaths,
254
+ log,
255
+ });
256
+ }
257
+
258
+ // Log summary
259
+ const behindCount = Object.values(results).filter(r =>
260
+ r.status === 'behind' || r.status === 'behind_busy' ||
261
+ r.status === 'behind_dirty' || r.status === 'behind_wrong_branch'
262
+ ).length;
263
+ const updatedCount = Object.values(results).filter(r => r.status === 'updated').length;
264
+
265
+ if (behindCount > 0 || updatedCount > 0) {
266
+ log(`Freshness: ${updatedCount} project(s) updated, ${behindCount} behind`);
267
+ }
268
+
269
+ return results;
270
+ }
package/lib/update.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Manual update orchestrator for Push CLI.
3
+ *
4
+ * `push-todo update` performs three actions:
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
8
+ *
9
+ * Separation of concerns:
10
+ * - Daemon: runs all three checks periodically (hourly, throttled, non-interactive)
11
+ * - This module: runs on explicit user request (immediate, verbose, interactive)
12
+ */
13
+
14
+ import { readFileSync } from 'fs';
15
+ import { join, dirname } from 'path';
16
+ import { fileURLToPath } from 'url';
17
+
18
+ import { checkForUpdate, performUpdate, compareSemver } from './self-update.js';
19
+ import { getAgentVersions, getKnownAgentTypes } from './agent-versions.js';
20
+ import { checkProjectFreshness } from './project-freshness.js';
21
+ import { getRegistry } from './project-registry.js';
22
+ import { bold, green, yellow, red, cyan, dim } from './utils/colors.js';
23
+
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
26
+
27
+ function getVersion() {
28
+ try {
29
+ return JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
30
+ } catch {
31
+ return 'unknown';
32
+ }
33
+ }
34
+
35
+ const AGENT_LABELS = {
36
+ 'claude-code': 'Claude Code',
37
+ 'openai-codex': 'Codex',
38
+ 'openclaw': 'OpenClaw',
39
+ };
40
+
41
+ /**
42
+ * Run the manual update flow.
43
+ *
44
+ * @param {Object} values - Parsed CLI flags
45
+ */
46
+ export async function runManualUpdate(values) {
47
+ const currentVersion = getVersion();
48
+
49
+ console.log();
50
+ console.log(bold(' Push Update'));
51
+ console.log(' ' + '='.repeat(40));
52
+ console.log();
53
+
54
+ // ── 1. Self-update ──────────────────────────────────────
55
+ console.log(bold(' Push CLI'));
56
+ console.log(` Current version: v${currentVersion}`);
57
+
58
+ // Force bypass throttle for manual check
59
+ const updateResult = checkForUpdate(currentVersion);
60
+
61
+ if (updateResult.available) {
62
+ console.log(` Latest version: ${green('v' + updateResult.version)}`);
63
+ console.log();
64
+ console.log(` Updating to v${updateResult.version}...`);
65
+ const success = performUpdate(updateResult.version);
66
+ if (success) {
67
+ console.log(` ${green('Updated successfully')}`);
68
+ } else {
69
+ console.log(` ${red('Update failed')} — try: npm install -g @masslessai/push-todo`);
70
+ }
71
+ } else if (updateResult.reason === 'too_recent') {
72
+ console.log(` Latest version: v${updateResult.version} ${dim('(published <1hr ago, waiting)')}`);
73
+ console.log(` ${green('Up to date')}`);
74
+ } else {
75
+ console.log(` ${green('Up to date')}`);
76
+ }
77
+ console.log();
78
+
79
+ // ── 2. Agent versions ──────────────────────────────────
80
+ console.log(bold(' Agent CLIs'));
81
+
82
+ const agentVersions = getAgentVersions({ force: true });
83
+ for (const type of getKnownAgentTypes()) {
84
+ const info = agentVersions[type];
85
+ const label = AGENT_LABELS[type] || type;
86
+
87
+ if (info.installed && info.version) {
88
+ console.log(` ${label}: ${green('v' + info.version)}`);
89
+ } else if (info.installed) {
90
+ console.log(` ${label}: ${yellow('installed')} ${dim('(version unknown)')}`);
91
+ } else {
92
+ console.log(` ${label}: ${dim('not installed')}`);
93
+ }
94
+ }
95
+ console.log();
96
+
97
+ // ── 3. Project freshness ───────────────────────────────
98
+ const registry = getRegistry();
99
+ const projects = registry.listProjects();
100
+ const projectPaths = Object.entries(projects);
101
+
102
+ if (projectPaths.length === 0) {
103
+ console.log(bold(' Projects'));
104
+ console.log(` ${dim('No projects registered')}`);
105
+ console.log();
106
+ return;
107
+ }
108
+
109
+ console.log(bold(' Project Freshness'));
110
+
111
+ for (const [remote, localPath] of projectPaths) {
112
+ const result = checkProjectFreshness(localPath, {
113
+ autoRebase: true,
114
+ busyPaths: new Set(), // Manual update has no running tasks
115
+ log: (msg) => console.log(` ${dim(msg)}`),
116
+ });
117
+
118
+ const shortRemote = remote.length > 40
119
+ ? '...' + remote.slice(-37)
120
+ : remote;
121
+
122
+ switch (result.status) {
123
+ case 'up_to_date':
124
+ console.log(` ${shortRemote}: ${green('up to date')}`);
125
+ break;
126
+ case 'updated':
127
+ console.log(` ${shortRemote}: ${green('updated')} ${dim(`(${result.behind} commit(s) rebased)`)}`);
128
+ break;
129
+ case 'behind_wrong_branch':
130
+ console.log(` ${shortRemote}: ${yellow(`${result.behind} behind`)} ${dim(`(on branch '${result.currentBranch}')`)}`);
131
+ break;
132
+ case 'behind_dirty':
133
+ console.log(` ${shortRemote}: ${yellow(`${result.behind} behind`)} ${dim('(dirty working tree)')}`);
134
+ break;
135
+ case 'rebase_failed':
136
+ console.log(` ${shortRemote}: ${red('rebase failed')} ${dim(`(${result.behind} behind)`)}`);
137
+ break;
138
+ case 'fetch_failed':
139
+ console.log(` ${shortRemote}: ${dim('fetch failed (offline?)')}`);
140
+ break;
141
+ case 'not_a_repo':
142
+ console.log(` ${shortRemote}: ${dim('path is not a git repo')}`);
143
+ break;
144
+ default:
145
+ console.log(` ${shortRemote}: ${dim(result.status)}`);
146
+ }
147
+ }
148
+
149
+ console.log();
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.1.3",
3
+ "version": "4.1.5",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {