@masslessai/push-todo 4.2.9 → 4.4.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/cron.js CHANGED
@@ -1,51 +1,13 @@
1
1
  /**
2
- * Cron job scheduler for Push daemon.
2
+ * Schedule engine for Push daemon.
3
3
  *
4
- * Stores recurring/one-shot jobs in ~/.push/cron/jobs.json.
5
- * Called from daemon main loop on each poll cycle.
4
+ * Pure scheduling logic (interval parsing, cron expressions, next-run computation)
5
+ * plus the remote schedule checker that polls Supabase.
6
6
  *
7
7
  * No npm dependencies — includes minimal cron expression parser.
8
- * Architecture: docs/20260214_push_daemon_evolution_complete_architecture.md §23
9
- * Pattern: Follow self-update.js — pure functions, called from daemon.js.
8
+ * Architecture: docs/20260301_system_architecture_complete_reference.md
10
9
  */
11
10
 
12
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
13
- import { homedir } from 'os';
14
- import { join } from 'path';
15
- import { randomUUID } from 'crypto';
16
- import { sendMacNotification } from './utils/notify.js';
17
-
18
- const CRON_DIR = join(homedir(), '.push', 'cron');
19
- const JOBS_FILE = join(CRON_DIR, 'jobs.json');
20
-
21
- // ==================== Storage ====================
22
-
23
- function ensureCronDir() {
24
- mkdirSync(CRON_DIR, { recursive: true });
25
- }
26
-
27
- /**
28
- * Load all cron jobs from disk.
29
- * @returns {Array} Job objects
30
- */
31
- export function loadJobs() {
32
- if (!existsSync(JOBS_FILE)) return [];
33
- try {
34
- return JSON.parse(readFileSync(JOBS_FILE, 'utf8'));
35
- } catch {
36
- return [];
37
- }
38
- }
39
-
40
- /**
41
- * Save all cron jobs to disk.
42
- * @param {Array} jobs
43
- */
44
- export function saveJobs(jobs) {
45
- ensureCronDir();
46
- writeFileSync(JOBS_FILE, JSON.stringify(jobs, null, 2) + '\n');
47
- }
48
-
49
11
  // ==================== Interval Parsing ====================
50
12
 
51
13
  /**
@@ -225,214 +187,135 @@ export function computeNextRun(schedule, fromDate = new Date()) {
225
187
  }
226
188
  }
227
189
 
228
- // ==================== Job Management ====================
190
+ // ==================== Remote Schedules (Supabase) ====================
229
191
 
230
192
  /**
231
- * Add a new cron job.
193
+ * Check for and run any due remote schedules from Supabase.
194
+ * Called from daemon poll loop on every cycle.
232
195
  *
233
- * @param {Object} config
234
- * @param {string} config.name - Job name
235
- * @param {{ type: string, value: string }} config.schedule - Schedule definition
236
- * @param {{ type: string, content: string }} config.action - Action to perform
237
- * @returns {Object} Created job
196
+ * @param {Function} [logFn] - Optional log function
197
+ * @param {Object} [context] - Injected dependencies from daemon
198
+ * @param {Function} [context.apiRequest] - API request function
238
199
  */
239
- export function addJob(config) {
240
- const { name, schedule, action } = config;
200
+ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
201
+ const log = logFn || (() => {});
241
202
 
242
- if (!name) throw new Error('Job name is required');
243
- if (!schedule || !schedule.type || !schedule.value) throw new Error('Schedule is required');
244
- if (!action || !action.type) throw new Error('Action is required');
203
+ if (!context.apiRequest) return;
245
204
 
246
- // Validate schedule by computing next run
247
- const nextRunAt = computeNextRun(schedule);
248
- if (!nextRunAt) {
249
- throw new Error(`Schedule "${schedule.type}: ${schedule.value}" has no future run time`);
205
+ // 1. Fetch due schedules
206
+ let schedules;
207
+ try {
208
+ const response = await context.apiRequest('manage-schedules?due=true', {
209
+ method: 'GET',
210
+ });
211
+ if (!response.ok) {
212
+ log(`Remote schedules: fetch failed (HTTP ${response.status})`);
213
+ return;
214
+ }
215
+ const data = await response.json();
216
+ schedules = data.schedules || [];
217
+ } catch (error) {
218
+ log(`Remote schedules: fetch error: ${error.message}`);
219
+ return;
250
220
  }
251
221
 
252
- const job = {
253
- id: randomUUID(),
254
- name,
255
- schedule,
256
- action,
257
- enabled: true,
258
- createdAt: new Date().toISOString(),
259
- lastRunAt: null,
260
- nextRunAt,
261
- };
262
-
263
- const jobs = loadJobs();
264
- jobs.push(job);
265
- saveJobs(jobs);
266
-
267
- return job;
268
- }
222
+ if (schedules.length === 0) return;
269
223
 
270
- /**
271
- * Remove a cron job by ID or ID prefix.
272
- *
273
- * @param {string} idOrPrefix - Full UUID or prefix (min 4 chars)
274
- * @returns {boolean} True if found and removed
275
- */
276
- export function removeJob(idOrPrefix) {
277
- const jobs = loadJobs();
278
- const idx = jobs.findIndex(j =>
279
- j.id === idOrPrefix || j.id.startsWith(idOrPrefix)
280
- );
224
+ log(`Remote schedules: ${schedules.length} due`);
281
225
 
282
- if (idx === -1) return false;
226
+ // 2. Fire each due schedule
227
+ for (const schedule of schedules) {
228
+ const expectedNextRunAt = schedule.next_run_at;
283
229
 
284
- jobs.splice(idx, 1);
285
- saveJobs(jobs);
286
- return true;
287
- }
288
-
289
- /**
290
- * List all cron jobs.
291
- * @returns {Array} Job objects
292
- */
293
- export function listJobs() {
294
- return loadJobs();
295
- }
296
-
297
- // ==================== Execution ====================
298
-
299
- /**
300
- * Execute a cron job action.
301
- *
302
- * @param {Object} job - Job object
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
307
- */
308
- async function executeAction(job, logFn, context = {}) {
309
- const log = logFn || (() => {});
230
+ try {
231
+ if (schedule.action_type === 'create-todo') {
232
+ // Create a new todo
233
+ const payload = {
234
+ title: schedule.action_title || schedule.name,
235
+ normalizedContent: schedule.action_content || null,
236
+ isBacklog: false,
237
+ createdByClient: 'daemon-schedule',
238
+ };
239
+ if (schedule.git_remote) {
240
+ payload.gitRemote = schedule.git_remote;
241
+ payload.actionType = 'claude-code';
242
+ }
310
243
 
311
- switch (job.action.type) {
312
- case 'notify':
313
- sendMacNotification('Push Cron', job.action.content || job.name);
314
- log(`Cron "${job.name}": notification sent`);
315
- break;
316
-
317
- case 'create-todo':
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);
244
+ const todoResponse = await context.apiRequest('create-todo', {
245
+ method: 'POST',
246
+ body: JSON.stringify(payload),
247
+ });
248
+ if (todoResponse.ok) {
249
+ const todoData = await todoResponse.json();
250
+ log(`Schedule "${schedule.name}": created todo #${todoData.todo?.displayNumber || '?'}`);
251
+ } else {
252
+ log(`Schedule "${schedule.name}": create-todo failed (HTTP ${todoResponse.status})`);
340
253
  }
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
- }
345
- break;
346
-
347
- case 'queue-execution':
348
- if (!job.action.todoId) {
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', {
254
+
255
+ } else if (schedule.action_type === 'queue-todo') {
256
+ // Re-queue an existing todo
257
+ if (!schedule.todo_id) {
258
+ log(`Schedule "${schedule.name}": queue-todo has no todo_id, disabling`);
259
+ await context.apiRequest('manage-schedules', {
353
260
  method: 'PATCH',
354
261
  body: JSON.stringify({
355
- todoId: job.action.todoId,
356
- status: 'queued',
262
+ id: schedule.id,
263
+ enabled: false,
264
+ lastRunAt: new Date().toISOString(),
265
+ nextRunAt: null,
357
266
  }),
358
267
  });
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}`);
268
+ continue;
366
269
  }
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}`);
270
+
271
+ const queueResponse = await context.apiRequest('update-task-execution', {
272
+ method: 'PATCH',
273
+ body: JSON.stringify({
274
+ todoId: schedule.todo_id,
275
+ status: 'queued',
276
+ }),
277
+ });
278
+ if (queueResponse.ok) {
279
+ log(`Schedule "${schedule.name}": queued todo ${schedule.todo_id}`);
280
+ } else {
281
+ log(`Schedule "${schedule.name}": queue-todo failed (HTTP ${queueResponse.status})`);
378
282
  }
379
- } else {
380
- log(`Cron "${job.name}": health-check not available (no daemon context)`);
381
283
  }
382
- break;
383
-
384
- default:
385
- log(`Cron "${job.name}": unknown action type "${job.action.type}"`);
386
- }
387
- }
388
-
389
- /**
390
- * Check for and run any due cron jobs.
391
- * Called from daemon poll loop on every cycle.
392
- *
393
- * @param {Function} [logFn] - Optional log function
394
- * @param {Object} [context] - Injected dependencies (apiRequest, spawnHealthCheck)
395
- */
396
- export async function checkAndRunDueJobs(logFn, context = {}) {
397
- const jobs = loadJobs();
398
- if (jobs.length === 0) return;
399
-
400
- const now = new Date();
401
- let modified = false;
402
-
403
- for (const job of jobs) {
404
- if (!job.enabled) continue;
405
- if (!job.nextRunAt) continue;
406
-
407
- const nextRun = new Date(job.nextRunAt);
408
- if (nextRun > now) continue;
409
-
410
- // Job is due — execute
411
- try {
412
- await executeAction(job, logFn, context);
413
284
  } catch (error) {
414
- if (logFn) logFn(`Cron "${job.name}" execution failed: ${error.message}`);
285
+ log(`Schedule "${schedule.name}": execution error: ${error.message}`);
415
286
  }
416
287
 
417
- // Update timing
418
- job.lastRunAt = now.toISOString();
288
+ // 3. Advance next_run_at (with optimistic lock)
289
+ const now = new Date();
290
+ const scheduleConfig = { type: schedule.schedule_type, value: schedule.schedule_value };
419
291
 
420
- if (job.schedule.type === 'at') {
292
+ let nextRunAt = null;
293
+ let enabled = true;
294
+
295
+ if (schedule.schedule_type === 'at') {
421
296
  // One-shot: disable after run
422
- job.enabled = false;
423
- job.nextRunAt = null;
297
+ enabled = false;
424
298
  } else {
425
299
  // Recurring: compute next run
426
- job.nextRunAt = computeNextRun(job.schedule, now);
427
- if (!job.nextRunAt) {
428
- job.enabled = false; // No more future runs
300
+ nextRunAt = computeNextRun(scheduleConfig, now);
301
+ if (!nextRunAt) {
302
+ enabled = false;
429
303
  }
430
304
  }
431
305
 
432
- modified = true;
433
- }
434
-
435
- if (modified) {
436
- saveJobs(jobs);
306
+ try {
307
+ await context.apiRequest('manage-schedules', {
308
+ method: 'PATCH',
309
+ body: JSON.stringify({
310
+ id: schedule.id,
311
+ enabled,
312
+ lastRunAt: now.toISOString(),
313
+ nextRunAt,
314
+ expectedNextRunAt,
315
+ }),
316
+ });
317
+ } catch (error) {
318
+ log(`Schedule "${schedule.name}": failed to advance next_run_at: ${error.message}`);
319
+ }
437
320
  }
438
321
  }
package/lib/daemon.js CHANGED
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'url';
23
23
  import { checkForUpdate, performUpdate } from './self-update.js';
24
24
  import { getProjectContext, buildSmartPrompt, invalidateCache } from './context-engine.js';
25
25
  import { sendMacNotification } from './utils/notify.js';
26
- import { checkAndRunDueJobs } from './cron.js';
26
+ import { checkAndRunRemoteSchedules } from './cron.js';
27
27
  import { runHeartbeatChecks } from './heartbeat.js';
28
28
  import { getAgentVersions, formatAgentVersionSummary, checkAllAgentUpdates, performAgentUpdate, checkVersionParity, ensureAgentReady } from './agent-versions.js';
29
29
  import { checkAllProjectsFreshness } from './project-freshness.js';
@@ -1693,14 +1693,22 @@ function respawnWithInjectedMessage(displayNumber) {
1693
1693
 
1694
1694
  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.`;
1695
1695
 
1696
- const allowedTools = [
1696
+ // Reuse the same expanded tool list as executeTask
1697
+ const baseTools = [
1697
1698
  'Read', 'Edit', 'Write', 'Glob', 'Grep',
1698
1699
  'Bash(git *)',
1699
1700
  'Bash(npm *)', 'Bash(npx *)', 'Bash(yarn *)',
1700
1701
  'Bash(python *)', 'Bash(python3 *)', 'Bash(pip *)', 'Bash(pip3 *)',
1701
1702
  'Bash(push-todo *)',
1702
- 'Task'
1703
- ].join(',');
1703
+ 'Task',
1704
+ 'WebSearch', 'WebFetch',
1705
+ 'ToolSearch',
1706
+ ];
1707
+ const projectContext = projectPath ? getProjectContext(projectPath) : { skills: [], state: {} };
1708
+ const skillTools = (projectContext.skills || [])
1709
+ .flatMap(s => s.tools || [])
1710
+ .filter(t => !baseTools.includes(t));
1711
+ const allowedTools = [...baseTools, ...skillTools].join(',');
1704
1712
 
1705
1713
  // Generate new session ID for the respawned session
1706
1714
  const newSessionId = randomUUID();
@@ -2371,14 +2379,22 @@ async function executeTask(task) {
2371
2379
  // No duplicate status update needed here (was causing race conditions)
2372
2380
 
2373
2381
  // Build Claude command
2374
- const allowedTools = [
2382
+ const baseTools = [
2375
2383
  'Read', 'Edit', 'Write', 'Glob', 'Grep',
2376
2384
  'Bash(git *)',
2377
2385
  'Bash(npm *)', 'Bash(npx *)', 'Bash(yarn *)',
2378
2386
  'Bash(python *)', 'Bash(python3 *)', 'Bash(pip *)', 'Bash(pip3 *)',
2379
2387
  'Bash(push-todo *)',
2380
- 'Task'
2381
- ].join(',');
2388
+ 'Task',
2389
+ 'WebSearch', 'WebFetch',
2390
+ 'ToolSearch',
2391
+ ];
2392
+
2393
+ // Merge tools declared by project skills (via `tools` frontmatter in SKILL.md)
2394
+ const skillTools = (projectContext.skills || [])
2395
+ .flatMap(s => s.tools || [])
2396
+ .filter(t => !baseTools.includes(t));
2397
+ const allowedTools = [...baseTools, ...skillTools].join(',');
2382
2398
 
2383
2399
  // For follow-ups: try --continue with previous session for full context.
2384
2400
  // Falls back to new session with prompt context if session is stale/unavailable.
@@ -3062,126 +3078,6 @@ function logVersionParityWarnings() {
3062
3078
 
3063
3079
  // ==================== Health Check (Phase 5) ====================
3064
3080
 
3065
- /**
3066
- * Spawn a health check Claude session for a project.
3067
- * Called by the cron module when a health-check job fires.
3068
- *
3069
- * The session runs in the project directory with a special prompt that asks
3070
- * Claude to review the codebase and suggest tasks. Results are created as
3071
- * draft todos via the create-todo API.
3072
- *
3073
- * @param {Object} job - Cron job object
3074
- * @param {Function} logFn - Log function
3075
- */
3076
- async function spawnHealthCheck(job, logFn) {
3077
- const projectPath = job.action.projectPath;
3078
- if (!projectPath || !existsSync(projectPath)) {
3079
- logFn(`Health check: project path not found: ${projectPath}`);
3080
- return;
3081
- }
3082
-
3083
- // Don't run if task slots are full
3084
- if (runningTasks.size >= MAX_CONCURRENT_TASKS) {
3085
- logFn(`Health check: all ${MAX_CONCURRENT_TASKS} slots in use, deferring`);
3086
- return;
3087
- }
3088
-
3089
- const scope = job.action.scope || 'general';
3090
- const customPrompt = job.action.prompt || '';
3091
-
3092
- const healthPrompts = {
3093
- general: `Review this codebase briefly. Check for:
3094
- 1. Failing tests (run the test suite if one exists)
3095
- 2. Obvious bugs or issues in recently modified files (last 7 days)
3096
- 3. Outdated dependencies worth updating
3097
-
3098
- For each issue found, create a todo using: push-todo create "<clear description of the issue>"
3099
- Only create todos for real, actionable issues — not style preferences or minor improvements.
3100
- If everything looks good, just say "No issues found" and don't create any todos.`,
3101
- tests: `Run the test suite for this project. If any tests fail, create a todo for each failure:
3102
- push-todo create "Fix failing test: <test name> - <brief reason>"
3103
- If all tests pass, say "All tests pass" and don't create any todos.`,
3104
- dependencies: `Check for outdated dependencies in this project. Only flag dependencies with:
3105
- - Known security vulnerabilities
3106
- - Major version bumps (not minor/patch)
3107
- For each, create a todo: push-todo create "Update <dep> from <old> to <new> (<reason>)"
3108
- If dependencies are current, say "All dependencies up to date."`,
3109
- };
3110
-
3111
- const prompt = customPrompt || healthPrompts[scope] || healthPrompts.general;
3112
-
3113
- const allowedTools = [
3114
- 'Read', 'Glob', 'Grep',
3115
- 'Bash(git *)',
3116
- 'Bash(npm *)', 'Bash(npx *)',
3117
- 'Bash(python *)', 'Bash(python3 *)',
3118
- 'Bash(push-todo create *)',
3119
- ].join(',');
3120
-
3121
- const claudeArgs = [
3122
- '-p', prompt,
3123
- '--verbose',
3124
- '--allowedTools', allowedTools,
3125
- '--output-format', 'stream-json',
3126
- '--permission-mode', 'bypassPermissions',
3127
- ];
3128
-
3129
- logFn(`Health check "${job.name}": spawning Claude in ${projectPath} (scope: ${scope})`);
3130
-
3131
- try {
3132
- const child = spawn('claude', claudeArgs, {
3133
- cwd: projectPath,
3134
- stdio: ['ignore', 'pipe', 'pipe'],
3135
- env: (() => {
3136
- const env = { ...process.env };
3137
- delete env.CLAUDECODE;
3138
- delete env.CLAUDE_CODE_ENTRYPOINT;
3139
- return env;
3140
- })(),
3141
- timeout: 300000, // 5 min max for health checks
3142
- });
3143
-
3144
- // Simple output tracking — health checks are lightweight, no full task tracking
3145
- let output = '';
3146
- child.stdout.on('data', (data) => {
3147
- output += data.toString();
3148
- });
3149
-
3150
- child.stderr.on('data', (data) => {
3151
- const errLine = data.toString().trim();
3152
- if (errLine) logFn(`Health check "${job.name}" stderr: ${errLine}`);
3153
- });
3154
-
3155
- await new Promise((resolve) => {
3156
- child.on('close', (code) => {
3157
- if (code === 0) {
3158
- logFn(`Health check "${job.name}": completed successfully`);
3159
- } else {
3160
- logFn(`Health check "${job.name}": exited with code ${code}`);
3161
- }
3162
- resolve();
3163
- });
3164
- child.on('error', (err) => {
3165
- logFn(`Health check "${job.name}": spawn error: ${err.message}`);
3166
- resolve();
3167
- });
3168
- });
3169
-
3170
- // Extract any text summary from stream-json output
3171
- const lines = output.split('\n').filter(l => l.trim());
3172
- for (const line of lines) {
3173
- try {
3174
- const event = JSON.parse(line);
3175
- if (event.type === 'result' && event.result) {
3176
- logFn(`Health check "${job.name}" result: ${event.result.slice(0, 200)}`);
3177
- }
3178
- } catch { /* ignore non-JSON lines */ }
3179
- }
3180
- } catch (error) {
3181
- logFn(`Health check "${job.name}": error: ${error.message}`);
3182
- }
3183
- }
3184
-
3185
3081
  // ==================== Main Loop ====================
3186
3082
 
3187
3083
  async function pollAndExecute() {
@@ -3352,11 +3248,11 @@ async function mainLoop() {
3352
3248
 
3353
3249
  await pollAndExecute();
3354
3250
 
3355
- // Cron jobs (check every poll cycle, execution throttled by nextRunAt)
3251
+ // Remote schedules from Supabase
3356
3252
  try {
3357
- await checkAndRunDueJobs(log, { apiRequest, spawnHealthCheck });
3253
+ await checkAndRunRemoteSchedules(log, { apiRequest });
3358
3254
  } catch (error) {
3359
- logError(`Cron check error: ${error.message}`);
3255
+ logError(`Remote schedules error: ${error.message}`);
3360
3256
  }
3361
3257
 
3362
3258
  // Heartbeat checks (internally throttled: 10min fast, 1hr slow)