@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.
Files changed (2) hide show
  1. package/lib/daemon.js +217 -3
  2. 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.1.4",
3
+ "version": "4.1.5",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {