@masslessai/push-todo 3.7.2 → 3.7.4

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/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,10 +1,10 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "3.7.2",
3
+ "version": "3.7.4",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
7
- "push-todo": "./bin/push-todo.js"
7
+ "push-todo": "bin/push-todo.js"
8
8
  },
9
9
  "main": "./lib/index.js",
10
10
  "exports": {
@@ -44,7 +44,7 @@
44
44
  "license": "MIT",
45
45
  "repository": {
46
46
  "type": "git",
47
- "url": "https://github.com/MasslessAI/push-todo-cli"
47
+ "url": "git+https://github.com/MasslessAI/push-todo-cli.git"
48
48
  },
49
49
  "homepage": "https://pushto.do",
50
50
  "bugs": {