@masslessai/push-todo 4.1.5 → 4.1.7

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);
@@ -115,6 +115,12 @@ const taskStdoutBuffer = new Map(); // displayNumber -> lines[]
115
115
  const taskStderrBuffer = new Map(); // displayNumber -> lines[]
116
116
  const taskProjectPaths = new Map(); // displayNumber -> projectPath
117
117
  const taskLastHeartbeat = new Map(); // displayNumber -> timestamp of last progress heartbeat
118
+
119
+ // Stream-json state (Phase 1: real-time progress)
120
+ const taskStreamLineBuffer = new Map(); // displayNumber -> partial NDJSON line fragment
121
+ const taskActivityState = new Map(); // displayNumber -> { filesRead: Set, filesEdited: Set, currentTool: string, lastText: string }
122
+ const taskLastStreamProgress = new Map(); // displayNumber -> timestamp of last stream progress sent
123
+ const STREAM_PROGRESS_THROTTLE_MS = 30000; // 30s between stream progress updates to Supabase
118
124
  let daemonStartTime = null;
119
125
 
120
126
  // ==================== Utilities ====================
@@ -282,6 +288,11 @@ function getAutoUpdateEnabled() {
282
288
  return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
283
289
  }
284
290
 
291
+ function getAutoUpdateAgentsEnabled() {
292
+ const v = getConfigValueFromFile('AUTO_UPDATE_AGENTS', 'false');
293
+ return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
294
+ }
295
+
285
296
  // ==================== Capabilities Detection ====================
286
297
 
287
298
  let cachedCapabilities = null;
@@ -1327,6 +1338,155 @@ function checkStuckPatterns(displayNumber, line) {
1327
1338
  return null;
1328
1339
  }
1329
1340
 
1341
+ // ==================== Stream-JSON Parsing (Phase 1) ====================
1342
+
1343
+ function parseStreamJsonLine(line) {
1344
+ const trimmed = line.trim();
1345
+ if (!trimmed || !trimmed.startsWith('{')) return null;
1346
+ try {
1347
+ return JSON.parse(trimmed);
1348
+ } catch {
1349
+ return null;
1350
+ }
1351
+ }
1352
+
1353
+ function extractTextFromContent(content) {
1354
+ if (!Array.isArray(content)) return '';
1355
+ return content
1356
+ .filter(block => block.type === 'text' && block.text)
1357
+ .map(block => block.text)
1358
+ .join('\n');
1359
+ }
1360
+
1361
+ function extractToolCallsFromContent(content) {
1362
+ if (!Array.isArray(content)) return [];
1363
+ return content
1364
+ .filter(block => block.type === 'tool_use')
1365
+ .map(block => ({ name: block.name, input: block.input || {} }));
1366
+ }
1367
+
1368
+ function processStreamEvent(displayNumber, event) {
1369
+ if (!event || !event.type) return;
1370
+
1371
+ const activity = taskActivityState.get(displayNumber) || {
1372
+ filesRead: new Set(),
1373
+ filesEdited: new Set(),
1374
+ currentTool: null,
1375
+ lastText: '',
1376
+ sessionId: null
1377
+ };
1378
+
1379
+ // Extract session_id from system init or result messages
1380
+ if (event.type === 'system' && event.session_id) {
1381
+ activity.sessionId = event.session_id;
1382
+ }
1383
+ if (event.type === 'result' && event.session_id) {
1384
+ activity.sessionId = event.session_id;
1385
+ }
1386
+
1387
+ // Extract activity from assistant messages
1388
+ if (event.type === 'assistant') {
1389
+ const content = event.message?.content;
1390
+ if (!content) { taskActivityState.set(displayNumber, activity); return; }
1391
+
1392
+ const text = extractTextFromContent(content);
1393
+ if (text) {
1394
+ activity.lastText = text.slice(0, 200);
1395
+
1396
+ // Run existing text-based checks on extracted content
1397
+ if (text.includes('[push-confirm] Waiting for')) {
1398
+ updateTaskDetail(displayNumber, {
1399
+ phase: 'awaiting_confirmation',
1400
+ detail: 'Waiting for user confirmation on iPhone'
1401
+ });
1402
+ }
1403
+
1404
+ const stuckReason = checkStuckPatterns(displayNumber, text);
1405
+ if (stuckReason) {
1406
+ log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
1407
+ updateTaskDetail(displayNumber, {
1408
+ phase: 'stuck',
1409
+ detail: `Waiting for input: ${stuckReason}`
1410
+ });
1411
+ }
1412
+ }
1413
+
1414
+ const toolCalls = extractToolCallsFromContent(content);
1415
+ for (const tool of toolCalls) {
1416
+ activity.currentTool = tool.name;
1417
+ const filePath = tool.input?.file_path || tool.input?.path;
1418
+ if (filePath) {
1419
+ if (tool.name === 'Read') {
1420
+ activity.filesRead.add(filePath);
1421
+ } else if (tool.name === 'Edit' || tool.name === 'Write') {
1422
+ activity.filesEdited.add(filePath);
1423
+ }
1424
+ }
1425
+ }
1426
+ }
1427
+
1428
+ taskActivityState.set(displayNumber, activity);
1429
+
1430
+ // Throttled progress reporting to Supabase
1431
+ maybesSendStreamProgress(displayNumber, activity);
1432
+ }
1433
+
1434
+ function maybesSendStreamProgress(displayNumber, activity) {
1435
+ const now = Date.now();
1436
+ const lastSent = taskLastStreamProgress.get(displayNumber) || 0;
1437
+ if (now - lastSent < STREAM_PROGRESS_THROTTLE_MS) return;
1438
+ taskLastStreamProgress.set(displayNumber, now);
1439
+
1440
+ const info = taskDetails.get(displayNumber) || {};
1441
+ const taskId = info.taskId || null;
1442
+ const taskInfo = runningTasks.get(displayNumber);
1443
+ if (!taskInfo) return;
1444
+
1445
+ const elapsedSec = Math.floor((now - taskInfo.startTime) / 1000);
1446
+ const elapsedMin = Math.floor(elapsedSec / 60);
1447
+
1448
+ // Build a meaningful summary from stream activity
1449
+ const parts = [`Running for ${elapsedMin}m.`];
1450
+ if (activity.filesEdited.size > 0) {
1451
+ const editedList = [...activity.filesEdited].map(f => f.split('/').pop()).slice(-5);
1452
+ parts.push(`Edited: ${editedList.join(', ')}`);
1453
+ }
1454
+ if (activity.filesRead.size > 0) {
1455
+ parts.push(`Read ${activity.filesRead.size} files.`);
1456
+ }
1457
+ if (activity.currentTool) {
1458
+ parts.push(`Current: ${activity.currentTool}`);
1459
+ }
1460
+ if (activity.lastText && !activity.currentTool) {
1461
+ parts.push(activity.lastText.slice(0, 80));
1462
+ }
1463
+
1464
+ const eventSummary = parts.join(' ');
1465
+
1466
+ log(`Task #${displayNumber}: stream progress (${eventSummary})`);
1467
+
1468
+ // Also update the existing heartbeat timestamp to prevent duplicate generic heartbeats
1469
+ taskLastHeartbeat.set(displayNumber, now);
1470
+
1471
+ apiRequest('update-task-execution', {
1472
+ method: 'PATCH',
1473
+ body: JSON.stringify({
1474
+ todoId: taskId,
1475
+ displayNumber,
1476
+ event: {
1477
+ type: 'progress',
1478
+ timestamp: new Date().toISOString(),
1479
+ machineName: getMachineName() || undefined,
1480
+ summary: eventSummary,
1481
+ filesEdited: [...activity.filesEdited].slice(-10),
1482
+ filesRead: activity.filesRead.size
1483
+ }
1484
+ })
1485
+ }).catch(err => {
1486
+ log(`Task #${displayNumber}: stream progress failed (non-fatal): ${err.message}`);
1487
+ });
1488
+ }
1489
+
1330
1490
  function monitorTaskStdout(displayNumber, proc) {
1331
1491
  if (!proc.stdout) return;
1332
1492
 
@@ -1424,7 +1584,23 @@ async function sendProgressHeartbeats() {
1424
1584
 
1425
1585
  const activityDesc = idleSec < 60 ? 'active' : `idle ${idleMin}m`;
1426
1586
  const phase = info.phase || 'executing';
1427
- const eventSummary = `Running for ${elapsedMin}m. Last activity: ${activityDesc}. Phase: ${phase}.`;
1587
+
1588
+ // Enrich heartbeat with stream activity data when available
1589
+ const activity = taskActivityState.get(displayNumber);
1590
+ const parts = [`Running for ${elapsedMin}m. Last activity: ${activityDesc}. Phase: ${phase}.`];
1591
+ const eventExtra = {};
1592
+ if (activity) {
1593
+ if (activity.filesEdited.size > 0) {
1594
+ const editedList = [...activity.filesEdited].map(f => f.split('/').pop()).slice(-5);
1595
+ parts.push(`Edited: ${editedList.join(', ')}`);
1596
+ eventExtra.filesEdited = [...activity.filesEdited].slice(-10);
1597
+ }
1598
+ if (activity.filesRead.size > 0) {
1599
+ parts.push(`Read ${activity.filesRead.size} files.`);
1600
+ eventExtra.filesRead = activity.filesRead.size;
1601
+ }
1602
+ }
1603
+ const eventSummary = parts.join(' ');
1428
1604
 
1429
1605
  log(`Task #${displayNumber}: sending progress heartbeat (${eventSummary})`);
1430
1606
 
@@ -1442,7 +1618,8 @@ async function sendProgressHeartbeats() {
1442
1618
  type: 'progress',
1443
1619
  timestamp: new Date().toISOString(),
1444
1620
  machineName: getMachineName() || undefined,
1445
- summary: eventSummary
1621
+ summary: eventSummary,
1622
+ ...eventExtra
1446
1623
  }
1447
1624
  // No status field — event-only update, won't change execution_status
1448
1625
  })
@@ -1538,6 +1715,9 @@ async function killIdleTasks() {
1538
1715
  taskStderrBuffer.delete(displayNumber);
1539
1716
  taskProjectPaths.delete(displayNumber);
1540
1717
  taskLastHeartbeat.delete(displayNumber);
1718
+ taskStreamLineBuffer.delete(displayNumber);
1719
+ taskActivityState.delete(displayNumber);
1720
+ taskLastStreamProgress.delete(displayNumber);
1541
1721
  }
1542
1722
 
1543
1723
  if (idleTimedOut.length > 0) {
@@ -1547,7 +1727,14 @@ async function killIdleTasks() {
1547
1727
 
1548
1728
  // ==================== Session ID Extraction ====================
1549
1729
 
1550
- function extractSessionIdFromStdout(proc, buffer) {
1730
+ function extractSessionIdFromStdout(proc, buffer, displayNumber) {
1731
+ // First: check stream activity state (populated by stream-json parsing)
1732
+ if (displayNumber != null) {
1733
+ const activity = taskActivityState.get(displayNumber);
1734
+ if (activity?.sessionId) return activity.sessionId;
1735
+ }
1736
+
1737
+ // Fallback: drain remaining stdout and scan for session_id in JSON lines
1551
1738
  let remaining = '';
1552
1739
  if (proc.stdout) {
1553
1740
  try {
@@ -1557,7 +1744,6 @@ function extractSessionIdFromStdout(proc, buffer) {
1557
1744
 
1558
1745
  const allOutput = buffer.join('\n') + '\n' + remaining;
1559
1746
 
1560
- // Try to parse JSON output
1561
1747
  for (const line of allOutput.split('\n')) {
1562
1748
  const trimmed = line.trim();
1563
1749
  if (trimmed.startsWith('{') && trimmed.includes('session_id')) {
@@ -1822,14 +2008,14 @@ async function executeTask(task) {
1822
2008
  '--continue', previousSessionId,
1823
2009
  '-p', prompt,
1824
2010
  '--allowedTools', allowedTools,
1825
- '--output-format', 'json',
2011
+ '--output-format', 'stream-json',
1826
2012
  '--permission-mode', 'bypassPermissions',
1827
2013
  '--session-id', preAssignedSessionId
1828
2014
  ]
1829
2015
  : [
1830
2016
  '-p', prompt,
1831
2017
  '--allowedTools', allowedTools,
1832
- '--output-format', 'json',
2018
+ '--output-format', 'stream-json',
1833
2019
  '--permission-mode', 'bypassPermissions',
1834
2020
  '--session-id', preAssignedSessionId
1835
2021
  ];
@@ -1878,25 +2064,44 @@ async function executeTask(task) {
1878
2064
  }
1879
2065
  });
1880
2066
 
1881
- // Monitor stdout
2067
+ // Monitor stdout (stream-json NDJSON parsing)
2068
+ taskStreamLineBuffer.set(displayNumber, '');
2069
+ taskActivityState.set(displayNumber, {
2070
+ filesRead: new Set(), filesEdited: new Set(),
2071
+ currentTool: null, lastText: '', sessionId: null
2072
+ });
2073
+ taskLastStreamProgress.set(displayNumber, Date.now());
2074
+
1882
2075
  child.stdout.on('data', (data) => {
1883
- const lines = data.toString().split('\n');
2076
+ taskLastOutput.set(displayNumber, Date.now());
2077
+
2078
+ // NDJSON line buffering: handle chunks that split across line boundaries
2079
+ const pending = (taskStreamLineBuffer.get(displayNumber) || '') + data.toString();
2080
+ const lines = pending.split('\n');
2081
+ // Last element is either empty (chunk ended with \n) or a partial line
2082
+ taskStreamLineBuffer.set(displayNumber, lines.pop() || '');
2083
+
1884
2084
  for (const line of lines) {
1885
- if (line.trim()) {
1886
- taskLastOutput.set(displayNumber, Date.now());
1887
- const buffer = taskStdoutBuffer.get(displayNumber) || [];
1888
- buffer.push(line);
1889
- if (buffer.length > 20) buffer.shift();
1890
- taskStdoutBuffer.set(displayNumber, buffer);
2085
+ if (!line.trim()) continue;
1891
2086
 
1892
- // Detect confirmation waiting (exempt from timeouts)
2087
+ // Keep raw lines in circular buffer for debugging/fallback
2088
+ const buffer = taskStdoutBuffer.get(displayNumber) || [];
2089
+ buffer.push(line);
2090
+ if (buffer.length > 50) buffer.shift();
2091
+ taskStdoutBuffer.set(displayNumber, buffer);
2092
+
2093
+ // Parse as NDJSON stream event
2094
+ const event = parseStreamJsonLine(line);
2095
+ if (event) {
2096
+ processStreamEvent(displayNumber, event);
2097
+ } else {
2098
+ // Fallback: line isn't valid JSON — run legacy text checks
1893
2099
  if (line.includes('[push-confirm] Waiting for')) {
1894
2100
  updateTaskDetail(displayNumber, {
1895
2101
  phase: 'awaiting_confirmation',
1896
2102
  detail: 'Waiting for user confirmation on iPhone'
1897
2103
  });
1898
2104
  }
1899
-
1900
2105
  const stuckReason = checkStuckPatterns(displayNumber, line);
1901
2106
  if (stuckReason) {
1902
2107
  log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
@@ -1920,6 +2125,9 @@ async function executeTask(task) {
1920
2125
  await updateTaskStatus(displayNumber, 'failed', { error: error.message }, taskId);
1921
2126
  taskDetails.delete(displayNumber);
1922
2127
  taskLastHeartbeat.delete(displayNumber);
2128
+ taskStreamLineBuffer.delete(displayNumber);
2129
+ taskActivityState.delete(displayNumber);
2130
+ taskLastStreamProgress.delete(displayNumber);
1923
2131
  updateStatusFile();
1924
2132
  });
1925
2133
 
@@ -1936,6 +2144,9 @@ async function executeTask(task) {
1936
2144
  logError(`Error starting Claude for task #${displayNumber}: ${error.message}`);
1937
2145
  await updateTaskStatus(displayNumber, 'failed', { error: error.message }, taskId);
1938
2146
  taskDetails.delete(displayNumber);
2147
+ taskStreamLineBuffer.delete(displayNumber);
2148
+ taskActivityState.delete(displayNumber);
2149
+ taskLastStreamProgress.delete(displayNumber);
1939
2150
  return null;
1940
2151
  }
1941
2152
  }
@@ -1954,7 +2165,7 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1954
2165
  log(`Task #${displayNumber} completed with code ${exitCode} (${duration}s)`);
1955
2166
 
1956
2167
  // Session ID: prefer pre-assigned ID (reliable), fall back to stdout extraction
1957
- const sessionId = taskInfo.sessionId || extractSessionIdFromStdout(taskInfo.process, taskStdoutBuffer.get(displayNumber) || []);
2168
+ const sessionId = taskInfo.sessionId || extractSessionIdFromStdout(taskInfo.process, taskStdoutBuffer.get(displayNumber) || [], displayNumber);
1958
2169
  const worktreePath = getWorktreePath(displayNumber, projectPath);
1959
2170
  const durationStr = duration < 60 ? `${duration}s` : `${Math.floor(duration / 60)}m ${duration % 60}s`;
1960
2171
  const machineName = getMachineName() || 'Mac';
@@ -2296,6 +2507,9 @@ async function checkTimeouts() {
2296
2507
  taskStderrBuffer.delete(displayNumber);
2297
2508
  taskProjectPaths.delete(displayNumber);
2298
2509
  taskLastHeartbeat.delete(displayNumber);
2510
+ taskStreamLineBuffer.delete(displayNumber);
2511
+ taskActivityState.delete(displayNumber);
2512
+ taskLastStreamProgress.delete(displayNumber);
2299
2513
  cleanupWorktree(displayNumber, projectPath);
2300
2514
  }
2301
2515
 
@@ -2355,6 +2569,55 @@ function checkAndApplyUpdate() {
2355
2569
  }
2356
2570
  }
2357
2571
 
2572
+ // ==================== Agent Auto-Update ====================
2573
+
2574
+ let pendingAgentUpdates = null; // { agentType: targetVersion, ... }
2575
+
2576
+ function checkAndApplyAgentUpdates() {
2577
+ // Check for updates (throttled internally to once per hour)
2578
+ if (!pendingAgentUpdates) {
2579
+ const results = checkAllAgentUpdates();
2580
+ if (!results) return; // throttled
2581
+
2582
+ const available = {};
2583
+ for (const [agentType, info] of Object.entries(results)) {
2584
+ if (info.available) {
2585
+ available[agentType] = info.latest;
2586
+ log(`Agent update available: ${agentType} v${info.current} -> v${info.latest}`);
2587
+ }
2588
+ }
2589
+ if (Object.keys(available).length > 0) {
2590
+ pendingAgentUpdates = available;
2591
+ }
2592
+ }
2593
+
2594
+ // Only apply when no tasks are running
2595
+ if (pendingAgentUpdates && runningTasks.size === 0) {
2596
+ for (const [agentType, targetVersion] of Object.entries(pendingAgentUpdates)) {
2597
+ log(`Updating ${agentType} to v${targetVersion}...`);
2598
+ const success = performAgentUpdate(agentType, targetVersion);
2599
+ if (success) {
2600
+ log(`${agentType} updated to v${targetVersion}`);
2601
+ sendMacNotification(
2602
+ 'Push: Agent Updated',
2603
+ `${agentType} updated to v${targetVersion}`,
2604
+ 'Glass'
2605
+ );
2606
+ } else {
2607
+ logError(`${agentType} update to v${targetVersion} failed`);
2608
+ }
2609
+ }
2610
+ pendingAgentUpdates = null;
2611
+ }
2612
+ }
2613
+
2614
+ function logVersionParityWarnings() {
2615
+ const warnings = checkVersionParity();
2616
+ for (const w of warnings) {
2617
+ log(`WARNING: ${w.agentType} v${w.installed} is below minimum v${w.required} — some features may not work`);
2618
+ }
2619
+ }
2620
+
2358
2621
  // ==================== Main Loop ====================
2359
2622
 
2360
2623
  async function pollAndExecute() {
@@ -2457,10 +2720,12 @@ async function mainLoop() {
2457
2720
  log(`Max concurrent tasks: ${MAX_CONCURRENT_TASKS}`);
2458
2721
  log(`E2EE: ${e2eeAvailable ? 'Available' : 'Not available'}`);
2459
2722
  log(`Auto-update: ${getAutoUpdateEnabled() ? 'Enabled' : 'Disabled'}`);
2723
+ log(`Auto-update-agents: ${getAutoUpdateAgentsEnabled() ? 'Enabled' : 'Disabled'}`);
2460
2724
  const caps = getCapabilities();
2461
2725
  log(`Capabilities: gh=${caps.gh_cli}, auto-merge=${caps.auto_merge}, auto-complete=${caps.auto_complete}`);
2462
2726
  const agentVersions = getAgentVersions({ force: true });
2463
2727
  log(`Agent versions: ${formatAgentVersionSummary(agentVersions)}`);
2728
+ logVersionParityWarnings();
2464
2729
  log(`Log file: ${LOG_FILE}`);
2465
2730
 
2466
2731
  // Show registered projects
@@ -2559,6 +2824,15 @@ async function mainLoop() {
2559
2824
  if (getAutoUpdateEnabled()) {
2560
2825
  checkAndApplyUpdate();
2561
2826
  }
2827
+
2828
+ // Agent CLI auto-update (throttled to once per hour, only applies when idle)
2829
+ if (getAutoUpdateAgentsEnabled()) {
2830
+ try {
2831
+ checkAndApplyAgentUpdates();
2832
+ } catch (error) {
2833
+ logError(`Agent auto-update error: ${error.message}`);
2834
+ }
2835
+ }
2562
2836
  } catch (error) {
2563
2837
  logError(`Poll error: ${error.message}`);
2564
2838
  }
@@ -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.7",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {