@masslessai/push-todo 4.1.6 → 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.
Files changed (2) hide show
  1. package/lib/daemon.js +226 -17
  2. package/package.json +1 -1
package/lib/daemon.js CHANGED
@@ -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 ====================
@@ -1332,6 +1338,155 @@ function checkStuckPatterns(displayNumber, line) {
1332
1338
  return null;
1333
1339
  }
1334
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
+
1335
1490
  function monitorTaskStdout(displayNumber, proc) {
1336
1491
  if (!proc.stdout) return;
1337
1492
 
@@ -1429,7 +1584,23 @@ async function sendProgressHeartbeats() {
1429
1584
 
1430
1585
  const activityDesc = idleSec < 60 ? 'active' : `idle ${idleMin}m`;
1431
1586
  const phase = info.phase || 'executing';
1432
- 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(' ');
1433
1604
 
1434
1605
  log(`Task #${displayNumber}: sending progress heartbeat (${eventSummary})`);
1435
1606
 
@@ -1447,7 +1618,8 @@ async function sendProgressHeartbeats() {
1447
1618
  type: 'progress',
1448
1619
  timestamp: new Date().toISOString(),
1449
1620
  machineName: getMachineName() || undefined,
1450
- summary: eventSummary
1621
+ summary: eventSummary,
1622
+ ...eventExtra
1451
1623
  }
1452
1624
  // No status field — event-only update, won't change execution_status
1453
1625
  })
@@ -1543,6 +1715,9 @@ async function killIdleTasks() {
1543
1715
  taskStderrBuffer.delete(displayNumber);
1544
1716
  taskProjectPaths.delete(displayNumber);
1545
1717
  taskLastHeartbeat.delete(displayNumber);
1718
+ taskStreamLineBuffer.delete(displayNumber);
1719
+ taskActivityState.delete(displayNumber);
1720
+ taskLastStreamProgress.delete(displayNumber);
1546
1721
  }
1547
1722
 
1548
1723
  if (idleTimedOut.length > 0) {
@@ -1552,7 +1727,14 @@ async function killIdleTasks() {
1552
1727
 
1553
1728
  // ==================== Session ID Extraction ====================
1554
1729
 
1555
- 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
1556
1738
  let remaining = '';
1557
1739
  if (proc.stdout) {
1558
1740
  try {
@@ -1562,7 +1744,6 @@ function extractSessionIdFromStdout(proc, buffer) {
1562
1744
 
1563
1745
  const allOutput = buffer.join('\n') + '\n' + remaining;
1564
1746
 
1565
- // Try to parse JSON output
1566
1747
  for (const line of allOutput.split('\n')) {
1567
1748
  const trimmed = line.trim();
1568
1749
  if (trimmed.startsWith('{') && trimmed.includes('session_id')) {
@@ -1827,14 +2008,14 @@ async function executeTask(task) {
1827
2008
  '--continue', previousSessionId,
1828
2009
  '-p', prompt,
1829
2010
  '--allowedTools', allowedTools,
1830
- '--output-format', 'json',
2011
+ '--output-format', 'stream-json',
1831
2012
  '--permission-mode', 'bypassPermissions',
1832
2013
  '--session-id', preAssignedSessionId
1833
2014
  ]
1834
2015
  : [
1835
2016
  '-p', prompt,
1836
2017
  '--allowedTools', allowedTools,
1837
- '--output-format', 'json',
2018
+ '--output-format', 'stream-json',
1838
2019
  '--permission-mode', 'bypassPermissions',
1839
2020
  '--session-id', preAssignedSessionId
1840
2021
  ];
@@ -1883,25 +2064,44 @@ async function executeTask(task) {
1883
2064
  }
1884
2065
  });
1885
2066
 
1886
- // 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
+
1887
2075
  child.stdout.on('data', (data) => {
1888
- 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
+
1889
2084
  for (const line of lines) {
1890
- if (line.trim()) {
1891
- taskLastOutput.set(displayNumber, Date.now());
1892
- const buffer = taskStdoutBuffer.get(displayNumber) || [];
1893
- buffer.push(line);
1894
- if (buffer.length > 20) buffer.shift();
1895
- taskStdoutBuffer.set(displayNumber, buffer);
2085
+ if (!line.trim()) continue;
1896
2086
 
1897
- // 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
1898
2099
  if (line.includes('[push-confirm] Waiting for')) {
1899
2100
  updateTaskDetail(displayNumber, {
1900
2101
  phase: 'awaiting_confirmation',
1901
2102
  detail: 'Waiting for user confirmation on iPhone'
1902
2103
  });
1903
2104
  }
1904
-
1905
2105
  const stuckReason = checkStuckPatterns(displayNumber, line);
1906
2106
  if (stuckReason) {
1907
2107
  log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
@@ -1925,6 +2125,9 @@ async function executeTask(task) {
1925
2125
  await updateTaskStatus(displayNumber, 'failed', { error: error.message }, taskId);
1926
2126
  taskDetails.delete(displayNumber);
1927
2127
  taskLastHeartbeat.delete(displayNumber);
2128
+ taskStreamLineBuffer.delete(displayNumber);
2129
+ taskActivityState.delete(displayNumber);
2130
+ taskLastStreamProgress.delete(displayNumber);
1928
2131
  updateStatusFile();
1929
2132
  });
1930
2133
 
@@ -1941,6 +2144,9 @@ async function executeTask(task) {
1941
2144
  logError(`Error starting Claude for task #${displayNumber}: ${error.message}`);
1942
2145
  await updateTaskStatus(displayNumber, 'failed', { error: error.message }, taskId);
1943
2146
  taskDetails.delete(displayNumber);
2147
+ taskStreamLineBuffer.delete(displayNumber);
2148
+ taskActivityState.delete(displayNumber);
2149
+ taskLastStreamProgress.delete(displayNumber);
1944
2150
  return null;
1945
2151
  }
1946
2152
  }
@@ -1959,7 +2165,7 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1959
2165
  log(`Task #${displayNumber} completed with code ${exitCode} (${duration}s)`);
1960
2166
 
1961
2167
  // Session ID: prefer pre-assigned ID (reliable), fall back to stdout extraction
1962
- const sessionId = taskInfo.sessionId || extractSessionIdFromStdout(taskInfo.process, taskStdoutBuffer.get(displayNumber) || []);
2168
+ const sessionId = taskInfo.sessionId || extractSessionIdFromStdout(taskInfo.process, taskStdoutBuffer.get(displayNumber) || [], displayNumber);
1963
2169
  const worktreePath = getWorktreePath(displayNumber, projectPath);
1964
2170
  const durationStr = duration < 60 ? `${duration}s` : `${Math.floor(duration / 60)}m ${duration % 60}s`;
1965
2171
  const machineName = getMachineName() || 'Mac';
@@ -2301,6 +2507,9 @@ async function checkTimeouts() {
2301
2507
  taskStderrBuffer.delete(displayNumber);
2302
2508
  taskProjectPaths.delete(displayNumber);
2303
2509
  taskLastHeartbeat.delete(displayNumber);
2510
+ taskStreamLineBuffer.delete(displayNumber);
2511
+ taskActivityState.delete(displayNumber);
2512
+ taskLastStreamProgress.delete(displayNumber);
2304
2513
  cleanupWorktree(displayNumber, projectPath);
2305
2514
  }
2306
2515
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.1.6",
3
+ "version": "4.1.7",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {