@masslessai/push-todo 4.1.4 → 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.
- package/lib/daemon.js +217 -3
- package/package.json +1 -1
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';
|
|
@@ -48,6 +48,10 @@ const RETRY_BACKOFF_FACTOR = 2;
|
|
|
48
48
|
const CERTAINTY_HIGH_THRESHOLD = 0.7;
|
|
49
49
|
const CERTAINTY_LOW_THRESHOLD = 0.4;
|
|
50
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
|
+
|
|
51
55
|
// Stuck detection
|
|
52
56
|
const STUCK_IDLE_THRESHOLD = 600000; // 10 min
|
|
53
57
|
const STUCK_WARNING_THRESHOLD = 300000; // 5 min
|
|
@@ -110,6 +114,7 @@ const taskLastOutput = new Map(); // displayNumber -> timestamp
|
|
|
110
114
|
const taskStdoutBuffer = new Map(); // displayNumber -> lines[]
|
|
111
115
|
const taskStderrBuffer = new Map(); // displayNumber -> lines[]
|
|
112
116
|
const taskProjectPaths = new Map(); // displayNumber -> projectPath
|
|
117
|
+
const taskLastHeartbeat = new Map(); // displayNumber -> timestamp of last progress heartbeat
|
|
113
118
|
let daemonStartTime = null;
|
|
114
119
|
|
|
115
120
|
// ==================== Utilities ====================
|
|
@@ -283,6 +288,59 @@ let cachedCapabilities = null;
|
|
|
283
288
|
let lastCapabilityCheck = 0;
|
|
284
289
|
const CAPABILITY_CHECK_INTERVAL = 3600000; // 1 hour
|
|
285
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
|
+
|
|
286
344
|
function detectCapabilities() {
|
|
287
345
|
const caps = {
|
|
288
346
|
auto_merge: getAutoMergeEnabled(),
|
|
@@ -302,6 +360,8 @@ function detectCapabilities() {
|
|
|
302
360
|
caps.gh_cli = 'not_installed';
|
|
303
361
|
}
|
|
304
362
|
|
|
363
|
+
caps.project_skills = discoverProjectSkills();
|
|
364
|
+
|
|
305
365
|
return caps;
|
|
306
366
|
}
|
|
307
367
|
|
|
@@ -1340,6 +1400,151 @@ function checkTaskIdle(displayNumber) {
|
|
|
1340
1400
|
return false;
|
|
1341
1401
|
}
|
|
1342
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
|
+
|
|
1343
1548
|
// ==================== Session ID Extraction ====================
|
|
1344
1549
|
|
|
1345
1550
|
function extractSessionIdFromStdout(proc, buffer) {
|
|
@@ -1616,7 +1821,6 @@ async function executeTask(task) {
|
|
|
1616
1821
|
? [
|
|
1617
1822
|
'--continue', previousSessionId,
|
|
1618
1823
|
'-p', prompt,
|
|
1619
|
-
'--worktree', worktreeName,
|
|
1620
1824
|
'--allowedTools', allowedTools,
|
|
1621
1825
|
'--output-format', 'json',
|
|
1622
1826
|
'--permission-mode', 'bypassPermissions',
|
|
@@ -1624,7 +1828,6 @@ async function executeTask(task) {
|
|
|
1624
1828
|
]
|
|
1625
1829
|
: [
|
|
1626
1830
|
'-p', prompt,
|
|
1627
|
-
'--worktree', worktreeName,
|
|
1628
1831
|
'--allowedTools', allowedTools,
|
|
1629
1832
|
'--output-format', 'json',
|
|
1630
1833
|
'--permission-mode', 'bypassPermissions',
|
|
@@ -1660,6 +1863,7 @@ async function executeTask(task) {
|
|
|
1660
1863
|
taskLastOutput.set(displayNumber, Date.now());
|
|
1661
1864
|
taskStdoutBuffer.set(displayNumber, []);
|
|
1662
1865
|
taskStderrBuffer.set(displayNumber, []);
|
|
1866
|
+
taskLastHeartbeat.set(displayNumber, Date.now());
|
|
1663
1867
|
|
|
1664
1868
|
// Monitor stderr (critical for diagnosing fast exits)
|
|
1665
1869
|
child.stderr.on('data', (data) => {
|
|
@@ -1715,6 +1919,7 @@ async function executeTask(task) {
|
|
|
1715
1919
|
runningTasks.delete(displayNumber);
|
|
1716
1920
|
await updateTaskStatus(displayNumber, 'failed', { error: error.message }, taskId);
|
|
1717
1921
|
taskDetails.delete(displayNumber);
|
|
1922
|
+
taskLastHeartbeat.delete(displayNumber);
|
|
1718
1923
|
updateStatusFile();
|
|
1719
1924
|
});
|
|
1720
1925
|
|
|
@@ -1938,6 +2143,7 @@ async function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1938
2143
|
taskStdoutBuffer.delete(displayNumber);
|
|
1939
2144
|
taskStderrBuffer.delete(displayNumber);
|
|
1940
2145
|
taskProjectPaths.delete(displayNumber);
|
|
2146
|
+
taskLastHeartbeat.delete(displayNumber);
|
|
1941
2147
|
updateStatusFile();
|
|
1942
2148
|
}
|
|
1943
2149
|
|
|
@@ -2016,6 +2222,13 @@ function updateStatusFile() {
|
|
|
2016
2222
|
// ==================== Task Checking ====================
|
|
2017
2223
|
|
|
2018
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)
|
|
2019
2232
|
const now = Date.now();
|
|
2020
2233
|
const timedOut = [];
|
|
2021
2234
|
|
|
@@ -2082,6 +2295,7 @@ async function checkTimeouts() {
|
|
|
2082
2295
|
taskStdoutBuffer.delete(displayNumber);
|
|
2083
2296
|
taskStderrBuffer.delete(displayNumber);
|
|
2084
2297
|
taskProjectPaths.delete(displayNumber);
|
|
2298
|
+
taskLastHeartbeat.delete(displayNumber);
|
|
2085
2299
|
cleanupWorktree(displayNumber, projectPath);
|
|
2086
2300
|
}
|
|
2087
2301
|
|