@masslessai/push-todo 4.1.6 → 4.1.8

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 +294 -19
  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,182 @@ 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
+ }).then(async (response) => {
1486
+ // Check for cancellation signal in response
1487
+ const result = await response.json().catch(() => null);
1488
+ if (result?.status === 'cancelling') {
1489
+ handleCancellation(displayNumber, 'stream_progress');
1490
+ }
1491
+ }).catch(err => {
1492
+ log(`Task #${displayNumber}: stream progress failed (non-fatal): ${err.message}`);
1493
+ });
1494
+ }
1495
+
1496
+ // ==================== Cancellation (Phase 2) ====================
1497
+
1498
+ function handleCancellation(displayNumber, source) {
1499
+ const taskInfo = runningTasks.get(displayNumber);
1500
+ if (!taskInfo) return;
1501
+
1502
+ log(`Task #${displayNumber}: cancellation detected via ${source}, sending SIGTERM`);
1503
+
1504
+ try {
1505
+ taskInfo.process.kill('SIGTERM');
1506
+ } catch (err) {
1507
+ log(`Task #${displayNumber}: SIGTERM failed: ${err.message}`);
1508
+ }
1509
+
1510
+ // Mark as cancelled so handleTaskCompletion reports the right reason
1511
+ updateTaskDetail(displayNumber, {
1512
+ phase: 'cancelled',
1513
+ detail: 'Cancelled by user'
1514
+ });
1515
+ }
1516
+
1335
1517
  function monitorTaskStdout(displayNumber, proc) {
1336
1518
  if (!proc.stdout) return;
1337
1519
 
@@ -1429,7 +1611,23 @@ async function sendProgressHeartbeats() {
1429
1611
 
1430
1612
  const activityDesc = idleSec < 60 ? 'active' : `idle ${idleMin}m`;
1431
1613
  const phase = info.phase || 'executing';
1432
- const eventSummary = `Running for ${elapsedMin}m. Last activity: ${activityDesc}. Phase: ${phase}.`;
1614
+
1615
+ // Enrich heartbeat with stream activity data when available
1616
+ const activity = taskActivityState.get(displayNumber);
1617
+ const parts = [`Running for ${elapsedMin}m. Last activity: ${activityDesc}. Phase: ${phase}.`];
1618
+ const eventExtra = {};
1619
+ if (activity) {
1620
+ if (activity.filesEdited.size > 0) {
1621
+ const editedList = [...activity.filesEdited].map(f => f.split('/').pop()).slice(-5);
1622
+ parts.push(`Edited: ${editedList.join(', ')}`);
1623
+ eventExtra.filesEdited = [...activity.filesEdited].slice(-10);
1624
+ }
1625
+ if (activity.filesRead.size > 0) {
1626
+ parts.push(`Read ${activity.filesRead.size} files.`);
1627
+ eventExtra.filesRead = activity.filesRead.size;
1628
+ }
1629
+ }
1630
+ const eventSummary = parts.join(' ');
1433
1631
 
1434
1632
  log(`Task #${displayNumber}: sending progress heartbeat (${eventSummary})`);
1435
1633
 
@@ -1447,10 +1645,17 @@ async function sendProgressHeartbeats() {
1447
1645
  type: 'progress',
1448
1646
  timestamp: new Date().toISOString(),
1449
1647
  machineName: getMachineName() || undefined,
1450
- summary: eventSummary
1648
+ summary: eventSummary,
1649
+ ...eventExtra
1451
1650
  }
1452
1651
  // No status field — event-only update, won't change execution_status
1453
1652
  })
1653
+ }).then(async (response) => {
1654
+ // Check for cancellation signal in heartbeat response
1655
+ const result = await response.json().catch(() => null);
1656
+ if (result?.status === 'cancelling') {
1657
+ handleCancellation(displayNumber, 'heartbeat');
1658
+ }
1454
1659
  }).catch(err => {
1455
1660
  log(`Task #${displayNumber}: heartbeat failed (non-fatal): ${err.message}`);
1456
1661
  });
@@ -1543,6 +1748,9 @@ async function killIdleTasks() {
1543
1748
  taskStderrBuffer.delete(displayNumber);
1544
1749
  taskProjectPaths.delete(displayNumber);
1545
1750
  taskLastHeartbeat.delete(displayNumber);
1751
+ taskStreamLineBuffer.delete(displayNumber);
1752
+ taskActivityState.delete(displayNumber);
1753
+ taskLastStreamProgress.delete(displayNumber);
1546
1754
  }
1547
1755
 
1548
1756
  if (idleTimedOut.length > 0) {
@@ -1552,7 +1760,14 @@ async function killIdleTasks() {
1552
1760
 
1553
1761
  // ==================== Session ID Extraction ====================
1554
1762
 
1555
- function extractSessionIdFromStdout(proc, buffer) {
1763
+ function extractSessionIdFromStdout(proc, buffer, displayNumber) {
1764
+ // First: check stream activity state (populated by stream-json parsing)
1765
+ if (displayNumber != null) {
1766
+ const activity = taskActivityState.get(displayNumber);
1767
+ if (activity?.sessionId) return activity.sessionId;
1768
+ }
1769
+
1770
+ // Fallback: drain remaining stdout and scan for session_id in JSON lines
1556
1771
  let remaining = '';
1557
1772
  if (proc.stdout) {
1558
1773
  try {
@@ -1562,7 +1777,6 @@ function extractSessionIdFromStdout(proc, buffer) {
1562
1777
 
1563
1778
  const allOutput = buffer.join('\n') + '\n' + remaining;
1564
1779
 
1565
- // Try to parse JSON output
1566
1780
  for (const line of allOutput.split('\n')) {
1567
1781
  const trimmed = line.trim();
1568
1782
  if (trimmed.startsWith('{') && trimmed.includes('session_id')) {
@@ -1827,14 +2041,14 @@ async function executeTask(task) {
1827
2041
  '--continue', previousSessionId,
1828
2042
  '-p', prompt,
1829
2043
  '--allowedTools', allowedTools,
1830
- '--output-format', 'json',
2044
+ '--output-format', 'stream-json',
1831
2045
  '--permission-mode', 'bypassPermissions',
1832
2046
  '--session-id', preAssignedSessionId
1833
2047
  ]
1834
2048
  : [
1835
2049
  '-p', prompt,
1836
2050
  '--allowedTools', allowedTools,
1837
- '--output-format', 'json',
2051
+ '--output-format', 'stream-json',
1838
2052
  '--permission-mode', 'bypassPermissions',
1839
2053
  '--session-id', preAssignedSessionId
1840
2054
  ];
@@ -1883,25 +2097,44 @@ async function executeTask(task) {
1883
2097
  }
1884
2098
  });
1885
2099
 
1886
- // Monitor stdout
2100
+ // Monitor stdout (stream-json NDJSON parsing)
2101
+ taskStreamLineBuffer.set(displayNumber, '');
2102
+ taskActivityState.set(displayNumber, {
2103
+ filesRead: new Set(), filesEdited: new Set(),
2104
+ currentTool: null, lastText: '', sessionId: null
2105
+ });
2106
+ taskLastStreamProgress.set(displayNumber, Date.now());
2107
+
1887
2108
  child.stdout.on('data', (data) => {
1888
- const lines = data.toString().split('\n');
2109
+ taskLastOutput.set(displayNumber, Date.now());
2110
+
2111
+ // NDJSON line buffering: handle chunks that split across line boundaries
2112
+ const pending = (taskStreamLineBuffer.get(displayNumber) || '') + data.toString();
2113
+ const lines = pending.split('\n');
2114
+ // Last element is either empty (chunk ended with \n) or a partial line
2115
+ taskStreamLineBuffer.set(displayNumber, lines.pop() || '');
2116
+
1889
2117
  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);
2118
+ if (!line.trim()) continue;
1896
2119
 
1897
- // Detect confirmation waiting (exempt from timeouts)
2120
+ // Keep raw lines in circular buffer for debugging/fallback
2121
+ const buffer = taskStdoutBuffer.get(displayNumber) || [];
2122
+ buffer.push(line);
2123
+ if (buffer.length > 50) buffer.shift();
2124
+ taskStdoutBuffer.set(displayNumber, buffer);
2125
+
2126
+ // Parse as NDJSON stream event
2127
+ const event = parseStreamJsonLine(line);
2128
+ if (event) {
2129
+ processStreamEvent(displayNumber, event);
2130
+ } else {
2131
+ // Fallback: line isn't valid JSON — run legacy text checks
1898
2132
  if (line.includes('[push-confirm] Waiting for')) {
1899
2133
  updateTaskDetail(displayNumber, {
1900
2134
  phase: 'awaiting_confirmation',
1901
2135
  detail: 'Waiting for user confirmation on iPhone'
1902
2136
  });
1903
2137
  }
1904
-
1905
2138
  const stuckReason = checkStuckPatterns(displayNumber, line);
1906
2139
  if (stuckReason) {
1907
2140
  log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
@@ -1925,6 +2158,9 @@ async function executeTask(task) {
1925
2158
  await updateTaskStatus(displayNumber, 'failed', { error: error.message }, taskId);
1926
2159
  taskDetails.delete(displayNumber);
1927
2160
  taskLastHeartbeat.delete(displayNumber);
2161
+ taskStreamLineBuffer.delete(displayNumber);
2162
+ taskActivityState.delete(displayNumber);
2163
+ taskLastStreamProgress.delete(displayNumber);
1928
2164
  updateStatusFile();
1929
2165
  });
1930
2166
 
@@ -1941,6 +2177,9 @@ async function executeTask(task) {
1941
2177
  logError(`Error starting Claude for task #${displayNumber}: ${error.message}`);
1942
2178
  await updateTaskStatus(displayNumber, 'failed', { error: error.message }, taskId);
1943
2179
  taskDetails.delete(displayNumber);
2180
+ taskStreamLineBuffer.delete(displayNumber);
2181
+ taskActivityState.delete(displayNumber);
2182
+ taskLastStreamProgress.delete(displayNumber);
1944
2183
  return null;
1945
2184
  }
1946
2185
  }
@@ -1956,12 +2195,45 @@ async function handleTaskCompletion(displayNumber, exitCode) {
1956
2195
  const summary = info.summary || 'Unknown task';
1957
2196
  const projectPath = taskProjectPaths.get(displayNumber);
1958
2197
 
1959
- log(`Task #${displayNumber} completed with code ${exitCode} (${duration}s)`);
2198
+ const durationStr = duration < 60 ? `${duration}s` : `${Math.floor(duration / 60)}m ${duration % 60}s`;
2199
+ const wasCancelled = info.phase === 'cancelled';
2200
+ log(`Task #${displayNumber} ${wasCancelled ? 'cancelled' : 'completed'} with code ${exitCode} (${duration}s)`);
2201
+
2202
+ // Handle user cancellation — report as failed with clear reason, skip PR/merge/summary
2203
+ if (wasCancelled) {
2204
+ const cancelMsg = `Cancelled by user after ${durationStr}.`;
2205
+ await updateTaskStatus(displayNumber, 'failed', {
2206
+ error: cancelMsg,
2207
+ sessionId: taskInfo.sessionId
2208
+ }, info.taskId);
2209
+
2210
+ cleanupWorktree(displayNumber, projectPath);
2211
+
2212
+ trackCompleted({
2213
+ displayNumber,
2214
+ summary,
2215
+ completedAt: new Date().toISOString(),
2216
+ duration,
2217
+ status: 'cancelled'
2218
+ });
2219
+
2220
+ // Cleanup internal tracking
2221
+ taskDetails.delete(displayNumber);
2222
+ taskLastOutput.delete(displayNumber);
2223
+ taskStdoutBuffer.delete(displayNumber);
2224
+ taskStderrBuffer.delete(displayNumber);
2225
+ taskProjectPaths.delete(displayNumber);
2226
+ taskLastHeartbeat.delete(displayNumber);
2227
+ taskStreamLineBuffer.delete(displayNumber);
2228
+ taskActivityState.delete(displayNumber);
2229
+ taskLastStreamProgress.delete(displayNumber);
2230
+ updateStatusFile();
2231
+ return;
2232
+ }
1960
2233
 
1961
2234
  // Session ID: prefer pre-assigned ID (reliable), fall back to stdout extraction
1962
- const sessionId = taskInfo.sessionId || extractSessionIdFromStdout(taskInfo.process, taskStdoutBuffer.get(displayNumber) || []);
2235
+ const sessionId = taskInfo.sessionId || extractSessionIdFromStdout(taskInfo.process, taskStdoutBuffer.get(displayNumber) || [], displayNumber);
1963
2236
  const worktreePath = getWorktreePath(displayNumber, projectPath);
1964
- const durationStr = duration < 60 ? `${duration}s` : `${Math.floor(duration / 60)}m ${duration % 60}s`;
1965
2237
  const machineName = getMachineName() || 'Mac';
1966
2238
 
1967
2239
  if (sessionId) {
@@ -2301,6 +2573,9 @@ async function checkTimeouts() {
2301
2573
  taskStderrBuffer.delete(displayNumber);
2302
2574
  taskProjectPaths.delete(displayNumber);
2303
2575
  taskLastHeartbeat.delete(displayNumber);
2576
+ taskStreamLineBuffer.delete(displayNumber);
2577
+ taskActivityState.delete(displayNumber);
2578
+ taskLastStreamProgress.delete(displayNumber);
2304
2579
  cleanupWorktree(displayNumber, projectPath);
2305
2580
  }
2306
2581
 
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.8",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {