@masslessai/push-todo 4.1.9 → 4.2.1
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/auto-connect.js +39 -0
- package/lib/cli.js +57 -1
- package/lib/cron.js +64 -8
- package/lib/daemon.js +439 -3
- package/package.json +1 -1
- package/scripts/postinstall.js +10 -10
package/lib/auto-connect.js
CHANGED
|
@@ -20,6 +20,38 @@ import { discoverAllProjects } from './discovery.js';
|
|
|
20
20
|
import { createSpinner } from './utils/spinner.js';
|
|
21
21
|
import { bold, green, red, dim, cyan } from './utils/colors.js';
|
|
22
22
|
import { ensureDaemonRunning } from './daemon-health.js';
|
|
23
|
+
import { install as installLaunchAgent, getStatus as getLaunchAgentStatus } from './launchagent.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Install or update LaunchAgent (macOS only).
|
|
27
|
+
* Called at the end of auto-connect, even for early-exit paths.
|
|
28
|
+
*/
|
|
29
|
+
function installLaunchAgentIfNeeded() {
|
|
30
|
+
if (process.platform !== 'darwin') return;
|
|
31
|
+
|
|
32
|
+
const laStatus = getLaunchAgentStatus();
|
|
33
|
+
if (!laStatus.installed) {
|
|
34
|
+
const laSpinner = createSpinner();
|
|
35
|
+
laSpinner.start('Installing LaunchAgent for auto-start on login...');
|
|
36
|
+
|
|
37
|
+
const laResult = installLaunchAgent();
|
|
38
|
+
if (laResult.success) {
|
|
39
|
+
laSpinner.succeed('LaunchAgent installed — daemon starts automatically on login');
|
|
40
|
+
} else {
|
|
41
|
+
laSpinner.fail(`LaunchAgent: ${laResult.message}`);
|
|
42
|
+
console.log(` ${dim('Daemon will still self-heal via push-todo commands.')}`);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
// Already installed — ensure it's loaded and up to date
|
|
46
|
+
const laResult = installLaunchAgent();
|
|
47
|
+
if (laResult.alreadyInstalled) {
|
|
48
|
+
console.log(` ${green('✓')} LaunchAgent already configured`);
|
|
49
|
+
} else if (laResult.success) {
|
|
50
|
+
console.log(` ${green('✓')} LaunchAgent updated`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
console.log('');
|
|
54
|
+
}
|
|
23
55
|
|
|
24
56
|
/**
|
|
25
57
|
* Run the auto-connect flow.
|
|
@@ -98,6 +130,8 @@ export async function runAutoConnect(options = {}) {
|
|
|
98
130
|
console.log(' No projects with git remotes found.');
|
|
99
131
|
console.log(` ${dim('Projects must have a git remote to be registered.')}`);
|
|
100
132
|
console.log('');
|
|
133
|
+
// Still install LaunchAgent even with no projects
|
|
134
|
+
installLaunchAgentIfNeeded();
|
|
101
135
|
return;
|
|
102
136
|
}
|
|
103
137
|
|
|
@@ -127,6 +161,8 @@ export async function runAutoConnect(options = {}) {
|
|
|
127
161
|
console.log(` ${'='.repeat(40)}`);
|
|
128
162
|
console.log(` All ${alreadyConnected} projects already connected.`);
|
|
129
163
|
console.log('');
|
|
164
|
+
// Still install LaunchAgent even when all projects connected
|
|
165
|
+
installLaunchAgentIfNeeded();
|
|
130
166
|
return;
|
|
131
167
|
}
|
|
132
168
|
|
|
@@ -192,4 +228,7 @@ export async function runAutoConnect(options = {}) {
|
|
|
192
228
|
console.log(` Run ${cyan("'push-todo'")} to see your tasks.`);
|
|
193
229
|
}
|
|
194
230
|
console.log('');
|
|
231
|
+
|
|
232
|
+
// Phase 6: Install LaunchAgent (macOS only)
|
|
233
|
+
installLaunchAgentIfNeeded();
|
|
195
234
|
}
|
package/lib/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ import { runConnect } from './connect.js';
|
|
|
15
15
|
import { startWatch } from './watch.js';
|
|
16
16
|
import { showSettings, toggleSetting, setMaxBatchSize } from './config.js';
|
|
17
17
|
import { ensureDaemonRunning, getDaemonStatus, startDaemon, stopDaemon } from './daemon-health.js';
|
|
18
|
+
import { install as installLaunchAgent, uninstall as uninstallLaunchAgent, getStatus as getLaunchAgentStatus } from './launchagent.js';
|
|
18
19
|
import { getScreenshotPath, screenshotExists, openScreenshot } from './utils/screenshots.js';
|
|
19
20
|
import { bold, red, cyan, dim, green } from './utils/colors.js';
|
|
20
21
|
import { getMachineId } from './machine-id.js';
|
|
@@ -79,6 +80,8 @@ ${bold('OPTIONS:')}
|
|
|
79
80
|
--daemon-status Show daemon status
|
|
80
81
|
--daemon-start Start daemon manually
|
|
81
82
|
--daemon-stop Stop daemon
|
|
83
|
+
--daemon-install Install LaunchAgent (auto-start on login)
|
|
84
|
+
--daemon-uninstall Remove LaunchAgent
|
|
82
85
|
--commands Show available user commands
|
|
83
86
|
--json Output as JSON
|
|
84
87
|
--version, -v Show version
|
|
@@ -121,6 +124,7 @@ ${bold('CRON (scheduled jobs):')}
|
|
|
121
124
|
--cron <expression> 5-field cron expression
|
|
122
125
|
--notify <message> Send Mac notification
|
|
123
126
|
--create-todo <content> Create todo reminder
|
|
127
|
+
--health-check <path> Run codebase health check (scope: general|tests|deps)
|
|
124
128
|
push-todo cron list List all cron jobs
|
|
125
129
|
push-todo cron remove <id> Remove a cron job by ID
|
|
126
130
|
|
|
@@ -154,6 +158,8 @@ const options = {
|
|
|
154
158
|
'daemon-status': { type: 'boolean' },
|
|
155
159
|
'daemon-start': { type: 'boolean' },
|
|
156
160
|
'daemon-stop': { type: 'boolean' },
|
|
161
|
+
'daemon-install': { type: 'boolean' },
|
|
162
|
+
'daemon-uninstall': { type: 'boolean' },
|
|
157
163
|
'commands': { type: 'boolean' },
|
|
158
164
|
'json': { type: 'boolean' },
|
|
159
165
|
'version': { type: 'boolean', short: 'v' },
|
|
@@ -182,6 +188,8 @@ const options = {
|
|
|
182
188
|
'create-todo': { type: 'string' },
|
|
183
189
|
'notify': { type: 'string' },
|
|
184
190
|
'queue-execution': { type: 'string' },
|
|
191
|
+
'health-check': { type: 'string' },
|
|
192
|
+
'scope': { type: 'string' },
|
|
185
193
|
// Skill CLI options (Phase 3)
|
|
186
194
|
'report-progress': { type: 'string' },
|
|
187
195
|
'phase': { type: 'string' },
|
|
@@ -251,6 +259,14 @@ export async function run(argv) {
|
|
|
251
259
|
} else {
|
|
252
260
|
console.log(`${bold('Daemon:')} Not running`);
|
|
253
261
|
}
|
|
262
|
+
// Show LaunchAgent status
|
|
263
|
+
const laStatus = getLaunchAgentStatus();
|
|
264
|
+
if (laStatus.installed) {
|
|
265
|
+
console.log(`${bold('LaunchAgent:')} Installed${laStatus.loaded ? ' (loaded)' : ' (not loaded)'}`);
|
|
266
|
+
} else {
|
|
267
|
+
console.log(`${bold('LaunchAgent:')} Not installed`);
|
|
268
|
+
console.log(dim(' Run: push-todo --daemon-install'));
|
|
269
|
+
}
|
|
254
270
|
return;
|
|
255
271
|
}
|
|
256
272
|
|
|
@@ -278,6 +294,11 @@ export async function run(argv) {
|
|
|
278
294
|
const success = stopDaemon();
|
|
279
295
|
if (success) {
|
|
280
296
|
console.log('Daemon stopped');
|
|
297
|
+
const laStatus = getLaunchAgentStatus();
|
|
298
|
+
if (laStatus.installed && laStatus.loaded) {
|
|
299
|
+
console.log(dim('Note: LaunchAgent will restart the daemon automatically.'));
|
|
300
|
+
console.log(dim('To fully stop: push-todo --daemon-uninstall'));
|
|
301
|
+
}
|
|
281
302
|
} else {
|
|
282
303
|
console.error(red('Failed to stop daemon'));
|
|
283
304
|
process.exit(1);
|
|
@@ -286,6 +307,35 @@ export async function run(argv) {
|
|
|
286
307
|
return;
|
|
287
308
|
}
|
|
288
309
|
|
|
310
|
+
if (values['daemon-install']) {
|
|
311
|
+
const result = installLaunchAgent();
|
|
312
|
+
if (result.success) {
|
|
313
|
+
if (result.alreadyInstalled) {
|
|
314
|
+
console.log('LaunchAgent already installed');
|
|
315
|
+
} else {
|
|
316
|
+
console.log('LaunchAgent installed — daemon will start automatically on login');
|
|
317
|
+
}
|
|
318
|
+
const laStatus = getLaunchAgentStatus();
|
|
319
|
+
console.log(dim(`Plist: ${laStatus.plistPath}`));
|
|
320
|
+
} else {
|
|
321
|
+
console.error(red(result.message));
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (values['daemon-uninstall']) {
|
|
328
|
+
const result = uninstallLaunchAgent();
|
|
329
|
+
if (result.success) {
|
|
330
|
+
console.log(result.message);
|
|
331
|
+
console.log(dim('Daemon will still self-heal via push-todo commands.'));
|
|
332
|
+
} else {
|
|
333
|
+
console.error(red(result.message));
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
289
339
|
// Handle --commands (simple user help)
|
|
290
340
|
if (values.commands) {
|
|
291
341
|
console.log(`
|
|
@@ -688,8 +738,14 @@ export async function run(argv) {
|
|
|
688
738
|
action = { type: 'notify', content: values.notify };
|
|
689
739
|
} else if (values['queue-execution']) {
|
|
690
740
|
action = { type: 'queue-execution', todoId: values['queue-execution'] };
|
|
741
|
+
} else if (values['health-check']) {
|
|
742
|
+
action = {
|
|
743
|
+
type: 'health-check',
|
|
744
|
+
projectPath: values['health-check'],
|
|
745
|
+
scope: values.scope || 'general',
|
|
746
|
+
};
|
|
691
747
|
} else {
|
|
692
|
-
console.error(red('Action required: --create-todo, --notify, or --
|
|
748
|
+
console.error(red('Action required: --create-todo, --notify, --queue-execution, or --health-check'));
|
|
693
749
|
process.exit(1);
|
|
694
750
|
}
|
|
695
751
|
|
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
|
@@ -27,6 +27,7 @@ import { checkAndRunDueJobs } from './cron.js';
|
|
|
27
27
|
import { runHeartbeatChecks } from './heartbeat.js';
|
|
28
28
|
import { getAgentVersions, formatAgentVersionSummary, checkAllAgentUpdates, performAgentUpdate, checkVersionParity } from './agent-versions.js';
|
|
29
29
|
import { checkAllProjectsFreshness } from './project-freshness.js';
|
|
30
|
+
import { getStatus as getLaunchAgentStatus, install as refreshLaunchAgent } from './launchagent.js';
|
|
30
31
|
|
|
31
32
|
const __filename = fileURLToPath(import.meta.url);
|
|
32
33
|
const __dirname = dirname(__filename);
|
|
@@ -1514,6 +1515,295 @@ function handleCancellation(displayNumber, source) {
|
|
|
1514
1515
|
});
|
|
1515
1516
|
}
|
|
1516
1517
|
|
|
1518
|
+
// ==================== Message Injection (Phase 4) ====================
|
|
1519
|
+
|
|
1520
|
+
const MESSAGE_POLL_INTERVAL_MS = 30000; // Check for messages every 30s
|
|
1521
|
+
let lastMessagePoll = 0;
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Poll for urgent messages directed to running tasks.
|
|
1525
|
+
* Called from checkTimeouts() on each poll cycle.
|
|
1526
|
+
*
|
|
1527
|
+
* Normal messages: agent picks up via push-todo --check-messages at natural pauses.
|
|
1528
|
+
* Urgent messages: daemon kills the session and respawns with --continue + injected message.
|
|
1529
|
+
*/
|
|
1530
|
+
async function checkUrgentMessages() {
|
|
1531
|
+
const now = Date.now();
|
|
1532
|
+
if (now - lastMessagePoll < MESSAGE_POLL_INTERVAL_MS) return;
|
|
1533
|
+
lastMessagePoll = now;
|
|
1534
|
+
|
|
1535
|
+
if (runningTasks.size === 0) return;
|
|
1536
|
+
|
|
1537
|
+
for (const [displayNumber, taskInfo] of runningTasks) {
|
|
1538
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
1539
|
+
const taskId = info.taskId;
|
|
1540
|
+
if (!taskId) continue;
|
|
1541
|
+
|
|
1542
|
+
// Skip tasks already being cancelled or injected
|
|
1543
|
+
if (info.phase === 'cancelled' || info.phase === 'injecting_message') continue;
|
|
1544
|
+
|
|
1545
|
+
try {
|
|
1546
|
+
const params = new URLSearchParams({
|
|
1547
|
+
todo_id: taskId,
|
|
1548
|
+
direction: 'to_agent',
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
const response = await apiRequest(`task-messages?${params}`, {}, false);
|
|
1552
|
+
if (!response.ok) continue;
|
|
1553
|
+
|
|
1554
|
+
const data = await response.json();
|
|
1555
|
+
const messages = data.messages || [];
|
|
1556
|
+
|
|
1557
|
+
// Only process urgent messages via kill+continue
|
|
1558
|
+
const urgentMessages = messages.filter(m => m.is_urgent || m.type === 'urgent');
|
|
1559
|
+
if (urgentMessages.length === 0) continue;
|
|
1560
|
+
|
|
1561
|
+
// Combine all urgent messages into one injection
|
|
1562
|
+
const combinedMessage = urgentMessages
|
|
1563
|
+
.map(m => m.message)
|
|
1564
|
+
.join('\n\n');
|
|
1565
|
+
|
|
1566
|
+
log(`Task #${displayNumber}: ${urgentMessages.length} urgent message(s) detected, initiating kill+continue`);
|
|
1567
|
+
|
|
1568
|
+
// Mark messages as read
|
|
1569
|
+
for (const msg of urgentMessages) {
|
|
1570
|
+
apiRequest('task-messages', {
|
|
1571
|
+
method: 'PATCH',
|
|
1572
|
+
body: JSON.stringify({ message_id: msg.id }),
|
|
1573
|
+
}).catch(() => {}); // fire-and-forget
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// Kill the current session and respawn with injected message
|
|
1577
|
+
await injectMessageViaKillContinue(displayNumber, combinedMessage);
|
|
1578
|
+
} catch (err) {
|
|
1579
|
+
log(`Task #${displayNumber}: message poll failed (non-fatal): ${err.message}`);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* Kill the current Claude session and respawn with --continue, injecting a human message.
|
|
1586
|
+
*
|
|
1587
|
+
* Flow:
|
|
1588
|
+
* 1. SIGTERM the running process
|
|
1589
|
+
* 2. Wait for it to exit (handleTaskCompletion sees phase='injecting_message' and skips cleanup)
|
|
1590
|
+
* 3. Respawn with `claude --continue <sessionId> -p "Human sent: <message>"`
|
|
1591
|
+
*
|
|
1592
|
+
* The ~12s penalty is for Claude to reload context from the session transcript.
|
|
1593
|
+
*/
|
|
1594
|
+
async function injectMessageViaKillContinue(displayNumber, message) {
|
|
1595
|
+
const taskInfo = runningTasks.get(displayNumber);
|
|
1596
|
+
if (!taskInfo) return;
|
|
1597
|
+
|
|
1598
|
+
const sessionId = taskInfo.sessionId;
|
|
1599
|
+
const projectPath = taskInfo.projectPath || taskProjectPaths.get(displayNumber);
|
|
1600
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
1601
|
+
const taskId = info.taskId;
|
|
1602
|
+
|
|
1603
|
+
// Mark phase so handleTaskCompletion knows to respawn instead of cleaning up
|
|
1604
|
+
updateTaskDetail(displayNumber, {
|
|
1605
|
+
phase: 'injecting_message',
|
|
1606
|
+
detail: `Injecting message: ${message.slice(0, 50)}...`
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
// Report the injection event to Supabase
|
|
1610
|
+
apiRequest('update-task-execution', {
|
|
1611
|
+
method: 'PATCH',
|
|
1612
|
+
body: JSON.stringify({
|
|
1613
|
+
todoId: taskId,
|
|
1614
|
+
displayNumber,
|
|
1615
|
+
event: {
|
|
1616
|
+
type: 'message_injected',
|
|
1617
|
+
timestamp: new Date().toISOString(),
|
|
1618
|
+
machineName: getMachineName() || undefined,
|
|
1619
|
+
summary: `Human message injected: ${message.slice(0, 100)}`,
|
|
1620
|
+
}
|
|
1621
|
+
})
|
|
1622
|
+
}).catch(() => {});
|
|
1623
|
+
|
|
1624
|
+
// Store the message for respawn (handleTaskCompletion will read this)
|
|
1625
|
+
taskInfo.pendingInjection = { message, sessionId, projectPath, taskId };
|
|
1626
|
+
|
|
1627
|
+
// Kill the current process
|
|
1628
|
+
try {
|
|
1629
|
+
taskInfo.process.kill('SIGTERM');
|
|
1630
|
+
} catch (err) {
|
|
1631
|
+
log(`Task #${displayNumber}: SIGTERM for injection failed: ${err.message}`);
|
|
1632
|
+
// Reset phase if kill failed
|
|
1633
|
+
updateTaskDetail(displayNumber, { phase: 'executing' });
|
|
1634
|
+
delete taskInfo.pendingInjection;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* Respawn a Claude session after kill, with the human message injected.
|
|
1640
|
+
* Called from handleTaskCompletion when phase === 'injecting_message'.
|
|
1641
|
+
*/
|
|
1642
|
+
function respawnWithInjectedMessage(displayNumber) {
|
|
1643
|
+
const taskInfo = runningTasks.get(displayNumber);
|
|
1644
|
+
if (!taskInfo || !taskInfo.pendingInjection) {
|
|
1645
|
+
log(`Task #${displayNumber}: no pending injection found, cannot respawn`);
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const { message, sessionId, projectPath, taskId } = taskInfo.pendingInjection;
|
|
1650
|
+
delete taskInfo.pendingInjection;
|
|
1651
|
+
|
|
1652
|
+
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.`;
|
|
1653
|
+
|
|
1654
|
+
const allowedTools = [
|
|
1655
|
+
'Read', 'Edit', 'Write', 'Glob', 'Grep',
|
|
1656
|
+
'Bash(git *)',
|
|
1657
|
+
'Bash(npm *)', 'Bash(npx *)', 'Bash(yarn *)',
|
|
1658
|
+
'Bash(python *)', 'Bash(python3 *)', 'Bash(pip *)', 'Bash(pip3 *)',
|
|
1659
|
+
'Bash(push-todo *)',
|
|
1660
|
+
'Task'
|
|
1661
|
+
].join(',');
|
|
1662
|
+
|
|
1663
|
+
// Generate new session ID for the respawned session
|
|
1664
|
+
const newSessionId = randomUUID();
|
|
1665
|
+
|
|
1666
|
+
const claudeArgs = [
|
|
1667
|
+
'--continue', sessionId,
|
|
1668
|
+
'-p', injectionPrompt,
|
|
1669
|
+
'--allowedTools', allowedTools,
|
|
1670
|
+
'--output-format', 'stream-json',
|
|
1671
|
+
'--permission-mode', 'bypassPermissions',
|
|
1672
|
+
'--session-id', newSessionId,
|
|
1673
|
+
];
|
|
1674
|
+
|
|
1675
|
+
log(`Task #${displayNumber}: respawning with injected message (session: ${sessionId} -> ${newSessionId})`);
|
|
1676
|
+
|
|
1677
|
+
try {
|
|
1678
|
+
const child = spawn('claude', claudeArgs, {
|
|
1679
|
+
cwd: projectPath || process.cwd(),
|
|
1680
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1681
|
+
env: (() => {
|
|
1682
|
+
const env = { ...process.env, PUSH_TASK_ID: taskId, PUSH_DISPLAY_NUMBER: String(displayNumber) };
|
|
1683
|
+
delete env.CLAUDECODE;
|
|
1684
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
1685
|
+
return env;
|
|
1686
|
+
})()
|
|
1687
|
+
});
|
|
1688
|
+
|
|
1689
|
+
// Replace the old process info
|
|
1690
|
+
taskInfo.process = child;
|
|
1691
|
+
taskInfo.sessionId = newSessionId;
|
|
1692
|
+
taskInfo.startTime = taskInfo.startTime; // Keep original start time
|
|
1693
|
+
|
|
1694
|
+
// Re-initialize stream parsing state
|
|
1695
|
+
taskStreamLineBuffer.set(displayNumber, '');
|
|
1696
|
+
taskActivityState.set(displayNumber, {
|
|
1697
|
+
filesRead: new Set(), filesEdited: new Set(),
|
|
1698
|
+
currentTool: null, lastText: '', sessionId: null
|
|
1699
|
+
});
|
|
1700
|
+
taskLastStreamProgress.set(displayNumber, Date.now());
|
|
1701
|
+
taskLastOutput.set(displayNumber, Date.now());
|
|
1702
|
+
taskStdoutBuffer.set(displayNumber, []);
|
|
1703
|
+
taskStderrBuffer.set(displayNumber, []);
|
|
1704
|
+
taskLastHeartbeat.set(displayNumber, Date.now());
|
|
1705
|
+
|
|
1706
|
+
// Re-attach stdout handler (same as executeTask)
|
|
1707
|
+
child.stdout.on('data', (data) => {
|
|
1708
|
+
taskLastOutput.set(displayNumber, Date.now());
|
|
1709
|
+
|
|
1710
|
+
const pending = (taskStreamLineBuffer.get(displayNumber) || '') + data.toString();
|
|
1711
|
+
const lines = pending.split('\n');
|
|
1712
|
+
taskStreamLineBuffer.set(displayNumber, lines.pop() || '');
|
|
1713
|
+
|
|
1714
|
+
for (const line of lines) {
|
|
1715
|
+
if (!line.trim()) continue;
|
|
1716
|
+
|
|
1717
|
+
const buffer = taskStdoutBuffer.get(displayNumber) || [];
|
|
1718
|
+
buffer.push(line);
|
|
1719
|
+
if (buffer.length > 50) buffer.shift();
|
|
1720
|
+
taskStdoutBuffer.set(displayNumber, buffer);
|
|
1721
|
+
|
|
1722
|
+
const event = parseStreamJsonLine(line);
|
|
1723
|
+
if (event) {
|
|
1724
|
+
processStreamEvent(displayNumber, event);
|
|
1725
|
+
} else {
|
|
1726
|
+
if (line.includes('[push-confirm] Waiting for')) {
|
|
1727
|
+
updateTaskDetail(displayNumber, {
|
|
1728
|
+
phase: 'awaiting_confirmation',
|
|
1729
|
+
detail: 'Waiting for user confirmation on iPhone'
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
const stuckReason = checkStuckPatterns(displayNumber, line);
|
|
1733
|
+
if (stuckReason) {
|
|
1734
|
+
log(`Task #${displayNumber} may be stuck: ${stuckReason}`);
|
|
1735
|
+
updateTaskDetail(displayNumber, { phase: 'stuck', detail: `Waiting for input: ${stuckReason}` });
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
child.stderr.on('data', (data) => {
|
|
1742
|
+
const lines = data.toString().split('\n');
|
|
1743
|
+
for (const line of lines) {
|
|
1744
|
+
if (line.trim()) {
|
|
1745
|
+
const buffer = taskStderrBuffer.get(displayNumber) || [];
|
|
1746
|
+
buffer.push(line);
|
|
1747
|
+
if (buffer.length > 20) buffer.shift();
|
|
1748
|
+
taskStderrBuffer.set(displayNumber, buffer);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
child.on('close', (code) => {
|
|
1754
|
+
handleTaskCompletion(displayNumber, code);
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
child.on('error', async (error) => {
|
|
1758
|
+
logError(`Task #${displayNumber} respawn error: ${error.message}`);
|
|
1759
|
+
runningTasks.delete(displayNumber);
|
|
1760
|
+
await updateTaskStatus(displayNumber, 'failed', { error: `Respawn failed: ${error.message}` }, taskId);
|
|
1761
|
+
taskDetails.delete(displayNumber);
|
|
1762
|
+
taskLastHeartbeat.delete(displayNumber);
|
|
1763
|
+
taskStreamLineBuffer.delete(displayNumber);
|
|
1764
|
+
taskActivityState.delete(displayNumber);
|
|
1765
|
+
taskLastStreamProgress.delete(displayNumber);
|
|
1766
|
+
updateStatusFile();
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
updateTaskDetail(displayNumber, {
|
|
1770
|
+
phase: 'executing',
|
|
1771
|
+
detail: 'Resumed with injected message',
|
|
1772
|
+
claudePid: child.pid
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
// Update session ID in Supabase
|
|
1776
|
+
apiRequest('update-task-execution', {
|
|
1777
|
+
method: 'PATCH',
|
|
1778
|
+
body: JSON.stringify({
|
|
1779
|
+
todoId: taskId,
|
|
1780
|
+
displayNumber,
|
|
1781
|
+
sessionId: newSessionId,
|
|
1782
|
+
event: {
|
|
1783
|
+
type: 'progress',
|
|
1784
|
+
timestamp: new Date().toISOString(),
|
|
1785
|
+
machineName: getMachineName() || undefined,
|
|
1786
|
+
summary: `Respawned with injected human message (new session: ${newSessionId.slice(0, 8)})`,
|
|
1787
|
+
}
|
|
1788
|
+
})
|
|
1789
|
+
}).catch(() => {});
|
|
1790
|
+
|
|
1791
|
+
log(`Task #${displayNumber}: respawned successfully (PID: ${child.pid})`);
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
logError(`Task #${displayNumber}: respawn failed: ${error.message}`);
|
|
1794
|
+
// Clean up — task is effectively dead
|
|
1795
|
+
runningTasks.delete(displayNumber);
|
|
1796
|
+
updateTaskStatus(displayNumber, 'failed', {
|
|
1797
|
+
error: `Message injection respawn failed: ${error.message}`
|
|
1798
|
+
}, taskId).catch(() => {});
|
|
1799
|
+
taskDetails.delete(displayNumber);
|
|
1800
|
+
taskStreamLineBuffer.delete(displayNumber);
|
|
1801
|
+
taskActivityState.delete(displayNumber);
|
|
1802
|
+
taskLastStreamProgress.delete(displayNumber);
|
|
1803
|
+
updateStatusFile();
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1517
1807
|
function monitorTaskStdout(displayNumber, proc) {
|
|
1518
1808
|
if (!proc.stdout) return;
|
|
1519
1809
|
|
|
@@ -2188,10 +2478,19 @@ async function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
2188
2478
|
const taskInfo = runningTasks.get(displayNumber);
|
|
2189
2479
|
if (!taskInfo) return;
|
|
2190
2480
|
|
|
2481
|
+
const info = taskDetails.get(displayNumber) || {};
|
|
2482
|
+
|
|
2483
|
+
// Message injection: process was killed to inject a human message.
|
|
2484
|
+
// Don't clean up — respawn with --continue instead.
|
|
2485
|
+
if (info.phase === 'injecting_message' && taskInfo.pendingInjection) {
|
|
2486
|
+
log(`Task #${displayNumber}: process exited for message injection (code ${exitCode}), respawning...`);
|
|
2487
|
+
respawnWithInjectedMessage(displayNumber);
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2191
2491
|
runningTasks.delete(displayNumber);
|
|
2192
2492
|
|
|
2193
2493
|
const duration = Math.floor((Date.now() - taskInfo.startTime) / 1000);
|
|
2194
|
-
const info = taskDetails.get(displayNumber) || {};
|
|
2195
2494
|
const summary = info.summary || 'Unknown task';
|
|
2196
2495
|
const projectPath = taskProjectPaths.get(displayNumber);
|
|
2197
2496
|
|
|
@@ -2502,6 +2801,13 @@ async function checkTimeouts() {
|
|
|
2502
2801
|
// Level A: Send progress heartbeats for long-running tasks
|
|
2503
2802
|
await sendProgressHeartbeats();
|
|
2504
2803
|
|
|
2804
|
+
// Phase 4: Check for urgent human messages to inject into running tasks
|
|
2805
|
+
try {
|
|
2806
|
+
await checkUrgentMessages();
|
|
2807
|
+
} catch (error) {
|
|
2808
|
+
logError(`Urgent message check failed (non-fatal): ${error.message}`);
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2505
2811
|
// Level B: Kill tasks that have been idle too long (fires at 15 min, before 60 min absolute)
|
|
2506
2812
|
await killIdleTasks();
|
|
2507
2813
|
|
|
@@ -2613,7 +2919,16 @@ function checkAndApplyUpdate() {
|
|
|
2613
2919
|
if (success) {
|
|
2614
2920
|
log(`Update to v${pendingUpdateVersion} successful. Restarting daemon...`);
|
|
2615
2921
|
|
|
2616
|
-
//
|
|
2922
|
+
// If LaunchAgent is installed, refresh plist (paths may have changed)
|
|
2923
|
+
// and let launchd handle the restart instead of spawning manually
|
|
2924
|
+
const laStatus = getLaunchAgentStatus();
|
|
2925
|
+
if (laStatus.installed && laStatus.loaded) {
|
|
2926
|
+
refreshLaunchAgent(); // Update plist with current node/daemon paths
|
|
2927
|
+
log('LaunchAgent installed — letting launchd restart daemon.');
|
|
2928
|
+
process.exit(0);
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// No LaunchAgent — spawn new daemon manually, then exit
|
|
2617
2932
|
const daemonScript = join(__dirname, 'daemon.js');
|
|
2618
2933
|
const selfUpdateEnv = { ...process.env, PUSH_DAEMON: '1' };
|
|
2619
2934
|
delete selfUpdateEnv.CLAUDECODE; // Strip to avoid leaking into Claude child processes
|
|
@@ -2684,6 +2999,127 @@ function logVersionParityWarnings() {
|
|
|
2684
2999
|
}
|
|
2685
3000
|
}
|
|
2686
3001
|
|
|
3002
|
+
// ==================== Health Check (Phase 5) ====================
|
|
3003
|
+
|
|
3004
|
+
/**
|
|
3005
|
+
* Spawn a health check Claude session for a project.
|
|
3006
|
+
* Called by the cron module when a health-check job fires.
|
|
3007
|
+
*
|
|
3008
|
+
* The session runs in the project directory with a special prompt that asks
|
|
3009
|
+
* Claude to review the codebase and suggest tasks. Results are created as
|
|
3010
|
+
* draft todos via the create-todo API.
|
|
3011
|
+
*
|
|
3012
|
+
* @param {Object} job - Cron job object
|
|
3013
|
+
* @param {Function} logFn - Log function
|
|
3014
|
+
*/
|
|
3015
|
+
async function spawnHealthCheck(job, logFn) {
|
|
3016
|
+
const projectPath = job.action.projectPath;
|
|
3017
|
+
if (!projectPath || !existsSync(projectPath)) {
|
|
3018
|
+
logFn(`Health check: project path not found: ${projectPath}`);
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
// Don't run if task slots are full
|
|
3023
|
+
if (runningTasks.size >= MAX_CONCURRENT_TASKS) {
|
|
3024
|
+
logFn(`Health check: all ${MAX_CONCURRENT_TASKS} slots in use, deferring`);
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
const scope = job.action.scope || 'general';
|
|
3029
|
+
const customPrompt = job.action.prompt || '';
|
|
3030
|
+
|
|
3031
|
+
const healthPrompts = {
|
|
3032
|
+
general: `Review this codebase briefly. Check for:
|
|
3033
|
+
1. Failing tests (run the test suite if one exists)
|
|
3034
|
+
2. Obvious bugs or issues in recently modified files (last 7 days)
|
|
3035
|
+
3. Outdated dependencies worth updating
|
|
3036
|
+
|
|
3037
|
+
For each issue found, create a todo using: push-todo create "<clear description of the issue>"
|
|
3038
|
+
Only create todos for real, actionable issues — not style preferences or minor improvements.
|
|
3039
|
+
If everything looks good, just say "No issues found" and don't create any todos.`,
|
|
3040
|
+
tests: `Run the test suite for this project. If any tests fail, create a todo for each failure:
|
|
3041
|
+
push-todo create "Fix failing test: <test name> - <brief reason>"
|
|
3042
|
+
If all tests pass, say "All tests pass" and don't create any todos.`,
|
|
3043
|
+
dependencies: `Check for outdated dependencies in this project. Only flag dependencies with:
|
|
3044
|
+
- Known security vulnerabilities
|
|
3045
|
+
- Major version bumps (not minor/patch)
|
|
3046
|
+
For each, create a todo: push-todo create "Update <dep> from <old> to <new> (<reason>)"
|
|
3047
|
+
If dependencies are current, say "All dependencies up to date."`,
|
|
3048
|
+
};
|
|
3049
|
+
|
|
3050
|
+
const prompt = customPrompt || healthPrompts[scope] || healthPrompts.general;
|
|
3051
|
+
|
|
3052
|
+
const allowedTools = [
|
|
3053
|
+
'Read', 'Glob', 'Grep',
|
|
3054
|
+
'Bash(git *)',
|
|
3055
|
+
'Bash(npm *)', 'Bash(npx *)',
|
|
3056
|
+
'Bash(python *)', 'Bash(python3 *)',
|
|
3057
|
+
'Bash(push-todo create *)',
|
|
3058
|
+
].join(',');
|
|
3059
|
+
|
|
3060
|
+
const claudeArgs = [
|
|
3061
|
+
'-p', prompt,
|
|
3062
|
+
'--allowedTools', allowedTools,
|
|
3063
|
+
'--output-format', 'stream-json',
|
|
3064
|
+
'--permission-mode', 'bypassPermissions',
|
|
3065
|
+
];
|
|
3066
|
+
|
|
3067
|
+
logFn(`Health check "${job.name}": spawning Claude in ${projectPath} (scope: ${scope})`);
|
|
3068
|
+
|
|
3069
|
+
try {
|
|
3070
|
+
const child = spawn('claude', claudeArgs, {
|
|
3071
|
+
cwd: projectPath,
|
|
3072
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3073
|
+
env: (() => {
|
|
3074
|
+
const env = { ...process.env };
|
|
3075
|
+
delete env.CLAUDECODE;
|
|
3076
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
3077
|
+
return env;
|
|
3078
|
+
})(),
|
|
3079
|
+
timeout: 300000, // 5 min max for health checks
|
|
3080
|
+
});
|
|
3081
|
+
|
|
3082
|
+
// Simple output tracking — health checks are lightweight, no full task tracking
|
|
3083
|
+
let output = '';
|
|
3084
|
+
child.stdout.on('data', (data) => {
|
|
3085
|
+
output += data.toString();
|
|
3086
|
+
});
|
|
3087
|
+
|
|
3088
|
+
child.stderr.on('data', (data) => {
|
|
3089
|
+
const errLine = data.toString().trim();
|
|
3090
|
+
if (errLine) logFn(`Health check "${job.name}" stderr: ${errLine}`);
|
|
3091
|
+
});
|
|
3092
|
+
|
|
3093
|
+
await new Promise((resolve) => {
|
|
3094
|
+
child.on('close', (code) => {
|
|
3095
|
+
if (code === 0) {
|
|
3096
|
+
logFn(`Health check "${job.name}": completed successfully`);
|
|
3097
|
+
} else {
|
|
3098
|
+
logFn(`Health check "${job.name}": exited with code ${code}`);
|
|
3099
|
+
}
|
|
3100
|
+
resolve();
|
|
3101
|
+
});
|
|
3102
|
+
child.on('error', (err) => {
|
|
3103
|
+
logFn(`Health check "${job.name}": spawn error: ${err.message}`);
|
|
3104
|
+
resolve();
|
|
3105
|
+
});
|
|
3106
|
+
});
|
|
3107
|
+
|
|
3108
|
+
// Extract any text summary from stream-json output
|
|
3109
|
+
const lines = output.split('\n').filter(l => l.trim());
|
|
3110
|
+
for (const line of lines) {
|
|
3111
|
+
try {
|
|
3112
|
+
const event = JSON.parse(line);
|
|
3113
|
+
if (event.type === 'result' && event.result) {
|
|
3114
|
+
logFn(`Health check "${job.name}" result: ${event.result.slice(0, 200)}`);
|
|
3115
|
+
}
|
|
3116
|
+
} catch { /* ignore non-JSON lines */ }
|
|
3117
|
+
}
|
|
3118
|
+
} catch (error) {
|
|
3119
|
+
logFn(`Health check "${job.name}": error: ${error.message}`);
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
|
|
2687
3123
|
// ==================== Main Loop ====================
|
|
2688
3124
|
|
|
2689
3125
|
async function pollAndExecute() {
|
|
@@ -2845,7 +3281,7 @@ async function mainLoop() {
|
|
|
2845
3281
|
|
|
2846
3282
|
// Cron jobs (check every poll cycle, execution throttled by nextRunAt)
|
|
2847
3283
|
try {
|
|
2848
|
-
await checkAndRunDueJobs(log);
|
|
3284
|
+
await checkAndRunDueJobs(log, { apiRequest, spawnHealthCheck });
|
|
2849
3285
|
} catch (error) {
|
|
2850
3286
|
logError(`Cron check error: ${error.message}`);
|
|
2851
3287
|
}
|
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -407,11 +407,11 @@ async function main() {
|
|
|
407
407
|
}
|
|
408
408
|
console.log('');
|
|
409
409
|
console.log('[push-todo] Quick start:');
|
|
410
|
-
console.log('[push-todo] push-todo connect
|
|
411
|
-
console.log('[push-todo] push-todo
|
|
412
|
-
if (claudeSuccess) console.log('[push-todo] /push-todo
|
|
413
|
-
if (codexSuccess) console.log('[push-todo] $push-todo
|
|
414
|
-
if (openclawSuccess) console.log('[push-todo] /push-todo
|
|
410
|
+
console.log('[push-todo] push-todo connect --auto One-command setup (auth + projects + daemon)');
|
|
411
|
+
console.log('[push-todo] push-todo List your tasks');
|
|
412
|
+
if (claudeSuccess) console.log('[push-todo] /push-todo Use in Claude Code');
|
|
413
|
+
if (codexSuccess) console.log('[push-todo] $push-todo Use in OpenAI Codex');
|
|
414
|
+
if (openclawSuccess) console.log('[push-todo] /push-todo Use in OpenClaw');
|
|
415
415
|
return;
|
|
416
416
|
}
|
|
417
417
|
|
|
@@ -440,16 +440,16 @@ async function main() {
|
|
|
440
440
|
}
|
|
441
441
|
console.log('');
|
|
442
442
|
console.log('[push-todo] Quick start:');
|
|
443
|
-
console.log('[push-todo] push-todo connect
|
|
444
|
-
console.log('[push-todo] push-todo
|
|
443
|
+
console.log('[push-todo] push-todo connect --auto One-command setup (auth + projects + daemon)');
|
|
444
|
+
console.log('[push-todo] push-todo List your tasks');
|
|
445
445
|
if (claudeSuccess) {
|
|
446
|
-
console.log('[push-todo] /push-todo
|
|
446
|
+
console.log('[push-todo] /push-todo Use in Claude Code');
|
|
447
447
|
}
|
|
448
448
|
if (codexSuccess) {
|
|
449
|
-
console.log('[push-todo] $push-todo
|
|
449
|
+
console.log('[push-todo] $push-todo Use in OpenAI Codex');
|
|
450
450
|
}
|
|
451
451
|
if (openclawSuccess) {
|
|
452
|
-
console.log('[push-todo] /push-todo
|
|
452
|
+
console.log('[push-todo] /push-todo Use in OpenClaw');
|
|
453
453
|
}
|
|
454
454
|
}
|
|
455
455
|
|