@masslessai/push-todo 3.7.7 → 3.7.9

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 (3) hide show
  1. package/lib/daemon.js +332 -52
  2. package/lib/fetch.js +10 -1
  3. package/package.json +1 -1
package/lib/daemon.js CHANGED
@@ -439,7 +439,15 @@ async function updateTaskStatus(displayNumber, status, extra = {}) {
439
439
  });
440
440
 
441
441
  const result = await response.json().catch(() => null);
442
- return response.ok && result?.success !== false;
442
+ if (!response.ok) {
443
+ logError(`Task status update failed: HTTP ${response.status} for #${displayNumber} -> ${status}`);
444
+ return false;
445
+ }
446
+ if (result?.success === false) {
447
+ logError(`Task status update rejected for #${displayNumber} -> ${status}: ${JSON.stringify(result)}`);
448
+ return false;
449
+ }
450
+ return true;
443
451
  } catch (error) {
444
452
  logError(`Failed to update task status: ${error.message}`);
445
453
  return false;
@@ -678,15 +686,13 @@ Automated PR from Push daemon for task #${displayNumber}.
678
686
  }
679
687
 
680
688
  /**
681
- * Merge a PR into main and update local main branch.
682
- * Uses `gh pr merge` for clean remote merge, then pulls locally.
689
+ * Attempt to merge a PR. If merge conflicts, use Claude to resolve them.
683
690
  *
684
691
  * @returns {boolean} True if merge succeeded
685
692
  */
686
693
  function mergePRForTask(displayNumber, prUrl, projectPath) {
687
694
  const gitCwd = projectPath || process.cwd();
688
695
 
689
- // Extract PR number from URL (e.g., https://github.com/user/repo/pull/42)
690
696
  const prMatch = prUrl.match(/\/pull\/(\d+)/);
691
697
  if (!prMatch) {
692
698
  logError(`Could not extract PR number from: ${prUrl}`);
@@ -694,6 +700,26 @@ function mergePRForTask(displayNumber, prUrl, projectPath) {
694
700
  }
695
701
  const prNumber = prMatch[1];
696
702
 
703
+ // First attempt: direct merge
704
+ const firstResult = attemptPRMerge(prNumber, displayNumber, gitCwd);
705
+ if (firstResult === 'success') return true;
706
+ if (firstResult !== 'conflict') return false; // non-conflict failure
707
+
708
+ // Conflict detected — try to resolve with Claude
709
+ const suffix = getWorktreeSuffix();
710
+ const branch = `push-${displayNumber}-${suffix}`;
711
+ const resolved = resolveConflictsWithClaude(displayNumber, branch, gitCwd);
712
+ if (!resolved) return false;
713
+
714
+ // Second attempt after conflict resolution
715
+ log(`Retrying merge for PR #${prNumber} after conflict resolution`);
716
+ return attemptPRMerge(prNumber, displayNumber, gitCwd) === 'success';
717
+ }
718
+
719
+ /**
720
+ * Try gh pr merge. Returns 'success', 'conflict', or 'error'.
721
+ */
722
+ function attemptPRMerge(prNumber, displayNumber, gitCwd) {
697
723
  try {
698
724
  execFileSync('gh', ['pr', 'merge', prNumber, '--merge', '--delete-branch'], {
699
725
  cwd: gitCwd,
@@ -714,16 +740,132 @@ function mergePRForTask(displayNumber, prUrl, projectPath) {
714
740
  log('Could not pull main (may not be checked out), skipping local update');
715
741
  }
716
742
 
717
- return true;
743
+ return 'success';
718
744
  } catch (e) {
719
745
  const stderr = e.stderr?.toString() || e.message || '';
720
- if (stderr.includes('merge conflict') || stderr.includes('conflict')) {
721
- logError(`PR #${prNumber} has merge conflicts, skipping auto-merge`);
722
- } else if (stderr.includes('not found') || stderr.includes('ENOENT')) {
746
+ if (stderr.includes('not found') || stderr.includes('ENOENT')) {
723
747
  log('GitHub CLI (gh) not installed, skipping merge');
724
- } else {
725
- logError(`Failed to merge PR #${prNumber}: ${stderr.slice(0, 200)}`);
748
+ return 'error';
749
+ }
750
+ if (stderr.includes('merge conflict') || stderr.includes('conflict')) {
751
+ log(`PR #${prNumber} has merge conflicts, will attempt resolution`);
752
+ return 'conflict';
753
+ }
754
+ logError(`PR merge failed for #${displayNumber}: ${stderr.slice(0, 200)}`);
755
+ return 'error';
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Resolve merge conflicts by creating a worktree, merging main, using Claude
761
+ * to fix conflicts, then committing and pushing the resolution.
762
+ *
763
+ * @returns {boolean} True if conflicts were resolved and pushed
764
+ */
765
+ function resolveConflictsWithClaude(displayNumber, branch, projectPath) {
766
+ log(`Attempting conflict resolution for task #${displayNumber}`);
767
+
768
+ const worktreePath = getWorktreePath(displayNumber, projectPath);
769
+
770
+ // Recreate worktree from the existing branch
771
+ try {
772
+ if (!existsSync(worktreePath)) {
773
+ execFileSync('git', ['worktree', 'add', worktreePath, branch], {
774
+ cwd: projectPath,
775
+ timeout: 30000,
776
+ stdio: 'pipe'
777
+ });
778
+ }
779
+ } catch (e) {
780
+ logError(`Could not create worktree for conflict resolution: ${e.message}`);
781
+ return false;
782
+ }
783
+
784
+ try {
785
+ // Fetch latest main
786
+ execFileSync('git', ['fetch', 'origin'], {
787
+ cwd: worktreePath,
788
+ timeout: 30000,
789
+ stdio: 'pipe'
790
+ });
791
+
792
+ // Attempt merge — this will create conflict markers if conflicts exist
793
+ try {
794
+ execFileSync('git', ['merge', 'origin/main', '--no-edit'], {
795
+ cwd: worktreePath,
796
+ timeout: 30000,
797
+ stdio: 'pipe'
798
+ });
799
+ // No conflicts — merge succeeded cleanly
800
+ log(`No actual conflicts for #${displayNumber}, merge succeeded locally`);
801
+ execFileSync('git', ['push', 'origin', branch], {
802
+ cwd: worktreePath,
803
+ timeout: 30000,
804
+ stdio: 'pipe'
805
+ });
806
+ cleanupWorktree(displayNumber, projectPath);
807
+ return true;
808
+ } catch {
809
+ // Expected: merge conflicts exist, continue to resolution
810
+ log(`Conflicts detected for #${displayNumber}, invoking Claude to resolve`);
811
+ }
812
+
813
+ // Use Claude to resolve the conflicts
814
+ const prompt = [
815
+ 'There are merge conflicts in this repository from merging origin/main.',
816
+ 'Run `git diff` to see the conflict markers.',
817
+ 'Resolve ALL conflicts by choosing the correct code for each one.',
818
+ 'After resolving, stage the files with `git add` and commit with:',
819
+ `git commit -m "Resolve merge conflicts for task #${displayNumber}"`,
820
+ 'Do NOT push. Just resolve and commit.'
821
+ ].join(' ');
822
+
823
+ execFileSync('claude', [
824
+ '-p', prompt,
825
+ '--allowedTools', 'Bash(git *),Read,Edit,Write,Glob,Grep',
826
+ '--output-format', 'json',
827
+ '--permission-mode', 'bypassPermissions'
828
+ ], {
829
+ cwd: worktreePath,
830
+ timeout: 120000, // 2 min for conflict resolution
831
+ stdio: 'pipe'
832
+ });
833
+
834
+ // Verify the merge completed (no unmerged files remaining)
835
+ try {
836
+ const status = execFileSync('git', ['status', '--porcelain'], {
837
+ cwd: worktreePath,
838
+ timeout: 10000,
839
+ encoding: 'utf8',
840
+ stdio: ['pipe', 'pipe', 'pipe']
841
+ }).toString();
842
+
843
+ if (status.includes('UU ') || status.includes('AA ') || status.includes('DD ')) {
844
+ logError(`Conflicts not fully resolved for #${displayNumber}`);
845
+ try { execFileSync('git', ['merge', '--abort'], { cwd: worktreePath, stdio: 'pipe' }); } catch {}
846
+ cleanupWorktree(displayNumber, projectPath);
847
+ return false;
848
+ }
849
+ } catch {
850
+ try { execFileSync('git', ['merge', '--abort'], { cwd: worktreePath, stdio: 'pipe' }); } catch {}
851
+ cleanupWorktree(displayNumber, projectPath);
852
+ return false;
726
853
  }
854
+
855
+ // Push the resolved branch
856
+ execFileSync('git', ['push', 'origin', branch], {
857
+ cwd: worktreePath,
858
+ timeout: 30000,
859
+ stdio: 'pipe'
860
+ });
861
+
862
+ log(`Conflict resolution pushed for task #${displayNumber}`);
863
+ cleanupWorktree(displayNumber, projectPath);
864
+ return true;
865
+ } catch (e) {
866
+ logError(`Conflict resolution failed for #${displayNumber}: ${e.message}`);
867
+ try { execFileSync('git', ['merge', '--abort'], { cwd: worktreePath, stdio: 'pipe' }); } catch {}
868
+ cleanupWorktree(displayNumber, projectPath);
727
869
  return false;
728
870
  }
729
871
  }
@@ -754,6 +896,110 @@ async function markTaskAsCompleted(displayNumber, taskId, comment) {
754
896
  }
755
897
  }
756
898
 
899
+ /**
900
+ * Auto-heal: detect if a previous execution already completed work for this task.
901
+ * Checks for existing branch commits and PRs to avoid redundant re-execution.
902
+ * Returns true if the task was healed (status updated, no re-execution needed).
903
+ */
904
+ async function autoHealExistingWork(displayNumber, summary, projectPath) {
905
+ const suffix = getWorktreeSuffix();
906
+ const branch = `push-${displayNumber}-${suffix}`;
907
+ const gitCwd = projectPath || process.cwd();
908
+
909
+ try {
910
+ // Check if branch has commits ahead of main
911
+ let hasCommits = false;
912
+ try {
913
+ const logResult = execSync(
914
+ `git log HEAD..origin/${branch} --oneline 2>/dev/null || git log HEAD..${branch} --oneline 2>/dev/null`,
915
+ { cwd: gitCwd, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }
916
+ ).toString().trim();
917
+ hasCommits = logResult.length > 0;
918
+ } catch {
919
+ // Branch doesn't exist — no previous work
920
+ return false;
921
+ }
922
+
923
+ if (!hasCommits) {
924
+ return false;
925
+ }
926
+
927
+ log(`Task #${displayNumber}: found existing commits on branch ${branch}`);
928
+
929
+ // Check for existing PR
930
+ let prUrl = null;
931
+ let prState = null;
932
+ try {
933
+ const prResult = execSync(
934
+ `gh pr list --head ${branch} --json url,state --jq '.[0]' 2>/dev/null`,
935
+ { cwd: gitCwd, timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] }
936
+ ).toString().trim();
937
+ if (prResult) {
938
+ const pr = JSON.parse(prResult);
939
+ prUrl = pr.url;
940
+ prState = pr.state; // OPEN or MERGED
941
+ }
942
+ } catch {
943
+ // gh not available or no PR found
944
+ }
945
+
946
+ if (prUrl && prState === 'MERGED') {
947
+ // PR already merged — task is fully done
948
+ log(`Task #${displayNumber}: PR already merged (${prUrl}), updating status`);
949
+ const executionSummary = `Auto-healed: previous execution completed and PR merged. PR: ${prUrl}`;
950
+ await updateTaskStatus(displayNumber, 'session_finished', {
951
+ summary: executionSummary
952
+ });
953
+ completedToday.push({
954
+ displayNumber, summary,
955
+ completedAt: new Date().toISOString(),
956
+ duration: 0, status: 'session_finished', prUrl
957
+ });
958
+ return true;
959
+ }
960
+
961
+ if (prUrl && prState === 'OPEN') {
962
+ // PR is open — work is done, just needs review
963
+ log(`Task #${displayNumber}: PR already open (${prUrl}), updating status`);
964
+ const executionSummary = `Auto-healed: previous execution completed. PR pending review: ${prUrl}`;
965
+ await updateTaskStatus(displayNumber, 'session_finished', {
966
+ summary: executionSummary
967
+ });
968
+ completedToday.push({
969
+ displayNumber, summary,
970
+ completedAt: new Date().toISOString(),
971
+ duration: 0, status: 'session_finished', prUrl
972
+ });
973
+ return true;
974
+ }
975
+
976
+ if (!prUrl) {
977
+ // Commits exist but no PR — create one and update status
978
+ log(`Task #${displayNumber}: commits exist but no PR, creating PR`);
979
+ const newPrUrl = createPRForTask(displayNumber, summary, projectPath);
980
+ if (newPrUrl) {
981
+ const executionSummary = `Auto-healed: previous execution had uncommitted PR. Created PR: ${newPrUrl}`;
982
+ await updateTaskStatus(displayNumber, 'session_finished', {
983
+ summary: executionSummary
984
+ });
985
+ completedToday.push({
986
+ displayNumber, summary,
987
+ completedAt: new Date().toISOString(),
988
+ duration: 0, status: 'session_finished', prUrl: newPrUrl
989
+ });
990
+ return true;
991
+ }
992
+ // PR creation failed — fall through to re-execute
993
+ log(`Task #${displayNumber}: PR creation failed, will re-execute`);
994
+ }
995
+
996
+ return false;
997
+ } catch (error) {
998
+ log(`Task #${displayNumber}: auto-heal check failed: ${error.message}`);
999
+ return false;
1000
+ }
1001
+ }
1002
+
757
1003
  // ==================== Stuck Detection ====================
758
1004
 
759
1005
  function checkStuckPatterns(displayNumber, line) {
@@ -914,7 +1160,7 @@ function updateTaskDetail(displayNumber, updates) {
914
1160
  updateStatusFile();
915
1161
  }
916
1162
 
917
- function executeTask(task) {
1163
+ async function executeTask(task) {
918
1164
  // Decrypt E2EE fields
919
1165
  task = decryptTaskFields(task);
920
1166
 
@@ -951,7 +1197,7 @@ function executeTask(task) {
951
1197
 
952
1198
  if (!existsSync(projectPath)) {
953
1199
  logError(`Task #${displayNumber}: Project path does not exist: ${projectPath}`);
954
- updateTaskStatus(displayNumber, 'failed', {
1200
+ await updateTaskStatus(displayNumber, 'failed', {
955
1201
  error: `Project path not found: ${projectPath}`
956
1202
  });
957
1203
  return null;
@@ -960,8 +1206,16 @@ function executeTask(task) {
960
1206
  log(`Task #${displayNumber}: Project ${gitRemote} -> ${projectPath}`);
961
1207
  }
962
1208
 
963
- // Atomic task claiming
964
- if (!claimTask(displayNumber)) {
1209
+ // Atomic task claiming - must await to actually check the result
1210
+ if (!(await claimTask(displayNumber))) {
1211
+ log(`Task #${displayNumber}: claim failed, skipping`);
1212
+ return null;
1213
+ }
1214
+
1215
+ // Auto-heal: check if previous execution already completed work for this task
1216
+ const healed = await autoHealExistingWork(displayNumber, summary, projectPath);
1217
+ if (healed) {
1218
+ log(`Task #${displayNumber}: auto-healed from previous execution, skipping re-execution`);
965
1219
  return null;
966
1220
  }
967
1221
 
@@ -981,7 +1235,7 @@ function executeTask(task) {
981
1235
  // Create worktree
982
1236
  const worktreePath = createWorktree(displayNumber, projectPath);
983
1237
  if (!worktreePath) {
984
- updateTaskStatus(displayNumber, 'failed', { error: 'Failed to create git worktree' });
1238
+ await updateTaskStatus(displayNumber, 'failed', { error: 'Failed to create git worktree' });
985
1239
  taskDetails.delete(displayNumber);
986
1240
  return null;
987
1241
  }
@@ -998,15 +1252,8 @@ IMPORTANT:
998
1252
  2. ALWAYS commit your changes before finishing. Use a descriptive commit message summarizing what you did. This is critical — uncommitted changes will be lost when the worktree is cleaned up.
999
1253
  3. When you're done, the SessionEnd hook will automatically report completion to Supabase.`;
1000
1254
 
1001
- // Update status to running (auto-generates 'started' event)
1002
- updateTaskStatus(displayNumber, 'running', {
1003
- event: {
1004
- type: 'started',
1005
- timestamp: new Date().toISOString(),
1006
- machineName: getMachineName() || undefined,
1007
- summary: summary.slice(0, 100),
1008
- }
1009
- });
1255
+ // Note: claimTask() already set status to 'running' with atomic: true
1256
+ // No duplicate status update needed here (was causing race conditions)
1010
1257
 
1011
1258
  // Build Claude command
1012
1259
  const allowedTools = [
@@ -1075,10 +1322,10 @@ IMPORTANT:
1075
1322
  handleTaskCompletion(displayNumber, code);
1076
1323
  });
1077
1324
 
1078
- child.on('error', (error) => {
1325
+ child.on('error', async (error) => {
1079
1326
  logError(`Task #${displayNumber} error: ${error.message}`);
1080
1327
  runningTasks.delete(displayNumber);
1081
- updateTaskStatus(displayNumber, 'failed', { error: error.message });
1328
+ await updateTaskStatus(displayNumber, 'failed', { error: error.message });
1082
1329
  taskDetails.delete(displayNumber);
1083
1330
  updateStatusFile();
1084
1331
  });
@@ -1094,13 +1341,13 @@ IMPORTANT:
1094
1341
  return taskInfo;
1095
1342
  } catch (error) {
1096
1343
  logError(`Error starting Claude for task #${displayNumber}: ${error.message}`);
1097
- updateTaskStatus(displayNumber, 'failed', { error: error.message });
1344
+ await updateTaskStatus(displayNumber, 'failed', { error: error.message });
1098
1345
  taskDetails.delete(displayNumber);
1099
1346
  return null;
1100
1347
  }
1101
1348
  }
1102
1349
 
1103
- function handleTaskCompletion(displayNumber, exitCode) {
1350
+ async function handleTaskCompletion(displayNumber, exitCode) {
1104
1351
  const taskInfo = runningTasks.get(displayNumber);
1105
1352
  if (!taskInfo) return;
1106
1353
 
@@ -1130,9 +1377,14 @@ function handleTaskCompletion(displayNumber, exitCode) {
1130
1377
  // Auto-create PR first so we can include it in the summary
1131
1378
  const prUrl = createPRForTask(displayNumber, summary, projectPath);
1132
1379
 
1133
- // Ask Claude to summarize what it accomplished
1380
+ // Ask Claude to summarize what it accomplished (needs worktree path)
1134
1381
  const semanticSummary = extractSemanticSummary(worktreePath, sessionId);
1135
1382
 
1383
+ // Clean up worktree BEFORE merge — gh pr merge --delete-branch fails if
1384
+ // the local branch is still referenced by a worktree. The branch itself
1385
+ // is preserved (only the worktree checkout is removed).
1386
+ cleanupWorktree(displayNumber, projectPath);
1387
+
1136
1388
  // Combine: semantic summary first (what), then machine metadata (how)
1137
1389
  let executionSummary = '';
1138
1390
  if (semanticSummary) {
@@ -1143,11 +1395,18 @@ function handleTaskCompletion(displayNumber, exitCode) {
1143
1395
  executionSummary += ` PR: ${prUrl}`;
1144
1396
  }
1145
1397
 
1146
- updateTaskStatus(displayNumber, 'session_finished', {
1398
+ const statusUpdated = await updateTaskStatus(displayNumber, 'session_finished', {
1147
1399
  duration,
1148
1400
  sessionId,
1149
1401
  summary: executionSummary
1150
1402
  });
1403
+ if (!statusUpdated) {
1404
+ logError(`Task #${displayNumber}: Failed to update status to session_finished — will retry`);
1405
+ // Retry once
1406
+ await updateTaskStatus(displayNumber, 'session_finished', {
1407
+ duration, sessionId, summary: executionSummary
1408
+ });
1409
+ }
1151
1410
 
1152
1411
  if (NOTIFY_ON_COMPLETE) {
1153
1412
  const prNote = prUrl ? ' PR ready for review.' : '';
@@ -1170,7 +1429,10 @@ function handleTaskCompletion(displayNumber, exitCode) {
1170
1429
  const comment = semanticSummary
1171
1430
  ? `${semanticSummary} (${durationStr} on ${machineName})`
1172
1431
  : `Completed in ${durationStr} on ${machineName}`;
1173
- markTaskAsCompleted(displayNumber, taskId, comment);
1432
+ const completed = await markTaskAsCompleted(displayNumber, taskId, comment);
1433
+ if (!completed) {
1434
+ logError(`Task #${displayNumber}: Failed to mark as completed — status is session_finished but not completed`);
1435
+ }
1174
1436
  }
1175
1437
 
1176
1438
  completedToday.push({
@@ -1185,13 +1447,17 @@ function handleTaskCompletion(displayNumber, exitCode) {
1185
1447
  } else {
1186
1448
  const stderr = taskInfo.process.stderr?.read()?.toString() || '';
1187
1449
 
1188
- // Ask Claude to explain what went wrong (if session exists)
1450
+ // Ask Claude to explain what went wrong (needs worktree path)
1189
1451
  const failureSummary = extractSemanticSummary(worktreePath, sessionId);
1452
+
1453
+ // Clean up worktree after summary extraction
1454
+ cleanupWorktree(displayNumber, projectPath);
1455
+
1190
1456
  const errorMsg = failureSummary
1191
1457
  ? `${failureSummary}\nExit code ${exitCode}. Ran for ${durationStr} on ${machineName}.`
1192
1458
  : `Exit code ${exitCode}: ${stderr.slice(0, 200)}`;
1193
1459
 
1194
- updateTaskStatus(displayNumber, 'failed', { error: errorMsg });
1460
+ await updateTaskStatus(displayNumber, 'failed', { error: errorMsg });
1195
1461
 
1196
1462
  if (NOTIFY_ON_FAILURE) {
1197
1463
  sendMacNotification(
@@ -1215,10 +1481,6 @@ function handleTaskCompletion(displayNumber, exitCode) {
1215
1481
  taskLastOutput.delete(displayNumber);
1216
1482
  taskStdoutBuffer.delete(displayNumber);
1217
1483
  taskProjectPaths.delete(displayNumber);
1218
-
1219
- // Always clean up worktree — the branch preserves all committed work.
1220
- // On re-run, createWorktree() recreates from the existing branch.
1221
- cleanupWorktree(displayNumber, projectPath);
1222
1484
  updateStatusFile();
1223
1485
  }
1224
1486
 
@@ -1442,7 +1704,13 @@ async function pollAndExecute() {
1442
1704
  continue;
1443
1705
  }
1444
1706
 
1445
- executeTask(task);
1707
+ // Skip tasks already completed this daemon session (prevents re-execution loop)
1708
+ if (completedToday.some(c => c.displayNumber === displayNumber)) {
1709
+ log(`Task #${displayNumber} already completed this session, skipping`);
1710
+ continue;
1711
+ }
1712
+
1713
+ await executeTask(task);
1446
1714
  }
1447
1715
 
1448
1716
  updateStatusFile();
@@ -1517,10 +1785,11 @@ async function mainLoop() {
1517
1785
 
1518
1786
  // ==================== Signal Handling ====================
1519
1787
 
1520
- function cleanup() {
1788
+ async function cleanup() {
1521
1789
  log('Daemon shutting down...');
1522
1790
 
1523
- // Kill running tasks and mark them as failed in Supabase
1791
+ // Kill running tasks and collect status update promises
1792
+ const statusPromises = [];
1524
1793
  for (const [displayNumber, taskInfo] of runningTasks) {
1525
1794
  log(`Killing task #${displayNumber}`);
1526
1795
  try {
@@ -1528,19 +1797,30 @@ function cleanup() {
1528
1797
  } catch {}
1529
1798
  // Mark as failed so the task doesn't stay as 'running' forever
1530
1799
  const duration = Math.floor((Date.now() - taskInfo.startTime) / 1000);
1531
- updateTaskStatus(displayNumber, 'failed', {
1532
- error: `Daemon shutdown after ${duration}s`,
1533
- event: {
1534
- type: 'daemon_shutdown',
1535
- timestamp: new Date().toISOString(),
1536
- machineName: getMachineName() || undefined,
1537
- summary: `Daemon restarted after ${duration}s`,
1538
- }
1539
- });
1800
+ statusPromises.push(
1801
+ updateTaskStatus(displayNumber, 'failed', {
1802
+ error: `Daemon shutdown after ${duration}s`,
1803
+ event: {
1804
+ type: 'daemon_shutdown',
1805
+ timestamp: new Date().toISOString(),
1806
+ machineName: getMachineName() || undefined,
1807
+ summary: `Daemon restarted after ${duration}s`,
1808
+ }
1809
+ })
1810
+ );
1540
1811
  const projectPath = taskProjectPaths.get(displayNumber);
1541
1812
  cleanupWorktree(displayNumber, projectPath);
1542
1813
  }
1543
1814
 
1815
+ // Wait for all status updates to land (max 5s timeout)
1816
+ if (statusPromises.length > 0) {
1817
+ log(`Waiting for ${statusPromises.length} status update(s) to complete...`);
1818
+ await Promise.race([
1819
+ Promise.allSettled(statusPromises),
1820
+ new Promise(resolve => setTimeout(resolve, 5000))
1821
+ ]);
1822
+ }
1823
+
1544
1824
  // Clean up files
1545
1825
  try { unlinkSync(PID_FILE); } catch {}
1546
1826
 
@@ -1555,11 +1835,11 @@ function cleanup() {
1555
1835
  process.exit(0);
1556
1836
  }
1557
1837
 
1558
- process.on('SIGTERM', cleanup);
1559
- process.on('SIGINT', cleanup);
1838
+ process.on('SIGTERM', () => cleanup().catch(() => process.exit(1)));
1839
+ process.on('SIGINT', () => cleanup().catch(() => process.exit(1)));
1560
1840
  process.on('uncaughtException', (error) => {
1561
1841
  logError(`Uncaught exception: ${error.message}`);
1562
- cleanup();
1842
+ cleanup().catch(() => process.exit(1));
1563
1843
  });
1564
1844
 
1565
1845
  // ==================== Entry Point ====================
package/lib/fetch.js CHANGED
@@ -11,7 +11,7 @@ import { getGitRemote, isGitRepo } from './utils/git.js';
11
11
  import { formatTaskForDisplay, formatSearchResult } from './utils/format.js';
12
12
  import { bold, green, yellow, red, cyan, dim, muted } from './utils/colors.js';
13
13
  import { decryptTodoField, isE2EEAvailable } from './encryption.js';
14
- import { getAutoCommitEnabled, getMaxBatchSize } from './config.js';
14
+ import { getAutoCommitEnabled, getAutoMergeEnabled, getAutoCompleteEnabled, getAutoUpdateEnabled, getMaxBatchSize } from './config.js';
15
15
  import { writeFileSync, mkdirSync } from 'fs';
16
16
  import { homedir } from 'os';
17
17
  import { join } from 'path';
@@ -333,6 +333,9 @@ export async function showStatus(options = {}) {
333
333
  const registry = getRegistry();
334
334
  const [e2eeAvailable, e2eeMessage] = isE2EEAvailable();
335
335
  const autoCommit = getAutoCommitEnabled();
336
+ const autoMerge = getAutoMergeEnabled();
337
+ const autoComplete = getAutoCompleteEnabled();
338
+ const autoUpdate = getAutoUpdateEnabled();
336
339
  const maxBatch = getMaxBatchSize();
337
340
 
338
341
  // Validate API key
@@ -358,6 +361,9 @@ export async function showStatus(options = {}) {
358
361
  },
359
362
  settings: {
360
363
  autoCommit,
364
+ autoMerge,
365
+ autoComplete,
366
+ autoUpdate,
361
367
  maxBatchSize: maxBatch
362
368
  },
363
369
  registeredProjects: registry.projectCount()
@@ -401,6 +407,9 @@ export async function showStatus(options = {}) {
401
407
 
402
408
  // Settings
403
409
  console.log(`${bold('Auto-commit:')} ${autoCommit ? 'Enabled' : 'Disabled'}`);
410
+ console.log(`${bold('Auto-merge:')} ${autoMerge ? 'Enabled' : 'Disabled'}`);
411
+ console.log(`${bold('Auto-complete:')} ${autoComplete ? 'Enabled' : 'Disabled'}`);
412
+ console.log(`${bold('Auto-update:')} ${autoUpdate ? 'Enabled' : 'Disabled'}`);
404
413
  console.log(`${bold('Max batch size:')} ${maxBatch}`);
405
414
  console.log(`${bold('Registered projects:')} ${status.registeredProjects}`);
406
415
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "3.7.7",
3
+ "version": "3.7.9",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {