@masslessai/push-todo 4.1.8 → 4.2.0
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 +140 -0
- package/lib/cli.js +110 -1
- package/lib/cron.js +64 -8
- package/lib/daemon.js +428 -2
- package/package.json +1 -1
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
|
|
@@ -114,6 +121,7 @@ ${bold('CRON (scheduled jobs):')}
|
|
|
114
121
|
--cron <expression> 5-field cron expression
|
|
115
122
|
--notify <message> Send Mac notification
|
|
116
123
|
--create-todo <content> Create todo reminder
|
|
124
|
+
--health-check <path> Run codebase health check (scope: general|tests|deps)
|
|
117
125
|
push-todo cron list List all cron jobs
|
|
118
126
|
push-todo cron remove <id> Remove a cron job by ID
|
|
119
127
|
|
|
@@ -175,6 +183,16 @@ const options = {
|
|
|
175
183
|
'create-todo': { type: 'string' },
|
|
176
184
|
'notify': { type: 'string' },
|
|
177
185
|
'queue-execution': { type: 'string' },
|
|
186
|
+
'health-check': { type: 'string' },
|
|
187
|
+
'scope': { type: 'string' },
|
|
188
|
+
// Skill CLI options (Phase 3)
|
|
189
|
+
'report-progress': { type: 'string' },
|
|
190
|
+
'phase': { type: 'string' },
|
|
191
|
+
'detail': { type: 'string' },
|
|
192
|
+
'update-status': { type: 'string' },
|
|
193
|
+
'check-messages': { type: 'string' },
|
|
194
|
+
'request-input': { type: 'string' },
|
|
195
|
+
'question': { type: 'string' },
|
|
178
196
|
};
|
|
179
197
|
|
|
180
198
|
/**
|
|
@@ -512,6 +530,91 @@ export async function run(argv) {
|
|
|
512
530
|
return;
|
|
513
531
|
}
|
|
514
532
|
|
|
533
|
+
// Handle --report-progress (report structured progress for a running task)
|
|
534
|
+
if (values['report-progress']) {
|
|
535
|
+
try {
|
|
536
|
+
const result = await api.reportProgress(values['report-progress'], {
|
|
537
|
+
phase: values.phase || 'progress',
|
|
538
|
+
detail: values.detail || undefined,
|
|
539
|
+
});
|
|
540
|
+
if (values.json) {
|
|
541
|
+
console.log(JSON.stringify(result, null, 2));
|
|
542
|
+
} else {
|
|
543
|
+
console.log(green('Progress reported.'));
|
|
544
|
+
}
|
|
545
|
+
} catch (error) {
|
|
546
|
+
console.error(red(`Failed to report progress: ${error.message}`));
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Handle --update-status (update execution phase for a running task)
|
|
553
|
+
if (values['update-status']) {
|
|
554
|
+
const status = positionals[0] || values.phase;
|
|
555
|
+
if (!status) {
|
|
556
|
+
console.error(red('Status required. Usage: --update-status <uuid> <status>'));
|
|
557
|
+
process.exit(1);
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
const result = await api.updateStatus(values['update-status'], status);
|
|
561
|
+
if (values.json) {
|
|
562
|
+
console.log(JSON.stringify(result, null, 2));
|
|
563
|
+
} else {
|
|
564
|
+
console.log(green(`Status updated to: ${status}`));
|
|
565
|
+
}
|
|
566
|
+
} catch (error) {
|
|
567
|
+
console.error(red(`Failed to update status: ${error.message}`));
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Handle --check-messages (check for messages from the human)
|
|
574
|
+
if (values['check-messages']) {
|
|
575
|
+
try {
|
|
576
|
+
const messages = await api.checkMessages(values['check-messages']);
|
|
577
|
+
if (values.json) {
|
|
578
|
+
console.log(JSON.stringify(messages, null, 2));
|
|
579
|
+
} else if (messages.length === 0) {
|
|
580
|
+
console.log(dim('No pending messages.'));
|
|
581
|
+
} else {
|
|
582
|
+
for (const msg of messages) {
|
|
583
|
+
console.log(`[${msg.created_at || 'unknown'}] ${msg.message}`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
} catch (error) {
|
|
587
|
+
console.error(red(`Failed to check messages: ${error.message}`));
|
|
588
|
+
process.exit(1);
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Handle --request-input (ask the human a question, poll for response)
|
|
594
|
+
if (values['request-input']) {
|
|
595
|
+
const question = values.question || positionals.slice(0).join(' ');
|
|
596
|
+
if (!question) {
|
|
597
|
+
console.error(red('--question required with --request-input'));
|
|
598
|
+
console.error('Usage: --request-input <uuid> --question "What should I do?"');
|
|
599
|
+
process.exit(1);
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
console.log(dim('Waiting for human response (timeout: 5 min)...'));
|
|
603
|
+
const reply = await api.requestInput(values['request-input'], question);
|
|
604
|
+
if (values.json) {
|
|
605
|
+
console.log(JSON.stringify({ reply }, null, 2));
|
|
606
|
+
} else if (reply) {
|
|
607
|
+
console.log(green('Response:'), reply);
|
|
608
|
+
} else {
|
|
609
|
+
console.log(dim('No response received (timeout).'));
|
|
610
|
+
}
|
|
611
|
+
} catch (error) {
|
|
612
|
+
console.error(red(`Failed to request input: ${error.message}`));
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
515
618
|
// Auto-start daemon on every command (self-healing behavior)
|
|
516
619
|
ensureDaemonRunning();
|
|
517
620
|
|
|
@@ -588,8 +691,14 @@ export async function run(argv) {
|
|
|
588
691
|
action = { type: 'notify', content: values.notify };
|
|
589
692
|
} else if (values['queue-execution']) {
|
|
590
693
|
action = { type: 'queue-execution', todoId: values['queue-execution'] };
|
|
694
|
+
} else if (values['health-check']) {
|
|
695
|
+
action = {
|
|
696
|
+
type: 'health-check',
|
|
697
|
+
projectPath: values['health-check'],
|
|
698
|
+
scope: values.scope || 'general',
|
|
699
|
+
};
|
|
591
700
|
} else {
|
|
592
|
-
console.error(red('Action required: --create-todo, --notify, or --
|
|
701
|
+
console.error(red('Action required: --create-todo, --notify, --queue-execution, or --health-check'));
|
|
593
702
|
process.exit(1);
|
|
594
703
|
}
|
|
595
704
|
|
package/lib/cron.js
CHANGED
|
@@ -301,8 +301,11 @@ export function listJobs() {
|
|
|
301
301
|
*
|
|
302
302
|
* @param {Object} job - Job object
|
|
303
303
|
* @param {Function} [logFn] - Optional log function
|
|
304
|
+
* @param {Object} [context] - Injected dependencies from daemon
|
|
305
|
+
* @param {Function} [context.apiRequest] - API request function
|
|
306
|
+
* @param {Function} [context.spawnHealthCheck] - Spawn a health check Claude session
|
|
304
307
|
*/
|
|
305
|
-
async function executeAction(job, logFn) {
|
|
308
|
+
async function executeAction(job, logFn, context = {}) {
|
|
306
309
|
const log = logFn || (() => {});
|
|
307
310
|
|
|
308
311
|
switch (job.action.type) {
|
|
@@ -312,17 +315,69 @@ async function executeAction(job, logFn) {
|
|
|
312
315
|
break;
|
|
313
316
|
|
|
314
317
|
case 'create-todo':
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
+
if (context.apiRequest) {
|
|
319
|
+
try {
|
|
320
|
+
const response = await context.apiRequest('create-todo', {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
body: JSON.stringify({
|
|
323
|
+
title: job.action.content || job.name,
|
|
324
|
+
normalizedContent: job.action.detail || null,
|
|
325
|
+
isBacklog: job.action.backlog || false,
|
|
326
|
+
createdByClient: 'daemon-cron',
|
|
327
|
+
}),
|
|
328
|
+
});
|
|
329
|
+
if (response.ok) {
|
|
330
|
+
const data = await response.json();
|
|
331
|
+
log(`Cron "${job.name}": created todo #${data.todo?.displayNumber || '?'}`);
|
|
332
|
+
} else {
|
|
333
|
+
log(`Cron "${job.name}": create-todo failed (HTTP ${response.status})`);
|
|
334
|
+
// Fall back to notification
|
|
335
|
+
sendMacNotification('Push: Scheduled Todo', job.action.content || job.name);
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
log(`Cron "${job.name}": create-todo error: ${error.message}`);
|
|
339
|
+
sendMacNotification('Push: Scheduled Todo', job.action.content || job.name);
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
sendMacNotification('Push: Scheduled Todo', job.action.content || job.name);
|
|
343
|
+
log(`Cron "${job.name}": todo reminder sent (notification, no API context)`);
|
|
344
|
+
}
|
|
318
345
|
break;
|
|
319
346
|
|
|
320
347
|
case 'queue-execution':
|
|
321
|
-
// Requires todoId — log warning if missing
|
|
322
348
|
if (!job.action.todoId) {
|
|
323
349
|
log(`Cron "${job.name}": queue-execution requires todoId, skipping`);
|
|
350
|
+
} else if (context.apiRequest) {
|
|
351
|
+
try {
|
|
352
|
+
const response = await context.apiRequest('update-task-execution', {
|
|
353
|
+
method: 'PATCH',
|
|
354
|
+
body: JSON.stringify({
|
|
355
|
+
todoId: job.action.todoId,
|
|
356
|
+
status: 'queued',
|
|
357
|
+
}),
|
|
358
|
+
});
|
|
359
|
+
if (response.ok) {
|
|
360
|
+
log(`Cron "${job.name}": queued todo ${job.action.todoId} for execution`);
|
|
361
|
+
} else {
|
|
362
|
+
log(`Cron "${job.name}": queue-execution failed (HTTP ${response.status})`);
|
|
363
|
+
}
|
|
364
|
+
} catch (error) {
|
|
365
|
+
log(`Cron "${job.name}": queue-execution error: ${error.message}`);
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
log(`Cron "${job.name}": queue-execution not available (no API context)`);
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
|
|
372
|
+
case 'health-check':
|
|
373
|
+
if (context.spawnHealthCheck) {
|
|
374
|
+
try {
|
|
375
|
+
await context.spawnHealthCheck(job, log);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
log(`Cron "${job.name}": health-check error: ${error.message}`);
|
|
378
|
+
}
|
|
324
379
|
} else {
|
|
325
|
-
log(`Cron "${job.name}":
|
|
380
|
+
log(`Cron "${job.name}": health-check not available (no daemon context)`);
|
|
326
381
|
}
|
|
327
382
|
break;
|
|
328
383
|
|
|
@@ -336,8 +391,9 @@ async function executeAction(job, logFn) {
|
|
|
336
391
|
* Called from daemon poll loop on every cycle.
|
|
337
392
|
*
|
|
338
393
|
* @param {Function} [logFn] - Optional log function
|
|
394
|
+
* @param {Object} [context] - Injected dependencies (apiRequest, spawnHealthCheck)
|
|
339
395
|
*/
|
|
340
|
-
export async function checkAndRunDueJobs(logFn) {
|
|
396
|
+
export async function checkAndRunDueJobs(logFn, context = {}) {
|
|
341
397
|
const jobs = loadJobs();
|
|
342
398
|
if (jobs.length === 0) return;
|
|
343
399
|
|
|
@@ -353,7 +409,7 @@ export async function checkAndRunDueJobs(logFn) {
|
|
|
353
409
|
|
|
354
410
|
// Job is due — execute
|
|
355
411
|
try {
|
|
356
|
-
await executeAction(job, logFn);
|
|
412
|
+
await executeAction(job, logFn, context);
|
|
357
413
|
} catch (error) {
|
|
358
414
|
if (logFn) logFn(`Cron "${job.name}" execution failed: ${error.message}`);
|
|
359
415
|
}
|
package/lib/daemon.js
CHANGED
|
@@ -1514,6 +1514,295 @@ function handleCancellation(displayNumber, source) {
|
|
|
1514
1514
|
});
|
|
1515
1515
|
}
|
|
1516
1516
|
|
|
1517
|
+
// ==================== Message Injection (Phase 4) ====================
|
|
1518
|
+
|
|
1519
|
+
const MESSAGE_POLL_INTERVAL_MS = 30000; // Check for messages every 30s
|
|
1520
|
+
let lastMessagePoll = 0;
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Poll for urgent messages directed to running tasks.
|
|
1524
|
+
* Called from checkTimeouts() on each poll cycle.
|
|
1525
|
+
*
|
|
1526
|
+
* Normal messages: agent picks up via push-todo --check-messages at natural pauses.
|
|
1527
|
+
* Urgent messages: daemon kills the session and respawns with --continue + injected message.
|
|
1528
|
+
*/
|
|
1529
|
+
async function checkUrgentMessages() {
|
|
1530
|
+
const now = Date.now();
|
|
1531
|
+
if (now - lastMessagePoll < MESSAGE_POLL_INTERVAL_MS) return;
|
|
1532
|
+
lastMessagePoll = now;
|
|
1533
|
+
|
|
1534
|
+
if (runningTasks.size === 0) return;
|
|
1535
|
+
|
|
1536
|
+
for (const [displayNumber, taskInfo] of runningTasks) {
|
|
1537
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
1538
|
+
const taskId = info.taskId;
|
|
1539
|
+
if (!taskId) continue;
|
|
1540
|
+
|
|
1541
|
+
// Skip tasks already being cancelled or injected
|
|
1542
|
+
if (info.phase === 'cancelled' || info.phase === 'injecting_message') continue;
|
|
1543
|
+
|
|
1544
|
+
try {
|
|
1545
|
+
const params = new URLSearchParams({
|
|
1546
|
+
todo_id: taskId,
|
|
1547
|
+
direction: 'to_agent',
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
const response = await apiRequest(`task-messages?${params}`, {}, false);
|
|
1551
|
+
if (!response.ok) continue;
|
|
1552
|
+
|
|
1553
|
+
const data = await response.json();
|
|
1554
|
+
const messages = data.messages || [];
|
|
1555
|
+
|
|
1556
|
+
// Only process urgent messages via kill+continue
|
|
1557
|
+
const urgentMessages = messages.filter(m => m.is_urgent || m.type === 'urgent');
|
|
1558
|
+
if (urgentMessages.length === 0) continue;
|
|
1559
|
+
|
|
1560
|
+
// Combine all urgent messages into one injection
|
|
1561
|
+
const combinedMessage = urgentMessages
|
|
1562
|
+
.map(m => m.message)
|
|
1563
|
+
.join('\n\n');
|
|
1564
|
+
|
|
1565
|
+
log(`Task #${displayNumber}: ${urgentMessages.length} urgent message(s) detected, initiating kill+continue`);
|
|
1566
|
+
|
|
1567
|
+
// Mark messages as read
|
|
1568
|
+
for (const msg of urgentMessages) {
|
|
1569
|
+
apiRequest('task-messages', {
|
|
1570
|
+
method: 'PATCH',
|
|
1571
|
+
body: JSON.stringify({ message_id: msg.id }),
|
|
1572
|
+
}).catch(() => {}); // fire-and-forget
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Kill the current session and respawn with injected message
|
|
1576
|
+
await injectMessageViaKillContinue(displayNumber, combinedMessage);
|
|
1577
|
+
} catch (err) {
|
|
1578
|
+
log(`Task #${displayNumber}: message poll failed (non-fatal): ${err.message}`);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Kill the current Claude session and respawn with --continue, injecting a human message.
|
|
1585
|
+
*
|
|
1586
|
+
* Flow:
|
|
1587
|
+
* 1. SIGTERM the running process
|
|
1588
|
+
* 2. Wait for it to exit (handleTaskCompletion sees phase='injecting_message' and skips cleanup)
|
|
1589
|
+
* 3. Respawn with `claude --continue <sessionId> -p "Human sent: <message>"`
|
|
1590
|
+
*
|
|
1591
|
+
* The ~12s penalty is for Claude to reload context from the session transcript.
|
|
1592
|
+
*/
|
|
1593
|
+
async function injectMessageViaKillContinue(displayNumber, message) {
|
|
1594
|
+
const taskInfo = runningTasks.get(displayNumber);
|
|
1595
|
+
if (!taskInfo) return;
|
|
1596
|
+
|
|
1597
|
+
const sessionId = taskInfo.sessionId;
|
|
1598
|
+
const projectPath = taskInfo.projectPath || taskProjectPaths.get(displayNumber);
|
|
1599
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
1600
|
+
const taskId = info.taskId;
|
|
1601
|
+
|
|
1602
|
+
// Mark phase so handleTaskCompletion knows to respawn instead of cleaning up
|
|
1603
|
+
updateTaskDetail(displayNumber, {
|
|
1604
|
+
phase: 'injecting_message',
|
|
1605
|
+
detail: `Injecting message: ${message.slice(0, 50)}...`
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
// Report the injection event to Supabase
|
|
1609
|
+
apiRequest('update-task-execution', {
|
|
1610
|
+
method: 'PATCH',
|
|
1611
|
+
body: JSON.stringify({
|
|
1612
|
+
todoId: taskId,
|
|
1613
|
+
displayNumber,
|
|
1614
|
+
event: {
|
|
1615
|
+
type: 'message_injected',
|
|
1616
|
+
timestamp: new Date().toISOString(),
|
|
1617
|
+
machineName: getMachineName() || undefined,
|
|
1618
|
+
summary: `Human message injected: ${message.slice(0, 100)}`,
|
|
1619
|
+
}
|
|
1620
|
+
})
|
|
1621
|
+
}).catch(() => {});
|
|
1622
|
+
|
|
1623
|
+
// Store the message for respawn (handleTaskCompletion will read this)
|
|
1624
|
+
taskInfo.pendingInjection = { message, sessionId, projectPath, taskId };
|
|
1625
|
+
|
|
1626
|
+
// Kill the current process
|
|
1627
|
+
try {
|
|
1628
|
+
taskInfo.process.kill('SIGTERM');
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
log(`Task #${displayNumber}: SIGTERM for injection failed: ${err.message}`);
|
|
1631
|
+
// Reset phase if kill failed
|
|
1632
|
+
updateTaskDetail(displayNumber, { phase: 'executing' });
|
|
1633
|
+
delete taskInfo.pendingInjection;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* Respawn a Claude session after kill, with the human message injected.
|
|
1639
|
+
* Called from handleTaskCompletion when phase === 'injecting_message'.
|
|
1640
|
+
*/
|
|
1641
|
+
function respawnWithInjectedMessage(displayNumber) {
|
|
1642
|
+
const taskInfo = runningTasks.get(displayNumber);
|
|
1643
|
+
if (!taskInfo || !taskInfo.pendingInjection) {
|
|
1644
|
+
log(`Task #${displayNumber}: no pending injection found, cannot respawn`);
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const { message, sessionId, projectPath, taskId } = taskInfo.pendingInjection;
|
|
1649
|
+
delete taskInfo.pendingInjection;
|
|
1650
|
+
|
|
1651
|
+
const injectionPrompt = `IMPORTANT: The human sent you an urgent message while you were working:\n\n---\n${message}\n---\n\nPlease address this message and then continue with your task.`;
|
|
1652
|
+
|
|
1653
|
+
const allowedTools = [
|
|
1654
|
+
'Read', 'Edit', 'Write', 'Glob', 'Grep',
|
|
1655
|
+
'Bash(git *)',
|
|
1656
|
+
'Bash(npm *)', 'Bash(npx *)', 'Bash(yarn *)',
|
|
1657
|
+
'Bash(python *)', 'Bash(python3 *)', 'Bash(pip *)', 'Bash(pip3 *)',
|
|
1658
|
+
'Bash(push-todo *)',
|
|
1659
|
+
'Task'
|
|
1660
|
+
].join(',');
|
|
1661
|
+
|
|
1662
|
+
// Generate new session ID for the respawned session
|
|
1663
|
+
const newSessionId = randomUUID();
|
|
1664
|
+
|
|
1665
|
+
const claudeArgs = [
|
|
1666
|
+
'--continue', sessionId,
|
|
1667
|
+
'-p', injectionPrompt,
|
|
1668
|
+
'--allowedTools', allowedTools,
|
|
1669
|
+
'--output-format', 'stream-json',
|
|
1670
|
+
'--permission-mode', 'bypassPermissions',
|
|
1671
|
+
'--session-id', newSessionId,
|
|
1672
|
+
];
|
|
1673
|
+
|
|
1674
|
+
log(`Task #${displayNumber}: respawning with injected message (session: ${sessionId} -> ${newSessionId})`);
|
|
1675
|
+
|
|
1676
|
+
try {
|
|
1677
|
+
const child = spawn('claude', claudeArgs, {
|
|
1678
|
+
cwd: projectPath || process.cwd(),
|
|
1679
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1680
|
+
env: (() => {
|
|
1681
|
+
const env = { ...process.env, PUSH_TASK_ID: taskId, PUSH_DISPLAY_NUMBER: String(displayNumber) };
|
|
1682
|
+
delete env.CLAUDECODE;
|
|
1683
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
1684
|
+
return env;
|
|
1685
|
+
})()
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
// Replace the old process info
|
|
1689
|
+
taskInfo.process = child;
|
|
1690
|
+
taskInfo.sessionId = newSessionId;
|
|
1691
|
+
taskInfo.startTime = taskInfo.startTime; // Keep original start time
|
|
1692
|
+
|
|
1693
|
+
// Re-initialize stream parsing state
|
|
1694
|
+
taskStreamLineBuffer.set(displayNumber, '');
|
|
1695
|
+
taskActivityState.set(displayNumber, {
|
|
1696
|
+
filesRead: new Set(), filesEdited: new Set(),
|
|
1697
|
+
currentTool: null, lastText: '', sessionId: null
|
|
1698
|
+
});
|
|
1699
|
+
taskLastStreamProgress.set(displayNumber, Date.now());
|
|
1700
|
+
taskLastOutput.set(displayNumber, Date.now());
|
|
1701
|
+
taskStdoutBuffer.set(displayNumber, []);
|
|
1702
|
+
taskStderrBuffer.set(displayNumber, []);
|
|
1703
|
+
taskLastHeartbeat.set(displayNumber, Date.now());
|
|
1704
|
+
|
|
1705
|
+
// Re-attach stdout handler (same as executeTask)
|
|
1706
|
+
child.stdout.on('data', (data) => {
|
|
1707
|
+
taskLastOutput.set(displayNumber, Date.now());
|
|
1708
|
+
|
|
1709
|
+
const pending = (taskStreamLineBuffer.get(displayNumber) || '') + data.toString();
|
|
1710
|
+
const lines = pending.split('\n');
|
|
1711
|
+
taskStreamLineBuffer.set(displayNumber, lines.pop() || '');
|
|
1712
|
+
|
|
1713
|
+
for (const line of lines) {
|
|
1714
|
+
if (!line.trim()) continue;
|
|
1715
|
+
|
|
1716
|
+
const buffer = taskStdoutBuffer.get(displayNumber) || [];
|
|
1717
|
+
buffer.push(line);
|
|
1718
|
+
if (buffer.length > 50) buffer.shift();
|
|
1719
|
+
taskStdoutBuffer.set(displayNumber, buffer);
|
|
1720
|
+
|
|
1721
|
+
const event = parseStreamJsonLine(line);
|
|
1722
|
+
if (event) {
|
|
1723
|
+
processStreamEvent(displayNumber, event);
|
|
1724
|
+
} else {
|
|
1725
|
+
if (line.includes('[push-confirm] Waiting for')) {
|
|
1726
|
+
updateTaskDetail(displayNumber, {
|
|
1727
|
+
phase: 'awaiting_confirmation',
|
|
1728
|
+
detail: 'Waiting for user confirmation on iPhone'
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
const stuckReason = checkStuckPatterns(displayNumber, line);
|
|
1732
|
+
if (stuckReason) {
|
|
1733
|
+
log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
|
|
1734
|
+
updateTaskDetail(displayNumber, { phase: 'stuck', detail: `Waiting for input: ${stuckReason}` });
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
child.stderr.on('data', (data) => {
|
|
1741
|
+
const lines = data.toString().split('\n');
|
|
1742
|
+
for (const line of lines) {
|
|
1743
|
+
if (line.trim()) {
|
|
1744
|
+
const buffer = taskStderrBuffer.get(displayNumber) || [];
|
|
1745
|
+
buffer.push(line);
|
|
1746
|
+
if (buffer.length > 20) buffer.shift();
|
|
1747
|
+
taskStderrBuffer.set(displayNumber, buffer);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
child.on('close', (code) => {
|
|
1753
|
+
handleTaskCompletion(displayNumber, code);
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
child.on('error', async (error) => {
|
|
1757
|
+
logError(`Task #${displayNumber} respawn error: ${error.message}`);
|
|
1758
|
+
runningTasks.delete(displayNumber);
|
|
1759
|
+
await updateTaskStatus(displayNumber, 'failed', { error: `Respawn failed: ${error.message}` }, taskId);
|
|
1760
|
+
taskDetails.delete(displayNumber);
|
|
1761
|
+
taskLastHeartbeat.delete(displayNumber);
|
|
1762
|
+
taskStreamLineBuffer.delete(displayNumber);
|
|
1763
|
+
taskActivityState.delete(displayNumber);
|
|
1764
|
+
taskLastStreamProgress.delete(displayNumber);
|
|
1765
|
+
updateStatusFile();
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
updateTaskDetail(displayNumber, {
|
|
1769
|
+
phase: 'executing',
|
|
1770
|
+
detail: 'Resumed with injected message',
|
|
1771
|
+
claudePid: child.pid
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
// Update session ID in Supabase
|
|
1775
|
+
apiRequest('update-task-execution', {
|
|
1776
|
+
method: 'PATCH',
|
|
1777
|
+
body: JSON.stringify({
|
|
1778
|
+
todoId: taskId,
|
|
1779
|
+
displayNumber,
|
|
1780
|
+
sessionId: newSessionId,
|
|
1781
|
+
event: {
|
|
1782
|
+
type: 'progress',
|
|
1783
|
+
timestamp: new Date().toISOString(),
|
|
1784
|
+
machineName: getMachineName() || undefined,
|
|
1785
|
+
summary: `Respawned with injected human message (new session: ${newSessionId.slice(0, 8)})`,
|
|
1786
|
+
}
|
|
1787
|
+
})
|
|
1788
|
+
}).catch(() => {});
|
|
1789
|
+
|
|
1790
|
+
log(`Task #${displayNumber}: respawned successfully (PID: ${child.pid})`);
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
logError(`Task #${displayNumber}: respawn failed: ${error.message}`);
|
|
1793
|
+
// Clean up — task is effectively dead
|
|
1794
|
+
runningTasks.delete(displayNumber);
|
|
1795
|
+
updateTaskStatus(displayNumber, 'failed', {
|
|
1796
|
+
error: `Message injection respawn failed: ${error.message}`
|
|
1797
|
+
}, taskId).catch(() => {});
|
|
1798
|
+
taskDetails.delete(displayNumber);
|
|
1799
|
+
taskStreamLineBuffer.delete(displayNumber);
|
|
1800
|
+
taskActivityState.delete(displayNumber);
|
|
1801
|
+
taskLastStreamProgress.delete(displayNumber);
|
|
1802
|
+
updateStatusFile();
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1517
1806
|
function monitorTaskStdout(displayNumber, proc) {
|
|
1518
1807
|
if (!proc.stdout) return;
|
|
1519
1808
|
|
|
@@ -2188,10 +2477,19 @@ async function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
2188
2477
|
const taskInfo = runningTasks.get(displayNumber);
|
|
2189
2478
|
if (!taskInfo) return;
|
|
2190
2479
|
|
|
2480
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
2481
|
+
|
|
2482
|
+
// Message injection: process was killed to inject a human message.
|
|
2483
|
+
// Don't clean up — respawn with --continue instead.
|
|
2484
|
+
if (info.phase === 'injecting_message' && taskInfo.pendingInjection) {
|
|
2485
|
+
log(`Task #${displayNumber}: process exited for message injection (code ${exitCode}), respawning...`);
|
|
2486
|
+
respawnWithInjectedMessage(displayNumber);
|
|
2487
|
+
return;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2191
2490
|
runningTasks.delete(displayNumber);
|
|
2192
2491
|
|
|
2193
2492
|
const duration = Math.floor((Date.now() - taskInfo.startTime) / 1000);
|
|
2194
|
-
const info = taskDetails.get(displayNumber) || {};
|
|
2195
2493
|
const summary = info.summary || 'Unknown task';
|
|
2196
2494
|
const projectPath = taskProjectPaths.get(displayNumber);
|
|
2197
2495
|
|
|
@@ -2502,6 +2800,13 @@ async function checkTimeouts() {
|
|
|
2502
2800
|
// Level A: Send progress heartbeats for long-running tasks
|
|
2503
2801
|
await sendProgressHeartbeats();
|
|
2504
2802
|
|
|
2803
|
+
// Phase 4: Check for urgent human messages to inject into running tasks
|
|
2804
|
+
try {
|
|
2805
|
+
await checkUrgentMessages();
|
|
2806
|
+
} catch (error) {
|
|
2807
|
+
logError(`Urgent message check failed (non-fatal): ${error.message}`);
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2505
2810
|
// Level B: Kill tasks that have been idle too long (fires at 15 min, before 60 min absolute)
|
|
2506
2811
|
await killIdleTasks();
|
|
2507
2812
|
|
|
@@ -2684,6 +2989,127 @@ function logVersionParityWarnings() {
|
|
|
2684
2989
|
}
|
|
2685
2990
|
}
|
|
2686
2991
|
|
|
2992
|
+
// ==================== Health Check (Phase 5) ====================
|
|
2993
|
+
|
|
2994
|
+
/**
|
|
2995
|
+
* Spawn a health check Claude session for a project.
|
|
2996
|
+
* Called by the cron module when a health-check job fires.
|
|
2997
|
+
*
|
|
2998
|
+
* The session runs in the project directory with a special prompt that asks
|
|
2999
|
+
* Claude to review the codebase and suggest tasks. Results are created as
|
|
3000
|
+
* draft todos via the create-todo API.
|
|
3001
|
+
*
|
|
3002
|
+
* @param {Object} job - Cron job object
|
|
3003
|
+
* @param {Function} logFn - Log function
|
|
3004
|
+
*/
|
|
3005
|
+
async function spawnHealthCheck(job, logFn) {
|
|
3006
|
+
const projectPath = job.action.projectPath;
|
|
3007
|
+
if (!projectPath || !existsSync(projectPath)) {
|
|
3008
|
+
logFn(`Health check: project path not found: ${projectPath}`);
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
// Don't run if task slots are full
|
|
3013
|
+
if (runningTasks.size >= MAX_CONCURRENT_TASKS) {
|
|
3014
|
+
logFn(`Health check: all ${MAX_CONCURRENT_TASKS} slots in use, deferring`);
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
const scope = job.action.scope || 'general';
|
|
3019
|
+
const customPrompt = job.action.prompt || '';
|
|
3020
|
+
|
|
3021
|
+
const healthPrompts = {
|
|
3022
|
+
general: `Review this codebase briefly. Check for:
|
|
3023
|
+
1. Failing tests (run the test suite if one exists)
|
|
3024
|
+
2. Obvious bugs or issues in recently modified files (last 7 days)
|
|
3025
|
+
3. Outdated dependencies worth updating
|
|
3026
|
+
|
|
3027
|
+
For each issue found, create a todo using: push-todo create "<clear description of the issue>"
|
|
3028
|
+
Only create todos for real, actionable issues — not style preferences or minor improvements.
|
|
3029
|
+
If everything looks good, just say "No issues found" and don't create any todos.`,
|
|
3030
|
+
tests: `Run the test suite for this project. If any tests fail, create a todo for each failure:
|
|
3031
|
+
push-todo create "Fix failing test: <test name> - <brief reason>"
|
|
3032
|
+
If all tests pass, say "All tests pass" and don't create any todos.`,
|
|
3033
|
+
dependencies: `Check for outdated dependencies in this project. Only flag dependencies with:
|
|
3034
|
+
- Known security vulnerabilities
|
|
3035
|
+
- Major version bumps (not minor/patch)
|
|
3036
|
+
For each, create a todo: push-todo create "Update <dep> from <old> to <new> (<reason>)"
|
|
3037
|
+
If dependencies are current, say "All dependencies up to date."`,
|
|
3038
|
+
};
|
|
3039
|
+
|
|
3040
|
+
const prompt = customPrompt || healthPrompts[scope] || healthPrompts.general;
|
|
3041
|
+
|
|
3042
|
+
const allowedTools = [
|
|
3043
|
+
'Read', 'Glob', 'Grep',
|
|
3044
|
+
'Bash(git *)',
|
|
3045
|
+
'Bash(npm *)', 'Bash(npx *)',
|
|
3046
|
+
'Bash(python *)', 'Bash(python3 *)',
|
|
3047
|
+
'Bash(push-todo create *)',
|
|
3048
|
+
].join(',');
|
|
3049
|
+
|
|
3050
|
+
const claudeArgs = [
|
|
3051
|
+
'-p', prompt,
|
|
3052
|
+
'--allowedTools', allowedTools,
|
|
3053
|
+
'--output-format', 'stream-json',
|
|
3054
|
+
'--permission-mode', 'bypassPermissions',
|
|
3055
|
+
];
|
|
3056
|
+
|
|
3057
|
+
logFn(`Health check "${job.name}": spawning Claude in ${projectPath} (scope: ${scope})`);
|
|
3058
|
+
|
|
3059
|
+
try {
|
|
3060
|
+
const child = spawn('claude', claudeArgs, {
|
|
3061
|
+
cwd: projectPath,
|
|
3062
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3063
|
+
env: (() => {
|
|
3064
|
+
const env = { ...process.env };
|
|
3065
|
+
delete env.CLAUDECODE;
|
|
3066
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
3067
|
+
return env;
|
|
3068
|
+
})(),
|
|
3069
|
+
timeout: 300000, // 5 min max for health checks
|
|
3070
|
+
});
|
|
3071
|
+
|
|
3072
|
+
// Simple output tracking — health checks are lightweight, no full task tracking
|
|
3073
|
+
let output = '';
|
|
3074
|
+
child.stdout.on('data', (data) => {
|
|
3075
|
+
output += data.toString();
|
|
3076
|
+
});
|
|
3077
|
+
|
|
3078
|
+
child.stderr.on('data', (data) => {
|
|
3079
|
+
const errLine = data.toString().trim();
|
|
3080
|
+
if (errLine) logFn(`Health check "${job.name}" stderr: ${errLine}`);
|
|
3081
|
+
});
|
|
3082
|
+
|
|
3083
|
+
await new Promise((resolve) => {
|
|
3084
|
+
child.on('close', (code) => {
|
|
3085
|
+
if (code === 0) {
|
|
3086
|
+
logFn(`Health check "${job.name}": completed successfully`);
|
|
3087
|
+
} else {
|
|
3088
|
+
logFn(`Health check "${job.name}": exited with code ${code}`);
|
|
3089
|
+
}
|
|
3090
|
+
resolve();
|
|
3091
|
+
});
|
|
3092
|
+
child.on('error', (err) => {
|
|
3093
|
+
logFn(`Health check "${job.name}": spawn error: ${err.message}`);
|
|
3094
|
+
resolve();
|
|
3095
|
+
});
|
|
3096
|
+
});
|
|
3097
|
+
|
|
3098
|
+
// Extract any text summary from stream-json output
|
|
3099
|
+
const lines = output.split('\n').filter(l => l.trim());
|
|
3100
|
+
for (const line of lines) {
|
|
3101
|
+
try {
|
|
3102
|
+
const event = JSON.parse(line);
|
|
3103
|
+
if (event.type === 'result' && event.result) {
|
|
3104
|
+
logFn(`Health check "${job.name}" result: ${event.result.slice(0, 200)}`);
|
|
3105
|
+
}
|
|
3106
|
+
} catch { /* ignore non-JSON lines */ }
|
|
3107
|
+
}
|
|
3108
|
+
} catch (error) {
|
|
3109
|
+
logFn(`Health check "${job.name}": error: ${error.message}`);
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
|
|
2687
3113
|
// ==================== Main Loop ====================
|
|
2688
3114
|
|
|
2689
3115
|
async function pollAndExecute() {
|
|
@@ -2845,7 +3271,7 @@ async function mainLoop() {
|
|
|
2845
3271
|
|
|
2846
3272
|
// Cron jobs (check every poll cycle, execution throttled by nextRunAt)
|
|
2847
3273
|
try {
|
|
2848
|
-
await checkAndRunDueJobs(log);
|
|
3274
|
+
await checkAndRunDueJobs(log, { apiRequest, spawnHealthCheck });
|
|
2849
3275
|
} catch (error) {
|
|
2850
3276
|
logError(`Cron check error: ${error.message}`);
|
|
2851
3277
|
}
|