@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.
- package/lib/daemon.js +226 -17
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|