@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 +5 -0
- package/lib/api.js +1 -1
- package/lib/cli.js +165 -68
- package/lib/context-engine.js +6 -0
- package/lib/cron.js +104 -221
- package/lib/daemon.js +26 -130
- package/package.json +1 -1
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
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('
|
|
120
|
-
push-todo
|
|
121
|
-
--name <name>
|
|
122
|
-
--every <interval>
|
|
123
|
-
--at <iso-date>
|
|
124
|
-
--cron <expression>
|
|
125
|
-
--
|
|
126
|
-
--
|
|
127
|
-
--
|
|
128
|
-
|
|
129
|
-
push-todo
|
|
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
|
-
//
|
|
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
|
-
'
|
|
190
|
-
'queue-
|
|
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
|
-
//
|
|
710
|
-
if (command === '
|
|
711
|
-
const {
|
|
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
|
|
718
|
+
console.error(red('--name is required for schedule add'));
|
|
717
719
|
process.exit(1);
|
|
718
720
|
}
|
|
719
721
|
|
|
720
|
-
// Determine schedule
|
|
721
|
-
let
|
|
722
|
+
// Determine schedule timing
|
|
723
|
+
let scheduleType, scheduleValue;
|
|
722
724
|
if (values.every) {
|
|
723
|
-
|
|
725
|
+
scheduleType = 'every';
|
|
726
|
+
scheduleValue = values.every;
|
|
724
727
|
} else if (values.at) {
|
|
725
|
-
|
|
728
|
+
scheduleType = 'at';
|
|
729
|
+
scheduleValue = values.at;
|
|
726
730
|
} else if (values.cron) {
|
|
727
|
-
|
|
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
|
|
752
|
+
let actionType, actionTitle, actionContent, gitRemote, todoId;
|
|
735
753
|
if (values['create-todo']) {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
|
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
|
|
754
|
-
|
|
755
|
-
|
|
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
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
784
|
-
if (!
|
|
785
|
-
console.error(red('Usage: push-todo
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
|
798
|
-
console.log(`${bold('
|
|
799
|
-
push-todo
|
|
800
|
-
push-todo
|
|
801
|
-
push-todo
|
|
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
|
}
|
package/lib/context-engine.js
CHANGED
|
@@ -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
|
-
*
|
|
2
|
+
* Schedule engine for Push daemon.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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/
|
|
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
|
-
// ====================
|
|
190
|
+
// ==================== Remote Schedules (Supabase) ====================
|
|
229
191
|
|
|
230
192
|
/**
|
|
231
|
-
*
|
|
193
|
+
* Check for and run any due remote schedules from Supabase.
|
|
194
|
+
* Called from daemon poll loop on every cycle.
|
|
232
195
|
*
|
|
233
|
-
* @param {
|
|
234
|
-
* @param {
|
|
235
|
-
* @param {
|
|
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
|
|
240
|
-
const
|
|
200
|
+
export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
201
|
+
const log = logFn || (() => {});
|
|
241
202
|
|
|
242
|
-
if (!
|
|
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
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
+
// 2. Fire each due schedule
|
|
227
|
+
for (const schedule of schedules) {
|
|
228
|
+
const expectedNextRunAt = schedule.next_run_at;
|
|
283
229
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
356
|
-
|
|
262
|
+
id: schedule.id,
|
|
263
|
+
enabled: false,
|
|
264
|
+
lastRunAt: new Date().toISOString(),
|
|
265
|
+
nextRunAt: null,
|
|
357
266
|
}),
|
|
358
267
|
});
|
|
359
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
285
|
+
log(`Schedule "${schedule.name}": execution error: ${error.message}`);
|
|
415
286
|
}
|
|
416
287
|
|
|
417
|
-
//
|
|
418
|
-
|
|
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
|
-
|
|
292
|
+
let nextRunAt = null;
|
|
293
|
+
let enabled = true;
|
|
294
|
+
|
|
295
|
+
if (schedule.schedule_type === 'at') {
|
|
421
296
|
// One-shot: disable after run
|
|
422
|
-
|
|
423
|
-
job.nextRunAt = null;
|
|
297
|
+
enabled = false;
|
|
424
298
|
} else {
|
|
425
299
|
// Recurring: compute next run
|
|
426
|
-
|
|
427
|
-
if (!
|
|
428
|
-
|
|
300
|
+
nextRunAt = computeNextRun(scheduleConfig, now);
|
|
301
|
+
if (!nextRunAt) {
|
|
302
|
+
enabled = false;
|
|
429
303
|
}
|
|
430
304
|
}
|
|
431
305
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
3251
|
+
// Remote schedules from Supabase
|
|
3356
3252
|
try {
|
|
3357
|
-
await
|
|
3253
|
+
await checkAndRunRemoteSchedules(log, { apiRequest });
|
|
3358
3254
|
} catch (error) {
|
|
3359
|
-
logError(`
|
|
3255
|
+
logError(`Remote schedules error: ${error.message}`);
|
|
3360
3256
|
}
|
|
3361
3257
|
|
|
3362
3258
|
// Heartbeat checks (internally throttled: 10min fast, 1hr slow)
|