@masslessai/push-todo 4.1.7 → 4.1.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.
package/lib/api.js CHANGED
@@ -381,4 +381,144 @@ export async function learnVocabulary(todoId, keywords) {
381
381
  return response.json();
382
382
  }
383
383
 
384
+ // ==================== Phase 3: Extended Skill CLI ====================
385
+
386
+ /**
387
+ * Report structured progress for a running task.
388
+ * Called by the agent during execution to push activity updates to Supabase.
389
+ *
390
+ * @param {string} todoId - UUID of the task
391
+ * @param {Object} options - Progress details
392
+ * @param {string} options.phase - Current phase (e.g., "testing", "implementing")
393
+ * @param {string} [options.detail] - Human-readable detail
394
+ * @returns {Promise<Object>}
395
+ */
396
+ export async function reportProgress(todoId, { phase, detail }) {
397
+ const response = await apiRequest('update-task-execution', {
398
+ method: 'PATCH',
399
+ body: JSON.stringify({
400
+ todoId,
401
+ event: {
402
+ type: 'progress',
403
+ timestamp: new Date().toISOString(),
404
+ summary: detail ? `${phase}: ${detail}` : phase,
405
+ phase
406
+ }
407
+ })
408
+ });
409
+
410
+ if (!response.ok) {
411
+ const text = await response.text();
412
+ throw new Error(`Failed to report progress: ${text}`);
413
+ }
414
+
415
+ return response.json();
416
+ }
417
+
418
+ /**
419
+ * Update execution status/phase for a running task.
420
+ *
421
+ * @param {string} todoId - UUID of the task
422
+ * @param {string} status - New phase label (e.g., "implementing", "testing", "reviewing")
423
+ * @returns {Promise<Object>}
424
+ */
425
+ export async function updateStatus(todoId, status) {
426
+ const response = await apiRequest('update-task-execution', {
427
+ method: 'PATCH',
428
+ body: JSON.stringify({
429
+ todoId,
430
+ event: {
431
+ type: 'progress',
432
+ timestamp: new Date().toISOString(),
433
+ summary: `Phase: ${status}`
434
+ }
435
+ })
436
+ });
437
+
438
+ if (!response.ok) {
439
+ const text = await response.text();
440
+ throw new Error(`Failed to update status: ${text}`);
441
+ }
442
+
443
+ return response.json();
444
+ }
445
+
446
+ /**
447
+ * Check for messages from the human for a running task.
448
+ * Returns pending messages or empty array.
449
+ *
450
+ * @param {string} todoId - UUID of the task
451
+ * @returns {Promise<Object[]>} Array of pending messages
452
+ */
453
+ export async function checkMessages(todoId) {
454
+ const params = new URLSearchParams({ todo_id: todoId, direction: 'to_agent' });
455
+ const response = await apiRequest(`task-messages?${params}`, {
456
+ method: 'GET'
457
+ });
458
+
459
+ if (!response.ok) {
460
+ // 404 = no task_messages table/function yet — return empty gracefully
461
+ if (response.status === 404) return [];
462
+ const text = await response.text();
463
+ throw new Error(`Failed to check messages: ${text}`);
464
+ }
465
+
466
+ const result = await response.json();
467
+ return result.messages || [];
468
+ }
469
+
470
+ /**
471
+ * Request input from the human for a running task.
472
+ * Posts a question and polls until the human responds or timeout.
473
+ *
474
+ * @param {string} todoId - UUID of the task
475
+ * @param {string} question - Question to ask the human
476
+ * @param {number} [timeoutMs=300000] - Timeout in ms (default 5 min)
477
+ * @returns {Promise<string|null>} Human's response or null if timeout
478
+ */
479
+ export async function requestInput(todoId, question, timeoutMs = 300000) {
480
+ // Post the question
481
+ const postResponse = await apiRequest('task-messages', {
482
+ method: 'POST',
483
+ body: JSON.stringify({
484
+ todo_id: todoId,
485
+ direction: 'to_human',
486
+ message: question,
487
+ type: 'input_request'
488
+ })
489
+ });
490
+
491
+ if (!postResponse.ok) {
492
+ // 404 = task-messages endpoint not deployed yet
493
+ if (postResponse.status === 404) {
494
+ console.error('Message system not available yet. Use [push-confirm] pattern instead.');
495
+ return null;
496
+ }
497
+ const text = await postResponse.text();
498
+ throw new Error(`Failed to request input: ${text}`);
499
+ }
500
+
501
+ const { message_id } = await postResponse.json();
502
+
503
+ // Poll for response
504
+ const startTime = Date.now();
505
+ const pollInterval = 5000; // 5s
506
+
507
+ while (Date.now() - startTime < timeoutMs) {
508
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
509
+
510
+ const checkResponse = await apiRequest(`task-messages?message_id=${message_id}&direction=to_agent`, {
511
+ method: 'GET'
512
+ });
513
+
514
+ if (checkResponse.ok) {
515
+ const result = await checkResponse.json();
516
+ const reply = (result.messages || []).find(m => m.in_reply_to === message_id);
517
+ if (reply) return reply.message;
518
+ }
519
+ }
520
+
521
+ return null; // Timeout
522
+ }
523
+
384
524
  export { API_BASE };
package/lib/cli.js CHANGED
@@ -68,6 +68,13 @@ ${bold('OPTIONS:')}
68
68
  --view-screenshot <idx> Open screenshot for viewing (index or filename)
69
69
  --learn-vocabulary <uuid> Contribute vocabulary for a task
70
70
  --keywords <terms> Comma-separated vocabulary terms (with --learn-vocabulary)
71
+ --report-progress <uuid> Report progress for a running task
72
+ --phase <name> Phase name (with --report-progress)
73
+ --detail <text> Detail text (with --report-progress)
74
+ --update-status <uuid> Update execution phase for a running task
75
+ --check-messages <uuid> Check for messages from the human
76
+ --request-input <uuid> Request input from the human (blocking)
77
+ --question <text> Question to ask (with --request-input)
71
78
  --set-batch-size <N> Set max tasks for batch queue (1-20)
72
79
  --daemon-status Show daemon status
73
80
  --daemon-start Start daemon manually
@@ -175,6 +182,14 @@ const options = {
175
182
  'create-todo': { type: 'string' },
176
183
  'notify': { type: 'string' },
177
184
  'queue-execution': { type: 'string' },
185
+ // Skill CLI options (Phase 3)
186
+ 'report-progress': { type: 'string' },
187
+ 'phase': { type: 'string' },
188
+ 'detail': { type: 'string' },
189
+ 'update-status': { type: 'string' },
190
+ 'check-messages': { type: 'string' },
191
+ 'request-input': { type: 'string' },
192
+ 'question': { type: 'string' },
178
193
  };
179
194
 
180
195
  /**
@@ -512,6 +527,91 @@ export async function run(argv) {
512
527
  return;
513
528
  }
514
529
 
530
+ // Handle --report-progress (report structured progress for a running task)
531
+ if (values['report-progress']) {
532
+ try {
533
+ const result = await api.reportProgress(values['report-progress'], {
534
+ phase: values.phase || 'progress',
535
+ detail: values.detail || undefined,
536
+ });
537
+ if (values.json) {
538
+ console.log(JSON.stringify(result, null, 2));
539
+ } else {
540
+ console.log(green('Progress reported.'));
541
+ }
542
+ } catch (error) {
543
+ console.error(red(`Failed to report progress: ${error.message}`));
544
+ process.exit(1);
545
+ }
546
+ return;
547
+ }
548
+
549
+ // Handle --update-status (update execution phase for a running task)
550
+ if (values['update-status']) {
551
+ const status = positionals[0] || values.phase;
552
+ if (!status) {
553
+ console.error(red('Status required. Usage: --update-status <uuid> <status>'));
554
+ process.exit(1);
555
+ }
556
+ try {
557
+ const result = await api.updateStatus(values['update-status'], status);
558
+ if (values.json) {
559
+ console.log(JSON.stringify(result, null, 2));
560
+ } else {
561
+ console.log(green(`Status updated to: ${status}`));
562
+ }
563
+ } catch (error) {
564
+ console.error(red(`Failed to update status: ${error.message}`));
565
+ process.exit(1);
566
+ }
567
+ return;
568
+ }
569
+
570
+ // Handle --check-messages (check for messages from the human)
571
+ if (values['check-messages']) {
572
+ try {
573
+ const messages = await api.checkMessages(values['check-messages']);
574
+ if (values.json) {
575
+ console.log(JSON.stringify(messages, null, 2));
576
+ } else if (messages.length === 0) {
577
+ console.log(dim('No pending messages.'));
578
+ } else {
579
+ for (const msg of messages) {
580
+ console.log(`[${msg.created_at || 'unknown'}] ${msg.message}`);
581
+ }
582
+ }
583
+ } catch (error) {
584
+ console.error(red(`Failed to check messages: ${error.message}`));
585
+ process.exit(1);
586
+ }
587
+ return;
588
+ }
589
+
590
+ // Handle --request-input (ask the human a question, poll for response)
591
+ if (values['request-input']) {
592
+ const question = values.question || positionals.slice(0).join(' ');
593
+ if (!question) {
594
+ console.error(red('--question required with --request-input'));
595
+ console.error('Usage: --request-input <uuid> --question "What should I do?"');
596
+ process.exit(1);
597
+ }
598
+ try {
599
+ console.log(dim('Waiting for human response (timeout: 5 min)...'));
600
+ const reply = await api.requestInput(values['request-input'], question);
601
+ if (values.json) {
602
+ console.log(JSON.stringify({ reply }, null, 2));
603
+ } else if (reply) {
604
+ console.log(green('Response:'), reply);
605
+ } else {
606
+ console.log(dim('No response received (timeout).'));
607
+ }
608
+ } catch (error) {
609
+ console.error(red(`Failed to request input: ${error.message}`));
610
+ process.exit(1);
611
+ }
612
+ return;
613
+ }
614
+
515
615
  // Auto-start daemon on every command (self-healing behavior)
516
616
  ensureDaemonRunning();
517
617
 
package/lib/daemon.js CHANGED
@@ -1482,11 +1482,38 @@ function maybesSendStreamProgress(displayNumber, activity) {
1482
1482
  filesRead: activity.filesRead.size
1483
1483
  }
1484
1484
  })
1485
+ }).then(async (response) => {
1486
+ // Check for cancellation signal in response
1487
+ const result = await response.json().catch(() => null);
1488
+ if (result?.status === 'cancelling') {
1489
+ handleCancellation(displayNumber, 'stream_progress');
1490
+ }
1485
1491
  }).catch(err => {
1486
1492
  log(`Task #${displayNumber}: stream progress failed (non-fatal): ${err.message}`);
1487
1493
  });
1488
1494
  }
1489
1495
 
1496
+ // ==================== Cancellation (Phase 2) ====================
1497
+
1498
+ function handleCancellation(displayNumber, source) {
1499
+ const taskInfo = runningTasks.get(displayNumber);
1500
+ if (!taskInfo) return;
1501
+
1502
+ log(`Task #${displayNumber}: cancellation detected via ${source}, sending SIGTERM`);
1503
+
1504
+ try {
1505
+ taskInfo.process.kill('SIGTERM');
1506
+ } catch (err) {
1507
+ log(`Task #${displayNumber}: SIGTERM failed: ${err.message}`);
1508
+ }
1509
+
1510
+ // Mark as cancelled so handleTaskCompletion reports the right reason
1511
+ updateTaskDetail(displayNumber, {
1512
+ phase: 'cancelled',
1513
+ detail: 'Cancelled by user'
1514
+ });
1515
+ }
1516
+
1490
1517
  function monitorTaskStdout(displayNumber, proc) {
1491
1518
  if (!proc.stdout) return;
1492
1519
 
@@ -1623,6 +1650,12 @@ async function sendProgressHeartbeats() {
1623
1650
  }
1624
1651
  // No status field — event-only update, won't change execution_status
1625
1652
  })
1653
+ }).then(async (response) => {
1654
+ // Check for cancellation signal in heartbeat response
1655
+ const result = await response.json().catch(() => null);
1656
+ if (result?.status === 'cancelling') {
1657
+ handleCancellation(displayNumber, 'heartbeat');
1658
+ }
1626
1659
  }).catch(err => {
1627
1660
  log(`Task #${displayNumber}: heartbeat failed (non-fatal): ${err.message}`);
1628
1661
  });
@@ -2162,12 +2195,45 @@ async function handleTaskCompletion(displayNumber, exitCode) {
2162
2195
  const summary = info.summary || 'Unknown task';
2163
2196
  const projectPath = taskProjectPaths.get(displayNumber);
2164
2197
 
2165
- log(`Task #${displayNumber} completed with code ${exitCode} (${duration}s)`);
2198
+ const durationStr = duration < 60 ? `${duration}s` : `${Math.floor(duration / 60)}m ${duration % 60}s`;
2199
+ const wasCancelled = info.phase === 'cancelled';
2200
+ log(`Task #${displayNumber} ${wasCancelled ? 'cancelled' : 'completed'} with code ${exitCode} (${duration}s)`);
2201
+
2202
+ // Handle user cancellation — report as failed with clear reason, skip PR/merge/summary
2203
+ if (wasCancelled) {
2204
+ const cancelMsg = `Cancelled by user after ${durationStr}.`;
2205
+ await updateTaskStatus(displayNumber, 'failed', {
2206
+ error: cancelMsg,
2207
+ sessionId: taskInfo.sessionId
2208
+ }, info.taskId);
2209
+
2210
+ cleanupWorktree(displayNumber, projectPath);
2211
+
2212
+ trackCompleted({
2213
+ displayNumber,
2214
+ summary,
2215
+ completedAt: new Date().toISOString(),
2216
+ duration,
2217
+ status: 'cancelled'
2218
+ });
2219
+
2220
+ // Cleanup internal tracking
2221
+ taskDetails.delete(displayNumber);
2222
+ taskLastOutput.delete(displayNumber);
2223
+ taskStdoutBuffer.delete(displayNumber);
2224
+ taskStderrBuffer.delete(displayNumber);
2225
+ taskProjectPaths.delete(displayNumber);
2226
+ taskLastHeartbeat.delete(displayNumber);
2227
+ taskStreamLineBuffer.delete(displayNumber);
2228
+ taskActivityState.delete(displayNumber);
2229
+ taskLastStreamProgress.delete(displayNumber);
2230
+ updateStatusFile();
2231
+ return;
2232
+ }
2166
2233
 
2167
2234
  // Session ID: prefer pre-assigned ID (reliable), fall back to stdout extraction
2168
2235
  const sessionId = taskInfo.sessionId || extractSessionIdFromStdout(taskInfo.process, taskStdoutBuffer.get(displayNumber) || [], displayNumber);
2169
2236
  const worktreePath = getWorktreePath(displayNumber, projectPath);
2170
- const durationStr = duration < 60 ? `${duration}s` : `${Math.floor(duration / 60)}m ${duration % 60}s`;
2171
2237
  const machineName = getMachineName() || 'Mac';
2172
2238
 
2173
2239
  if (sessionId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.1.7",
3
+ "version": "4.1.9",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {