@masslessai/push-todo 3.7.3 → 3.7.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.
@@ -2,28 +2,29 @@
2
2
  /**
3
3
  * Session end hook for Push CLI.
4
4
  *
5
- * Reports session completion status to the backend.
5
+ * Reports session_finished status to Supabase when a Claude Code session ends.
6
+ * Reads the active task from ~/.push/active_task.json (written by fetch.js showTask).
7
+ *
8
+ * This mirrors the daemon's completion flow: daemon sends session_finished
9
+ * when its Claude process exits, this hook does the same for foreground sessions.
6
10
  */
7
11
 
8
- import { existsSync, readFileSync } from 'fs';
9
- import { homedir } from 'os';
12
+ import { existsSync, readFileSync, unlinkSync } from 'fs';
13
+ import { homedir, hostname } from 'os';
10
14
  import { join } from 'path';
11
15
 
12
16
  const CONFIG_FILE = join(homedir(), '.config', 'push', 'config');
17
+ const MACHINE_ID_FILE = join(homedir(), '.config', 'push', 'machine_id');
18
+ const ACTIVE_TASK_FILE = join(homedir(), '.push', 'active_task.json');
13
19
  const API_BASE = 'https://jxuzqcbqhiaxmfitzxlo.supabase.co/functions/v1';
14
20
 
15
- /**
16
- * Get the API key from config.
17
- */
18
21
  function getApiKey() {
19
22
  if (process.env.PUSH_API_KEY) {
20
23
  return process.env.PUSH_API_KEY;
21
24
  }
22
-
23
25
  if (!existsSync(CONFIG_FILE)) {
24
26
  return null;
25
27
  }
26
-
27
28
  try {
28
29
  const content = readFileSync(CONFIG_FILE, 'utf8');
29
30
  const match = content.match(/^export\s+PUSH_API_KEY\s*=\s*["']?([^"'\n]+)["']?/m);
@@ -33,65 +34,91 @@ function getApiKey() {
33
34
  }
34
35
  }
35
36
 
36
- /**
37
- * Get the machine ID.
38
- */
39
37
  function getMachineId() {
40
- const machineIdFile = join(homedir(), '.config', 'push', 'machine_id');
41
-
42
- if (existsSync(machineIdFile)) {
38
+ if (existsSync(MACHINE_ID_FILE)) {
43
39
  try {
44
- return readFileSync(machineIdFile, 'utf8').trim();
40
+ return readFileSync(MACHINE_ID_FILE, 'utf8').trim();
45
41
  } catch {
46
42
  return null;
47
43
  }
48
44
  }
49
-
50
45
  return null;
51
46
  }
52
47
 
53
- /**
54
- * Report session end to backend.
55
- */
56
- async function reportSessionEnd(apiKey, machineId, exitReason) {
48
+ function getActiveTask() {
49
+ if (!existsSync(ACTIVE_TASK_FILE)) {
50
+ return null;
51
+ }
52
+ try {
53
+ return JSON.parse(readFileSync(ACTIVE_TASK_FILE, 'utf8'));
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function clearActiveTask() {
60
+ try {
61
+ if (existsSync(ACTIVE_TASK_FILE)) {
62
+ unlinkSync(ACTIVE_TASK_FILE);
63
+ }
64
+ } catch {
65
+ // Ignore cleanup errors
66
+ }
67
+ }
68
+
69
+ async function reportSessionFinished(apiKey, activeTask) {
70
+ const machineId = getMachineId();
71
+ const machineName = hostname();
72
+ const startedAt = activeTask.startedAt ? new Date(activeTask.startedAt) : null;
73
+ const now = new Date();
74
+
75
+ const durationMs = startedAt ? now.getTime() - startedAt.getTime() : null;
76
+ const durationStr = durationMs
77
+ ? `${Math.floor(durationMs / 60000)}m ${Math.floor((durationMs % 60000) / 1000)}s`
78
+ : null;
79
+
80
+ const payload = {
81
+ displayNumber: activeTask.displayNumber,
82
+ status: 'session_finished',
83
+ machineId,
84
+ machineName,
85
+ event: {
86
+ type: 'session_finished',
87
+ timestamp: now.toISOString(),
88
+ machineName: machineName || undefined,
89
+ summary: durationStr
90
+ ? `Foreground session ended (${durationStr})`
91
+ : 'Foreground session ended',
92
+ },
93
+ };
94
+
57
95
  try {
58
- const response = await fetch(`${API_BASE}/session-end`, {
59
- method: 'POST',
96
+ const response = await fetch(`${API_BASE}/update-task-execution`, {
97
+ method: 'PATCH',
60
98
  headers: {
61
99
  'Authorization': `Bearer ${apiKey}`,
62
- 'Content-Type': 'application/json'
100
+ 'Content-Type': 'application/json',
63
101
  },
64
- body: JSON.stringify({
65
- machine_id: machineId,
66
- exit_reason: exitReason,
67
- timestamp: new Date().toISOString()
68
- }),
69
- signal: AbortSignal.timeout(10000)
102
+ body: JSON.stringify(payload),
103
+ signal: AbortSignal.timeout(10000),
70
104
  });
71
-
72
105
  return response.ok;
73
106
  } catch {
74
107
  return false;
75
108
  }
76
109
  }
77
110
 
78
- /**
79
- * Main entry point.
80
- */
81
111
  async function main() {
82
112
  const apiKey = getApiKey();
83
- const machineId = getMachineId();
113
+ const activeTask = getActiveTask();
84
114
 
85
- if (!apiKey || !machineId) {
86
- // Not configured - silent exit
115
+ if (!apiKey || !activeTask) {
116
+ // Not configured or no active task — silent exit
87
117
  process.exit(0);
88
118
  }
89
119
 
90
- // Get exit reason from environment (Claude Code may set this)
91
- const exitReason = process.env.CLAUDE_EXIT_REASON || 'normal';
92
-
93
- // Report to backend
94
- await reportSessionEnd(apiKey, machineId, exitReason);
120
+ await reportSessionFinished(apiKey, activeTask);
121
+ clearActiveTask();
95
122
 
96
123
  process.exit(0);
97
124
  }
package/lib/api.js CHANGED
@@ -229,7 +229,7 @@ export async function searchTasks(query, gitRemote = null) {
229
229
  */
230
230
  export async function updateTaskExecution(payload) {
231
231
  const response = await apiRequest('update-task-execution', {
232
- method: 'POST',
232
+ method: 'PATCH',
233
233
  body: JSON.stringify(payload)
234
234
  });
235
235
 
package/lib/config.js CHANGED
@@ -150,6 +150,48 @@ export function setAutoCommitEnabled(enabled) {
150
150
  return setConfigValue('AUTO_COMMIT', enabled ? 'true' : 'false');
151
151
  }
152
152
 
153
+ /**
154
+ * Check if auto-merge into main is enabled after daemon task completion.
155
+ * Default: true (merge PR into main after session_finished)
156
+ *
157
+ * @returns {boolean}
158
+ */
159
+ export function getAutoMergeEnabled() {
160
+ const value = getConfigValue('AUTO_MERGE', 'true');
161
+ return value.toLowerCase() === 'true' || value === '1' || value.toLowerCase() === 'yes';
162
+ }
163
+
164
+ /**
165
+ * Set auto-merge setting.
166
+ *
167
+ * @param {boolean} enabled
168
+ * @returns {boolean} True if successful
169
+ */
170
+ export function setAutoMergeEnabled(enabled) {
171
+ return setConfigValue('AUTO_MERGE', enabled ? 'true' : 'false');
172
+ }
173
+
174
+ /**
175
+ * Check if auto-complete is enabled after daemon task merge.
176
+ * Default: true (mark task as completed after successful merge)
177
+ *
178
+ * @returns {boolean}
179
+ */
180
+ export function getAutoCompleteEnabled() {
181
+ const value = getConfigValue('AUTO_COMPLETE', 'true');
182
+ return value.toLowerCase() === 'true' || value === '1' || value.toLowerCase() === 'yes';
183
+ }
184
+
185
+ /**
186
+ * Set auto-complete setting.
187
+ *
188
+ * @param {boolean} enabled
189
+ * @returns {boolean} True if successful
190
+ */
191
+ export function setAutoCompleteEnabled(enabled) {
192
+ return setConfigValue('AUTO_COMPLETE', enabled ? 'true' : 'false');
193
+ }
194
+
153
195
  /**
154
196
  * Get the maximum batch size for queuing tasks.
155
197
  * Default: 5
@@ -226,16 +268,24 @@ export function showSettings() {
226
268
  console.log();
227
269
 
228
270
  const autoCommit = getAutoCommitEnabled();
271
+ const autoMerge = getAutoMergeEnabled();
272
+ const autoComplete = getAutoCompleteEnabled();
229
273
  const batchSize = getMaxBatchSize();
230
274
 
231
- console.log(` auto-commit: ${autoCommit ? 'ON' : 'OFF'}`);
232
- console.log(' Auto-commit when task completes');
275
+ console.log(` auto-commit: ${autoCommit ? 'ON' : 'OFF'}`);
276
+ console.log(' Auto-commit when task completes');
277
+ console.log();
278
+ console.log(` auto-merge: ${autoMerge ? 'ON' : 'OFF'}`);
279
+ console.log(' Merge PR into main after daemon task finishes');
233
280
  console.log();
234
- console.log(` batch-size: ${batchSize}`);
235
- console.log(' Max tasks for batch queue (1-20)');
281
+ console.log(` auto-complete: ${autoComplete ? 'ON' : 'OFF'}`);
282
+ console.log(' Mark task completed after successful merge');
283
+ console.log();
284
+ console.log(` batch-size: ${batchSize}`);
285
+ console.log(' Max tasks for batch queue (1-20)');
236
286
  console.log();
237
287
  console.log(' Toggle with: push-todo setting <name>');
238
- console.log(' Example: push-todo setting auto-commit');
288
+ console.log(' Example: push-todo setting auto-merge');
239
289
  console.log();
240
290
  }
241
291
 
@@ -264,6 +314,38 @@ export function toggleSetting(settingName) {
264
314
  return false;
265
315
  }
266
316
 
317
+ if (normalized === 'auto-merge') {
318
+ const current = getAutoMergeEnabled();
319
+ const newValue = !current;
320
+ if (setAutoMergeEnabled(newValue)) {
321
+ console.log(`Auto-merge is now ${newValue ? 'ON' : 'OFF'}`);
322
+ if (newValue) {
323
+ console.log('PRs will be merged into main after daemon task finishes.');
324
+ } else {
325
+ console.log('PRs will NOT be merged automatically.');
326
+ }
327
+ return true;
328
+ }
329
+ console.error('Failed to update setting');
330
+ return false;
331
+ }
332
+
333
+ if (normalized === 'auto-complete') {
334
+ const current = getAutoCompleteEnabled();
335
+ const newValue = !current;
336
+ if (setAutoCompleteEnabled(newValue)) {
337
+ console.log(`Auto-complete is now ${newValue ? 'ON' : 'OFF'}`);
338
+ if (newValue) {
339
+ console.log('Tasks will be marked completed after successful merge.');
340
+ } else {
341
+ console.log('Tasks will NOT be marked completed automatically.');
342
+ }
343
+ return true;
344
+ }
345
+ console.error('Failed to update setting');
346
+ return false;
347
+ }
348
+
267
349
  if (normalized === 'batch-size') {
268
350
  const batchSize = getMaxBatchSize();
269
351
  console.log(`Current batch size: ${batchSize}`);
@@ -272,7 +354,7 @@ export function toggleSetting(settingName) {
272
354
  }
273
355
 
274
356
  console.error(`Unknown setting: ${settingName}`);
275
- console.error('Available settings: auto-commit, batch-size');
357
+ console.error('Available settings: auto-commit, auto-merge, auto-complete, batch-size');
276
358
  return false;
277
359
  }
278
360
 
package/lib/daemon.js CHANGED
@@ -199,6 +199,37 @@ function getVersion() {
199
199
  }
200
200
  }
201
201
 
202
+ function getConfigValueFromFile(key, defaultValue = '') {
203
+ const fullKey = `PUSH_${key}`;
204
+ if (!existsSync(CONFIG_FILE)) return defaultValue;
205
+ try {
206
+ const content = readFileSync(CONFIG_FILE, 'utf8');
207
+ for (const line of content.split('\n')) {
208
+ const trimmed = line.trim();
209
+ if (trimmed.startsWith(`export ${fullKey}=`)) {
210
+ let value = trimmed.split('=')[1] || '';
211
+ value = value.trim();
212
+ if ((value.startsWith('"') && value.endsWith('"')) ||
213
+ (value.startsWith("'") && value.endsWith("'"))) {
214
+ value = value.slice(1, -1);
215
+ }
216
+ return value;
217
+ }
218
+ }
219
+ } catch {}
220
+ return defaultValue;
221
+ }
222
+
223
+ function getAutoMergeEnabled() {
224
+ const v = getConfigValueFromFile('AUTO_MERGE', 'true');
225
+ return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
226
+ }
227
+
228
+ function getAutoCompleteEnabled() {
229
+ const v = getConfigValueFromFile('AUTO_COMPLETE', 'true');
230
+ return v.toLowerCase() === 'true' || v === '1' || v.toLowerCase() === 'yes';
231
+ }
232
+
202
233
  // ==================== E2EE Decryption ====================
203
234
 
204
235
  let decryptTodoField = null;
@@ -596,6 +627,83 @@ Automated PR from Push daemon for task #${displayNumber}.
596
627
  }
597
628
  }
598
629
 
630
+ /**
631
+ * Merge a PR into main and update local main branch.
632
+ * Uses `gh pr merge` for clean remote merge, then pulls locally.
633
+ *
634
+ * @returns {boolean} True if merge succeeded
635
+ */
636
+ function mergePRForTask(displayNumber, prUrl, projectPath) {
637
+ const gitCwd = projectPath || process.cwd();
638
+
639
+ // Extract PR number from URL (e.g., https://github.com/user/repo/pull/42)
640
+ const prMatch = prUrl.match(/\/pull\/(\d+)/);
641
+ if (!prMatch) {
642
+ logError(`Could not extract PR number from: ${prUrl}`);
643
+ return false;
644
+ }
645
+ const prNumber = prMatch[1];
646
+
647
+ try {
648
+ execFileSync('gh', ['pr', 'merge', prNumber, '--merge', '--delete-branch'], {
649
+ cwd: gitCwd,
650
+ timeout: 60000,
651
+ stdio: 'pipe'
652
+ });
653
+ log(`Merged PR #${prNumber} for task #${displayNumber}`);
654
+
655
+ // Pull main locally so next worktree is up to date
656
+ try {
657
+ execFileSync('git', ['pull', '--ff-only'], {
658
+ cwd: gitCwd,
659
+ timeout: 30000,
660
+ stdio: 'pipe'
661
+ });
662
+ log('Updated local main branch');
663
+ } catch {
664
+ log('Could not pull main (may not be checked out), skipping local update');
665
+ }
666
+
667
+ return true;
668
+ } catch (e) {
669
+ const stderr = e.stderr?.toString() || e.message || '';
670
+ if (stderr.includes('merge conflict') || stderr.includes('conflict')) {
671
+ logError(`PR #${prNumber} has merge conflicts, skipping auto-merge`);
672
+ } else if (stderr.includes('not found') || stderr.includes('ENOENT')) {
673
+ log('GitHub CLI (gh) not installed, skipping merge');
674
+ } else {
675
+ logError(`Failed to merge PR #${prNumber}: ${stderr.slice(0, 200)}`);
676
+ }
677
+ return false;
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Mark a task as completed via the todo-status endpoint.
683
+ */
684
+ async function markTaskAsCompleted(displayNumber, taskId, comment) {
685
+ try {
686
+ const response = await apiRequest('todo-status', {
687
+ method: 'PATCH',
688
+ body: JSON.stringify({
689
+ todoId: taskId,
690
+ isCompleted: true,
691
+ completedAt: new Date().toISOString(),
692
+ completionComment: comment || `Completed by daemon on ${getMachineName()}`
693
+ })
694
+ });
695
+ if (response.ok) {
696
+ log(`Task #${displayNumber} marked as completed`);
697
+ return true;
698
+ }
699
+ logError(`Failed to mark #${displayNumber} complete: HTTP ${response.status}`);
700
+ return false;
701
+ } catch (error) {
702
+ logError(`Failed to mark #${displayNumber} complete: ${error.message}`);
703
+ return false;
704
+ }
705
+ }
706
+
599
707
  // ==================== Stuck Detection ====================
600
708
 
601
709
  function checkStuckPatterns(displayNumber, line) {
@@ -1000,12 +1108,27 @@ function handleTaskCompletion(displayNumber, exitCode) {
1000
1108
  );
1001
1109
  }
1002
1110
 
1111
+ // Auto-merge PR into main (configurable, default ON)
1112
+ let merged = false;
1113
+ if (getAutoMergeEnabled() && prUrl) {
1114
+ merged = mergePRForTask(displayNumber, prUrl, projectPath);
1115
+ }
1116
+
1117
+ // Auto-complete task after successful merge (configurable, default ON)
1118
+ const taskId = info.taskId;
1119
+ if (getAutoCompleteEnabled() && merged && taskId) {
1120
+ const comment = semanticSummary
1121
+ ? `${semanticSummary} (${durationStr} on ${machineName})`
1122
+ : `Completed in ${durationStr} on ${machineName}`;
1123
+ markTaskAsCompleted(displayNumber, taskId, comment);
1124
+ }
1125
+
1003
1126
  completedToday.push({
1004
1127
  displayNumber,
1005
1128
  summary,
1006
1129
  completedAt: new Date().toISOString(),
1007
1130
  duration,
1008
- status: 'session_finished',
1131
+ status: merged ? 'completed' : 'session_finished',
1009
1132
  prUrl,
1010
1133
  sessionId
1011
1134
  });
package/lib/fetch.js CHANGED
@@ -12,6 +12,9 @@ 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
14
  import { getAutoCommitEnabled, getMaxBatchSize } from './config.js';
15
+ import { writeFileSync, mkdirSync } from 'fs';
16
+ import { homedir } from 'os';
17
+ import { join } from 'path';
15
18
 
16
19
  /**
17
20
  * Decrypt encrypted fields in a task object.
@@ -121,7 +124,7 @@ export async function listTasks(options = {}) {
121
124
  if (!options.backlog && !options.completed && running.length > 0) {
122
125
  console.log(`# ${running.length} Running/Queued Tasks (${scope})\n`);
123
126
  for (const task of running) {
124
- const displayNum = task.displayNumber || task.display_number;
127
+ const displayNum = task.displayNumber;
125
128
  console.log(`---\n### #${displayNum}\n`);
126
129
  console.log(formatTaskForDisplay(task));
127
130
  console.log('');
@@ -134,7 +137,7 @@ export async function listTasks(options = {}) {
134
137
 
135
138
  // Show full details for each task (matching Python behavior)
136
139
  for (const task of tasksToShow) {
137
- const displayNum = task.displayNumber || task.display_number;
140
+ const displayNum = task.displayNumber;
138
141
  console.log(`---\n### #${displayNum}\n`);
139
142
  console.log(formatTaskForDisplay(task));
140
143
  console.log('');
@@ -183,6 +186,49 @@ export async function showTask(displayNumber, options = {}) {
183
186
  }
184
187
 
185
188
  console.log(formatTaskForDisplay(decrypted));
189
+
190
+ // Track active task for session-end hook and send running status to Supabase.
191
+ // Skip if task is already running or queued (daemon owns it).
192
+ const execStatus = decrypted.executionStatus;
193
+ if (execStatus !== 'running' && execStatus !== 'queued') {
194
+ trackActiveTask(decrypted).catch(() => {});
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Track active task for foreground execution.
200
+ *
201
+ * Mirrors the daemon's behavior: sends 'running' status to Supabase
202
+ * and writes ~/.push/active_task.json for the session-end hook.
203
+ *
204
+ * @param {Object} task - Task object from API
205
+ */
206
+ async function trackActiveTask(task) {
207
+ const pushDir = join(homedir(), '.push');
208
+ mkdirSync(pushDir, { recursive: true });
209
+
210
+ // Write active task file for session-end hook to read
211
+ writeFileSync(join(pushDir, 'active_task.json'), JSON.stringify({
212
+ displayNumber: task.displayNumber,
213
+ taskId: task.id,
214
+ startedAt: new Date().toISOString(),
215
+ }));
216
+
217
+ // Send running status to Supabase (same as daemon's updateTaskStatus)
218
+ const machineId = getMachineId();
219
+ const machineName = getMachineName();
220
+ await api.updateTaskExecution({
221
+ displayNumber: task.displayNumber,
222
+ status: 'running',
223
+ machineId,
224
+ machineName,
225
+ event: {
226
+ type: 'started',
227
+ timestamp: new Date().toISOString(),
228
+ machineName: machineName || undefined,
229
+ summary: 'Foreground session started',
230
+ },
231
+ });
186
232
  }
187
233
 
188
234
  /**
@@ -432,7 +478,7 @@ export async function runReview(options = {}) {
432
478
 
433
479
  // Show full details for each task (matching Python behavior)
434
480
  for (const task of decrypted) {
435
- const displayNum = task.displayNumber || task.display_number;
481
+ const displayNum = task.displayNumber;
436
482
  console.log(`---\n### #${displayNum}\n`);
437
483
  console.log(formatTaskForDisplay(task));
438
484
  console.log('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "3.7.3",
3
+ "version": "3.7.5",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {