@masslessai/push-todo 4.2.9 → 4.3.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/SKILL.md CHANGED
@@ -433,6 +433,11 @@ The `push-todo` CLI supports these commands:
433
433
  | `push-todo --status` | Show connection status |
434
434
  | `push-todo --mark-completed <uuid>` | Mark task as completed |
435
435
  | `push-todo --json` | Output as JSON |
436
+ | `push-todo schedule add` | Create a remote schedule (Supabase-backed) |
437
+ | `push-todo schedule list` | List all remote schedules |
438
+ | `push-todo schedule remove <id>` | Remove a schedule |
439
+ | `push-todo schedule enable <id>` | Enable a schedule |
440
+ | `push-todo schedule disable <id>` | Disable a schedule |
436
441
 
437
442
  ## What is Push?
438
443
 
package/lib/api.js CHANGED
@@ -521,4 +521,4 @@ export async function requestInput(todoId, question, timeoutMs = 300000) {
521
521
  return null; // Timeout
522
522
  }
523
523
 
524
- export { API_BASE };
524
+ export { API_BASE, apiRequest };
package/lib/cli.js CHANGED
@@ -49,6 +49,7 @@ ${bold('USAGE:')}
49
49
  push-todo search <query> Search tasks
50
50
  push-todo review Review completed tasks
51
51
  push-todo update Update CLI, check agents, refresh projects
52
+ push-todo schedule Manage remote schedules (add/list/remove)
52
53
 
53
54
  ${bold('OPTIONS:')}
54
55
  --all-projects, -a List tasks from all projects
@@ -116,17 +117,20 @@ ${bold('CONFIRM (for daemon skills):')}
116
117
  --metadata <json> Optional JSON metadata for rich rendering
117
118
  --task <number> Display number (auto-detected in daemon)
118
119
 
119
- ${bold('CRON (scheduled jobs):')}
120
- push-todo cron add Add a cron job
121
- --name <name> Job name (required)
122
- --every <interval> Repeat interval: 30m, 1h, 24h, 7d
123
- --at <iso-date> One-shot at specific time
124
- --cron <expression> 5-field cron expression
125
- --notify <message> Send Mac notification
126
- --create-todo <content> Create todo reminder
127
- --health-check <path> Run codebase health check (scope: general|tests|deps)
128
- push-todo cron list List all cron jobs
129
- push-todo cron remove <id> Remove a cron job by ID
120
+ ${bold('SCHEDULE (remote scheduled jobs):')}
121
+ push-todo schedule add Create a schedule
122
+ --name <name> Schedule name (required)
123
+ --every <interval> Repeat interval: 30m, 1h, 24h, 7d
124
+ --at <iso-date> One-shot at specific time
125
+ --cron <expression> 5-field cron expression
126
+ --create-todo <title> Create a new todo each fire
127
+ --queue-todo <todoId> Re-queue an existing todo each fire
128
+ --git-remote <remote> Route created todos to a project
129
+ --content <body> Expanded content for created todos
130
+ push-todo schedule list List all schedules
131
+ push-todo schedule remove <id> Remove a schedule
132
+ push-todo schedule enable <id> Enable a schedule
133
+ push-todo schedule disable <id> Disable a schedule
130
134
 
131
135
  ${bold('SETTINGS:')}
132
136
  push-todo setting Show all settings
@@ -180,16 +184,14 @@ const options = {
180
184
  'content': { type: 'string' },
181
185
  'metadata': { type: 'string' },
182
186
  'task': { type: 'string' },
183
- // Cron command options
187
+ // Schedule command options
184
188
  'name': { type: 'string' },
185
189
  'every': { type: 'string' },
186
190
  'at': { type: 'string' },
187
191
  'cron': { type: 'string' },
188
192
  'create-todo': { type: 'string' },
189
- 'notify': { type: 'string' },
190
- 'queue-execution': { type: 'string' },
191
- 'health-check': { type: 'string' },
192
- 'scope': { type: 'string' },
193
+ 'git-remote': { type: 'string' },
194
+ 'queue-todo': { type: 'string' },
193
195
  // Skill CLI options (Phase 3)
194
196
  'report-progress': { type: 'string' },
195
197
  'phase': { type: 'string' },
@@ -706,99 +708,194 @@ export async function run(argv) {
706
708
  return;
707
709
  }
708
710
 
709
- // Cron command - scheduled jobs
710
- if (command === 'cron') {
711
- const { addJob, removeJob, listJobs } = await import('./cron.js');
711
+ // Schedule command - Supabase-backed unified scheduling
712
+ if (command === 'schedule') {
713
+ const { computeNextRun } = await import('./cron.js');
712
714
  const subCommand = positionals[1];
713
715
 
714
716
  if (subCommand === 'add') {
715
717
  if (!values.name) {
716
- console.error(red('--name is required for cron add'));
718
+ console.error(red('--name is required for schedule add'));
717
719
  process.exit(1);
718
720
  }
719
721
 
720
- // Determine schedule
721
- let schedule;
722
+ // Determine schedule timing
723
+ let scheduleType, scheduleValue;
722
724
  if (values.every) {
723
- schedule = { type: 'every', value: values.every };
725
+ scheduleType = 'every';
726
+ scheduleValue = values.every;
724
727
  } else if (values.at) {
725
- schedule = { type: 'at', value: values.at };
728
+ scheduleType = 'at';
729
+ scheduleValue = values.at;
726
730
  } else if (values.cron) {
727
- schedule = { type: 'cron', value: values.cron };
731
+ scheduleType = 'cron';
732
+ scheduleValue = values.cron;
728
733
  } else {
729
734
  console.error(red('Schedule required: --every, --at, or --cron'));
730
735
  process.exit(1);
731
736
  }
732
737
 
738
+ // Validate schedule and compute next run
739
+ let nextRunAt;
740
+ try {
741
+ nextRunAt = computeNextRun({ type: scheduleType, value: scheduleValue });
742
+ if (!nextRunAt) {
743
+ console.error(red('Schedule has no future run time'));
744
+ process.exit(1);
745
+ }
746
+ } catch (error) {
747
+ console.error(red(`Invalid schedule: ${error.message}`));
748
+ process.exit(1);
749
+ }
750
+
733
751
  // Determine action
734
- let action;
752
+ let actionType, actionTitle, actionContent, gitRemote, todoId;
735
753
  if (values['create-todo']) {
736
- action = { type: 'create-todo', content: values['create-todo'] };
737
- } else if (values.notify) {
738
- action = { type: 'notify', content: values.notify };
739
- } else if (values['queue-execution']) {
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
- };
754
+ actionType = 'create-todo';
755
+ actionTitle = values['create-todo'];
756
+ actionContent = values.content || null;
757
+ gitRemote = values['git-remote'] || null;
758
+ } else if (values['queue-todo']) {
759
+ actionType = 'queue-todo';
760
+ todoId = values['queue-todo'];
747
761
  } else {
748
- console.error(red('Action required: --create-todo, --notify, --queue-execution, or --health-check'));
762
+ console.error(red('Action required: --create-todo <title> or --queue-todo <todoId>'));
749
763
  process.exit(1);
750
764
  }
751
765
 
752
766
  try {
753
- const job = addJob({ name: values.name, schedule, action });
754
- console.log(green(`Created cron job: ${job.name} (ID: ${job.id.slice(0, 8)})`));
755
- console.log(dim(`Next run: ${job.nextRunAt}`));
767
+ const response = await api.apiRequest('manage-schedules', {
768
+ method: 'POST',
769
+ body: JSON.stringify({
770
+ name: values.name,
771
+ scheduleType,
772
+ scheduleValue,
773
+ actionType,
774
+ actionTitle,
775
+ actionContent,
776
+ gitRemote,
777
+ todoId,
778
+ nextRunAt,
779
+ }),
780
+ });
781
+
782
+ if (!response.ok) {
783
+ const data = await response.json();
784
+ console.error(red(`Failed to create schedule: ${data.error || response.status}`));
785
+ process.exit(1);
786
+ }
787
+
788
+ const data = await response.json();
789
+ const s = data.schedule;
790
+ console.log(green(`Created schedule: ${s.name} (ID: ${s.id.slice(0, 8)})`));
791
+ console.log(dim(`Next run: ${s.next_run_at}`));
756
792
  } catch (error) {
757
- console.error(red(`Failed to create cron job: ${error.message}`));
793
+ console.error(red(`Failed to create schedule: ${error.message}`));
758
794
  process.exit(1);
759
795
  }
760
796
  return;
761
797
  }
762
798
 
763
799
  if (subCommand === 'list') {
764
- const jobs = listJobs();
765
- if (jobs.length === 0) {
766
- console.log('No cron jobs configured.');
767
- console.log(dim('Add one with: push-todo cron add --name "..." --every "24h" --notify "..."'));
768
- return;
769
- }
770
- console.log(bold('Cron Jobs:'));
771
- for (const job of jobs) {
772
- const status = job.enabled ? green('ON') : dim('OFF');
773
- const schedStr = job.schedule.type === 'every' ? `every ${job.schedule.value}` :
774
- job.schedule.type === 'at' ? `at ${job.schedule.value}` :
775
- `cron: ${job.schedule.value}`;
776
- console.log(` ${status} ${job.name} [${schedStr}] ${job.action.type}: ${job.action.content || job.action.todoId || ''}`);
777
- console.log(dim(` ID: ${job.id.slice(0, 8)} | Next: ${job.nextRunAt || 'N/A'} | Last: ${job.lastRunAt || 'never'}`));
800
+ try {
801
+ const response = await api.apiRequest('manage-schedules', { method: 'GET' });
802
+ if (!response.ok) {
803
+ console.error(red(`Failed to list schedules: HTTP ${response.status}`));
804
+ process.exit(1);
805
+ }
806
+
807
+ const data = await response.json();
808
+ const schedules = data.schedules || [];
809
+
810
+ if (schedules.length === 0) {
811
+ console.log('No schedules configured.');
812
+ console.log(dim('Add one with: push-todo schedule add --name "..." --every "4h" --create-todo "..."'));
813
+ return;
814
+ }
815
+
816
+ console.log(bold('Schedules:'));
817
+ for (const s of schedules) {
818
+ const status = s.enabled ? green('ON') : dim('OFF');
819
+ const schedStr = s.schedule_type === 'every' ? `every ${s.schedule_value}` :
820
+ s.schedule_type === 'at' ? `at ${s.schedule_value}` :
821
+ `cron: ${s.schedule_value}`;
822
+ const actionStr = s.action_type === 'create-todo'
823
+ ? `create-todo: ${s.action_title || ''}`
824
+ : `queue-todo: ${s.todo_id || '?'}`;
825
+ console.log(` ${status} ${s.name} [${schedStr}] → ${actionStr}`);
826
+ console.log(dim(` ID: ${s.id.slice(0, 8)} | Next: ${s.next_run_at || 'N/A'} | Last: ${s.last_run_at || 'never'}`));
827
+ }
828
+ } catch (error) {
829
+ console.error(red(`Failed to list schedules: ${error.message}`));
830
+ process.exit(1);
778
831
  }
779
832
  return;
780
833
  }
781
834
 
782
835
  if (subCommand === 'remove') {
783
- const jobId = positionals[2];
784
- if (!jobId) {
785
- console.error(red('Usage: push-todo cron remove <id>'));
836
+ const scheduleId = positionals[2];
837
+ if (!scheduleId) {
838
+ console.error(red('Usage: push-todo schedule remove <id>'));
786
839
  process.exit(1);
787
840
  }
788
- if (removeJob(jobId)) {
789
- console.log(green(`Removed cron job ${jobId}`));
790
- } else {
791
- console.error(red(`Cron job not found: ${jobId}`));
841
+
842
+ try {
843
+ const response = await api.apiRequest(`manage-schedules?id=${scheduleId}`, {
844
+ method: 'DELETE',
845
+ });
846
+ if (response.ok) {
847
+ console.log(green(`Removed schedule ${scheduleId}`));
848
+ } else {
849
+ const data = await response.json();
850
+ console.error(red(`Failed to remove schedule: ${data.error || response.status}`));
851
+ process.exit(1);
852
+ }
853
+ } catch (error) {
854
+ console.error(red(`Failed to remove schedule: ${error.message}`));
855
+ process.exit(1);
856
+ }
857
+ return;
858
+ }
859
+
860
+ if (subCommand === 'enable' || subCommand === 'disable') {
861
+ const scheduleId = positionals[2];
862
+ if (!scheduleId) {
863
+ console.error(red(`Usage: push-todo schedule ${subCommand} <id>`));
864
+ process.exit(1);
865
+ }
866
+
867
+ const enabled = subCommand === 'enable';
868
+ try {
869
+ const response = await api.apiRequest('manage-schedules', {
870
+ method: 'PATCH',
871
+ body: JSON.stringify({ id: scheduleId, enabled }),
872
+ });
873
+ if (response.ok) {
874
+ console.log(green(`Schedule ${scheduleId} ${enabled ? 'enabled' : 'disabled'}`));
875
+ } else {
876
+ const data = await response.json();
877
+ console.error(red(`Failed to ${subCommand} schedule: ${data.error || response.status}`));
878
+ process.exit(1);
879
+ }
880
+ } catch (error) {
881
+ console.error(red(`Failed to ${subCommand} schedule: ${error.message}`));
792
882
  process.exit(1);
793
883
  }
794
884
  return;
795
885
  }
796
886
 
797
- // Default: show help for cron
798
- console.log(`${bold('Cron Commands:')}
799
- push-todo cron add Add a new scheduled job
800
- push-todo cron list List all jobs
801
- push-todo cron remove Remove a job by ID
887
+ // Default: show help for schedule
888
+ console.log(`${bold('Schedule Commands:')}
889
+ push-todo schedule add Create a new remote schedule
890
+ push-todo schedule list List all schedules
891
+ push-todo schedule remove Remove a schedule by ID
892
+ push-todo schedule enable Enable a schedule
893
+ push-todo schedule disable Disable a schedule
894
+
895
+ ${bold('Examples:')}
896
+ push-todo schedule add --name "Daily standup" --every 24h --create-todo "Write standup update"
897
+ push-todo schedule add --name "Weekly review" --cron "0 9 * * 1" --create-todo "Weekly code review" --git-remote github.com/user/repo
898
+ push-todo schedule add --name "Re-run task" --every 4h --queue-todo <todoId>
802
899
  `);
803
900
  return;
804
901
  }
@@ -110,10 +110,16 @@ export function scanProjectSkills(projectPath) {
110
110
  const content = readFileSync(skillFile, 'utf8');
111
111
  const { frontmatter, body } = parseFrontmatter(content);
112
112
 
113
+ // Parse tools field: comma-separated list of tool names/patterns
114
+ const tools = frontmatter.tools
115
+ ? frontmatter.tools.split(',').map(t => t.trim()).filter(Boolean)
116
+ : [];
117
+
113
118
  skills.push({
114
119
  name: frontmatter.name || entry,
115
120
  description: frontmatter.description || '',
116
121
  requiresConfirmation: body.includes('push-todo confirm'),
122
+ tools,
117
123
  });
118
124
  } catch {
119
125
  // Skip unreadable skill files
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.2.9",
3
+ "version": "4.3.0",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {