@masslessai/push-todo 4.0.1 → 4.0.3
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/cli.js +110 -0
- package/lib/context-engine.js +290 -0
- package/lib/cron.js +382 -0
- package/lib/daemon.js +121 -41
- package/lib/heartbeat.js +286 -0
- package/lib/utils/notify.js +32 -0
- package/package.json +1 -1
package/lib/cli.js
CHANGED
|
@@ -101,6 +101,17 @@ ${bold('CONFIRM (for daemon skills):')}
|
|
|
101
101
|
--metadata <json> Optional JSON metadata for rich rendering
|
|
102
102
|
--task <number> Display number (auto-detected in daemon)
|
|
103
103
|
|
|
104
|
+
${bold('CRON (scheduled jobs):')}
|
|
105
|
+
push-todo cron add Add a cron job
|
|
106
|
+
--name <name> Job name (required)
|
|
107
|
+
--every <interval> Repeat interval: 30m, 1h, 24h, 7d
|
|
108
|
+
--at <iso-date> One-shot at specific time
|
|
109
|
+
--cron <expression> 5-field cron expression
|
|
110
|
+
--notify <message> Send Mac notification
|
|
111
|
+
--create-todo <content> Create todo reminder
|
|
112
|
+
push-todo cron list List all cron jobs
|
|
113
|
+
push-todo cron remove <id> Remove a cron job by ID
|
|
114
|
+
|
|
104
115
|
${bold('SETTINGS:')}
|
|
105
116
|
push-todo setting Show all settings
|
|
106
117
|
push-todo setting auto-commit Toggle auto-commit
|
|
@@ -150,6 +161,14 @@ const options = {
|
|
|
150
161
|
'content': { type: 'string' },
|
|
151
162
|
'metadata': { type: 'string' },
|
|
152
163
|
'task': { type: 'string' },
|
|
164
|
+
// Cron command options
|
|
165
|
+
'name': { type: 'string' },
|
|
166
|
+
'every': { type: 'string' },
|
|
167
|
+
'at': { type: 'string' },
|
|
168
|
+
'cron': { type: 'string' },
|
|
169
|
+
'create-todo': { type: 'string' },
|
|
170
|
+
'notify': { type: 'string' },
|
|
171
|
+
'queue-execution': { type: 'string' },
|
|
153
172
|
};
|
|
154
173
|
|
|
155
174
|
/**
|
|
@@ -486,6 +505,97 @@ export async function run(argv) {
|
|
|
486
505
|
return requestConfirmation(values, positionals);
|
|
487
506
|
}
|
|
488
507
|
|
|
508
|
+
// Cron command - scheduled jobs
|
|
509
|
+
if (command === 'cron') {
|
|
510
|
+
const { addJob, removeJob, listJobs } = await import('./cron.js');
|
|
511
|
+
const subCommand = positionals[1];
|
|
512
|
+
|
|
513
|
+
if (subCommand === 'add') {
|
|
514
|
+
if (!values.name) {
|
|
515
|
+
console.error(red('--name is required for cron add'));
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Determine schedule
|
|
520
|
+
let schedule;
|
|
521
|
+
if (values.every) {
|
|
522
|
+
schedule = { type: 'every', value: values.every };
|
|
523
|
+
} else if (values.at) {
|
|
524
|
+
schedule = { type: 'at', value: values.at };
|
|
525
|
+
} else if (values.cron) {
|
|
526
|
+
schedule = { type: 'cron', value: values.cron };
|
|
527
|
+
} else {
|
|
528
|
+
console.error(red('Schedule required: --every, --at, or --cron'));
|
|
529
|
+
process.exit(1);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Determine action
|
|
533
|
+
let action;
|
|
534
|
+
if (values['create-todo']) {
|
|
535
|
+
action = { type: 'create-todo', content: values['create-todo'] };
|
|
536
|
+
} else if (values.notify) {
|
|
537
|
+
action = { type: 'notify', content: values.notify };
|
|
538
|
+
} else if (values['queue-execution']) {
|
|
539
|
+
action = { type: 'queue-execution', todoId: values['queue-execution'] };
|
|
540
|
+
} else {
|
|
541
|
+
console.error(red('Action required: --create-todo, --notify, or --queue-execution'));
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const job = addJob({ name: values.name, schedule, action });
|
|
547
|
+
console.log(green(`Created cron job: ${job.name} (ID: ${job.id.slice(0, 8)})`));
|
|
548
|
+
console.log(dim(`Next run: ${job.nextRunAt}`));
|
|
549
|
+
} catch (error) {
|
|
550
|
+
console.error(red(`Failed to create cron job: ${error.message}`));
|
|
551
|
+
process.exit(1);
|
|
552
|
+
}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (subCommand === 'list') {
|
|
557
|
+
const jobs = listJobs();
|
|
558
|
+
if (jobs.length === 0) {
|
|
559
|
+
console.log('No cron jobs configured.');
|
|
560
|
+
console.log(dim('Add one with: push-todo cron add --name "..." --every "24h" --notify "..."'));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
console.log(bold('Cron Jobs:'));
|
|
564
|
+
for (const job of jobs) {
|
|
565
|
+
const status = job.enabled ? green('ON') : dim('OFF');
|
|
566
|
+
const schedStr = job.schedule.type === 'every' ? `every ${job.schedule.value}` :
|
|
567
|
+
job.schedule.type === 'at' ? `at ${job.schedule.value}` :
|
|
568
|
+
`cron: ${job.schedule.value}`;
|
|
569
|
+
console.log(` ${status} ${job.name} [${schedStr}] → ${job.action.type}: ${job.action.content || job.action.todoId || ''}`);
|
|
570
|
+
console.log(dim(` ID: ${job.id.slice(0, 8)} | Next: ${job.nextRunAt || 'N/A'} | Last: ${job.lastRunAt || 'never'}`));
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (subCommand === 'remove') {
|
|
576
|
+
const jobId = positionals[2];
|
|
577
|
+
if (!jobId) {
|
|
578
|
+
console.error(red('Usage: push-todo cron remove <id>'));
|
|
579
|
+
process.exit(1);
|
|
580
|
+
}
|
|
581
|
+
if (removeJob(jobId)) {
|
|
582
|
+
console.log(green(`Removed cron job ${jobId}`));
|
|
583
|
+
} else {
|
|
584
|
+
console.error(red(`Cron job not found: ${jobId}`));
|
|
585
|
+
process.exit(1);
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Default: show help for cron
|
|
591
|
+
console.log(`${bold('Cron Commands:')}
|
|
592
|
+
push-todo cron add Add a new scheduled job
|
|
593
|
+
push-todo cron list List all jobs
|
|
594
|
+
push-todo cron remove Remove a job by ID
|
|
595
|
+
`);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
489
599
|
// Connect command
|
|
490
600
|
if (command === 'connect') {
|
|
491
601
|
return runConnect(values);
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Engine + Prompt Engine for Push daemon.
|
|
3
|
+
*
|
|
4
|
+
* Scans project skills, collects git/GitHub state, builds context-rich prompts.
|
|
5
|
+
* All functions are non-fatal — errors result in empty/partial context.
|
|
6
|
+
*
|
|
7
|
+
* Architecture: docs/20260214_push_daemon_evolution_complete_architecture.md §23
|
|
8
|
+
* Pattern: Follow self-update.js — pure functions, execFileSync, no npm deps.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
|
|
15
|
+
// ==================== Cache ====================
|
|
16
|
+
|
|
17
|
+
const contextCache = new Map(); // projectPath -> { data, timestamp }
|
|
18
|
+
const CACHE_TTL = 3600000; // 1 hour
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Invalidate cache for a specific project path.
|
|
22
|
+
* Called before task execution to ensure fresh context.
|
|
23
|
+
*/
|
|
24
|
+
export function invalidateCache(projectPath) {
|
|
25
|
+
contextCache.delete(projectPath);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ==================== YAML Frontmatter Parser ====================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse YAML frontmatter from a SKILL.md file.
|
|
32
|
+
* Minimal parser: extracts key: value pairs between --- markers.
|
|
33
|
+
* No npm yaml dependency — skills use simple single-line key: value format.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} content - Full file content
|
|
36
|
+
* @returns {{ frontmatter: Object, body: string }}
|
|
37
|
+
*/
|
|
38
|
+
export function parseFrontmatter(content) {
|
|
39
|
+
const lines = content.split('\n');
|
|
40
|
+
|
|
41
|
+
// Must start with ---
|
|
42
|
+
if (lines[0].trim() !== '---') {
|
|
43
|
+
return { frontmatter: {}, body: content };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Find closing ---
|
|
47
|
+
let closingIndex = -1;
|
|
48
|
+
for (let i = 1; i < lines.length; i++) {
|
|
49
|
+
if (lines[i].trim() === '---') {
|
|
50
|
+
closingIndex = i;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (closingIndex === -1) {
|
|
56
|
+
return { frontmatter: {}, body: content };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Parse key: value pairs
|
|
60
|
+
const frontmatter = {};
|
|
61
|
+
for (let i = 1; i < closingIndex; i++) {
|
|
62
|
+
const line = lines[i];
|
|
63
|
+
const colonIdx = line.indexOf(':');
|
|
64
|
+
if (colonIdx === -1) continue;
|
|
65
|
+
|
|
66
|
+
const key = line.slice(0, colonIdx).trim();
|
|
67
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
68
|
+
if (key) {
|
|
69
|
+
frontmatter[key] = value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const body = lines.slice(closingIndex + 1).join('\n');
|
|
74
|
+
return { frontmatter, body };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ==================== Skill Scanning ====================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Scan .claude/skills/x/SKILL.md files in a project directory.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} projectPath - Absolute path to project root
|
|
83
|
+
* @returns {Array<{ name: string, description: string, requiresConfirmation: boolean }>}
|
|
84
|
+
*/
|
|
85
|
+
export function scanProjectSkills(projectPath) {
|
|
86
|
+
const skillsDir = join(projectPath, '.claude', 'skills');
|
|
87
|
+
|
|
88
|
+
if (!existsSync(skillsDir)) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const entries = readdirSync(skillsDir);
|
|
94
|
+
const skills = [];
|
|
95
|
+
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const entryPath = join(skillsDir, entry);
|
|
98
|
+
|
|
99
|
+
// Skip non-directories
|
|
100
|
+
try {
|
|
101
|
+
if (!statSync(entryPath).isDirectory()) continue;
|
|
102
|
+
} catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const skillFile = join(entryPath, 'SKILL.md');
|
|
107
|
+
if (!existsSync(skillFile)) continue;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const content = readFileSync(skillFile, 'utf8');
|
|
111
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
112
|
+
|
|
113
|
+
skills.push({
|
|
114
|
+
name: frontmatter.name || entry,
|
|
115
|
+
description: frontmatter.description || '',
|
|
116
|
+
requiresConfirmation: body.includes('push-todo confirm'),
|
|
117
|
+
});
|
|
118
|
+
} catch {
|
|
119
|
+
// Skip unreadable skill files
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return skills;
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ==================== Project State ====================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Collect git + GitHub state for a project.
|
|
133
|
+
* All calls are non-fatal — returns partial data on failure.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} projectPath - Absolute path to project root
|
|
136
|
+
* @returns {{ recentCommits: string[], openPRs: Array|null, currentBranch: string|null }}
|
|
137
|
+
*/
|
|
138
|
+
export function collectProjectState(projectPath) {
|
|
139
|
+
const result = {
|
|
140
|
+
recentCommits: [],
|
|
141
|
+
openPRs: null,
|
|
142
|
+
currentBranch: null,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Current branch
|
|
146
|
+
try {
|
|
147
|
+
result.currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
148
|
+
cwd: projectPath,
|
|
149
|
+
timeout: 5000,
|
|
150
|
+
encoding: 'utf8',
|
|
151
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
152
|
+
}).trim();
|
|
153
|
+
} catch { /* non-fatal */ }
|
|
154
|
+
|
|
155
|
+
// Recent commits
|
|
156
|
+
try {
|
|
157
|
+
const output = execFileSync('git', ['log', '--oneline', '-5'], {
|
|
158
|
+
cwd: projectPath,
|
|
159
|
+
timeout: 5000,
|
|
160
|
+
encoding: 'utf8',
|
|
161
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
162
|
+
}).trim();
|
|
163
|
+
|
|
164
|
+
if (output) {
|
|
165
|
+
result.recentCommits = output.split('\n');
|
|
166
|
+
}
|
|
167
|
+
} catch { /* non-fatal */ }
|
|
168
|
+
|
|
169
|
+
// Open PRs (only if gh CLI is available)
|
|
170
|
+
try {
|
|
171
|
+
execFileSync('which', ['gh'], {
|
|
172
|
+
timeout: 3000,
|
|
173
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const prOutput = execFileSync('gh', ['pr', 'list', '--json', 'number,title,updatedAt', '--limit', '5'], {
|
|
177
|
+
cwd: projectPath,
|
|
178
|
+
timeout: 10000,
|
|
179
|
+
encoding: 'utf8',
|
|
180
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
181
|
+
}).trim();
|
|
182
|
+
|
|
183
|
+
if (prOutput) {
|
|
184
|
+
result.openPRs = JSON.parse(prOutput);
|
|
185
|
+
}
|
|
186
|
+
} catch { /* non-fatal — gh not installed or not authenticated */ }
|
|
187
|
+
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ==================== Cached Context ====================
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get project context (skills + state), with hourly cache.
|
|
195
|
+
* Call invalidateCache(projectPath) before task execution for fresh data.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} projectPath - Absolute path to project root
|
|
198
|
+
* @returns {{ skills: Array, state: Object }}
|
|
199
|
+
*/
|
|
200
|
+
export function getProjectContext(projectPath) {
|
|
201
|
+
const cached = contextCache.get(projectPath);
|
|
202
|
+
|
|
203
|
+
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
|
|
204
|
+
return cached.data;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const skills = scanProjectSkills(projectPath);
|
|
208
|
+
const state = collectProjectState(projectPath);
|
|
209
|
+
const data = { skills, state };
|
|
210
|
+
|
|
211
|
+
contextCache.set(projectPath, { data, timestamp: Date.now() });
|
|
212
|
+
|
|
213
|
+
return data;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ==================== Prompt Engine ====================
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build a context-rich prompt for Claude Code sessions.
|
|
220
|
+
* Each section is only included when data exists.
|
|
221
|
+
*
|
|
222
|
+
* @param {Object} task
|
|
223
|
+
* @param {number} task.displayNumber
|
|
224
|
+
* @param {string} task.content - Normalized content or summary
|
|
225
|
+
* @param {string} task.attachmentContext - Pre-built attachment string (links, screenshots)
|
|
226
|
+
* @param {string|null} task.actionName - Action display name from registry
|
|
227
|
+
* @param {string|null} task.contextApp - Source app (X, Safari, etc.)
|
|
228
|
+
* @param {Object} context - From getProjectContext()
|
|
229
|
+
* @param {Array} context.skills
|
|
230
|
+
* @param {Object} context.state
|
|
231
|
+
* @returns {string} Complete prompt
|
|
232
|
+
*/
|
|
233
|
+
export function buildSmartPrompt(task, context) {
|
|
234
|
+
const sections = [];
|
|
235
|
+
|
|
236
|
+
// 1. Task section (always present)
|
|
237
|
+
sections.push(`## Task\nWork on Push task #${task.displayNumber}:\n\n${task.content}`);
|
|
238
|
+
|
|
239
|
+
// 2. Metadata section (conditional)
|
|
240
|
+
const metaParts = [];
|
|
241
|
+
if (task.actionName) metaParts.push(`Action: ${task.actionName}`);
|
|
242
|
+
if (task.contextApp) metaParts.push(`Context app: ${task.contextApp}`);
|
|
243
|
+
if (metaParts.length > 0) {
|
|
244
|
+
sections.push(`## Metadata\n${metaParts.join('\n')}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 3. Attachment section (conditional — pre-built by caller)
|
|
248
|
+
if (task.attachmentContext) {
|
|
249
|
+
sections.push(`## Attachments${task.attachmentContext}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 4. Skill section (conditional)
|
|
253
|
+
if (context.skills && context.skills.length > 0) {
|
|
254
|
+
const skillLines = context.skills.map(s => {
|
|
255
|
+
const flag = s.requiresConfirmation ? ' [requires push-todo confirm]' : '';
|
|
256
|
+
return `- /${s.name}: ${s.description || 'No description'}${flag}`;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const confirmSkills = context.skills.filter(s => s.requiresConfirmation);
|
|
260
|
+
let confirmNote = '';
|
|
261
|
+
if (confirmSkills.length > 0) {
|
|
262
|
+
confirmNote = '\n\nSkills marked [requires push-todo confirm] include a user approval step before performing irreversible actions. If your task matches one of these skills, you MUST invoke it to ensure the proper confirmation workflow.';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
sections.push(`## Available Skills\nThe following skills are available in this project:\n${skillLines.join('\n')}${confirmNote}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 5. Project state section (conditional)
|
|
269
|
+
const stateParts = [];
|
|
270
|
+
if (context.state?.currentBranch) {
|
|
271
|
+
stateParts.push(`Current branch: ${context.state.currentBranch}`);
|
|
272
|
+
}
|
|
273
|
+
if (context.state?.recentCommits?.length > 0) {
|
|
274
|
+
stateParts.push(`Recent commits:\n${context.state.recentCommits.map(c => ` ${c}`).join('\n')}`);
|
|
275
|
+
}
|
|
276
|
+
if (context.state?.openPRs?.length > 0) {
|
|
277
|
+
stateParts.push(`Open PRs:\n${context.state.openPRs.map(pr => ` #${pr.number} ${pr.title}`).join('\n')}`);
|
|
278
|
+
}
|
|
279
|
+
if (stateParts.length > 0) {
|
|
280
|
+
sections.push(`## Project State\n${stateParts.join('\n')}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 6. Instructions section (always present)
|
|
284
|
+
sections.push(`## Instructions
|
|
285
|
+
1. If you need to understand the codebase, start by reading the CLAUDE.md file if it exists.
|
|
286
|
+
2. ALWAYS commit your changes before finishing. Use a descriptive commit message summarizing what you did. This is critical — uncommitted changes will be lost when the worktree is cleaned up.
|
|
287
|
+
3. When you're done, the SessionEnd hook will automatically report completion to Supabase.`);
|
|
288
|
+
|
|
289
|
+
return sections.join('\n\n');
|
|
290
|
+
}
|
package/lib/cron.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron job scheduler for Push daemon.
|
|
3
|
+
*
|
|
4
|
+
* Stores recurring/one-shot jobs in ~/.push/cron/jobs.json.
|
|
5
|
+
* Called from daemon main loop on each poll cycle.
|
|
6
|
+
*
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
|
|
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
|
+
// ==================== Interval Parsing ====================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse an interval string into milliseconds.
|
|
53
|
+
* Supports: "30m", "1h", "24h", "7d"
|
|
54
|
+
*
|
|
55
|
+
* @param {string} value - Interval string
|
|
56
|
+
* @returns {number} Milliseconds
|
|
57
|
+
*/
|
|
58
|
+
export function parseInterval(value) {
|
|
59
|
+
const match = value.match(/^(\d+)(m|h|d)$/);
|
|
60
|
+
if (!match) {
|
|
61
|
+
throw new Error(`Invalid interval format: "${value}". Use Nm, Nh, or Nd (e.g., "30m", "1h", "7d")`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const num = parseInt(match[1], 10);
|
|
65
|
+
const unit = match[2];
|
|
66
|
+
|
|
67
|
+
const multipliers = { m: 60000, h: 3600000, d: 86400000 };
|
|
68
|
+
return num * multipliers[unit];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ==================== Cron Expression Parser ====================
|
|
72
|
+
|
|
73
|
+
// Parse a single cron field into an array of valid values.
|
|
74
|
+
// Supports: *, N, N-M, N,M, and step expressions.
|
|
75
|
+
function parseCronField(field, min, max) {
|
|
76
|
+
const values = new Set();
|
|
77
|
+
|
|
78
|
+
for (const part of field.split(',')) {
|
|
79
|
+
const trimmed = part.trim();
|
|
80
|
+
|
|
81
|
+
// */N — every N
|
|
82
|
+
if (trimmed.startsWith('*/')) {
|
|
83
|
+
const step = parseInt(trimmed.slice(2), 10);
|
|
84
|
+
if (isNaN(step) || step <= 0) throw new Error(`Invalid cron step: ${trimmed}`);
|
|
85
|
+
for (let i = min; i <= max; i += step) values.add(i);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// * — all values
|
|
90
|
+
if (trimmed === '*') {
|
|
91
|
+
for (let i = min; i <= max; i++) values.add(i);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// N-M — range
|
|
96
|
+
if (trimmed.includes('-')) {
|
|
97
|
+
const [startStr, endStr] = trimmed.split('-');
|
|
98
|
+
const start = parseInt(startStr, 10);
|
|
99
|
+
const end = parseInt(endStr, 10);
|
|
100
|
+
if (isNaN(start) || isNaN(end)) throw new Error(`Invalid cron range: ${trimmed}`);
|
|
101
|
+
for (let i = start; i <= end; i++) values.add(i);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// N — specific value
|
|
106
|
+
const num = parseInt(trimmed, 10);
|
|
107
|
+
if (isNaN(num)) throw new Error(`Invalid cron value: ${trimmed}`);
|
|
108
|
+
values.add(num);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [...values].sort((a, b) => a - b);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parse a 5-field cron expression and compute the next matching time.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} expression - Cron expression (e.g., "0 9 * * 1")
|
|
118
|
+
* @param {Date} [fromDate] - Start searching from this date (default: now)
|
|
119
|
+
* @returns {Date|null} Next matching time, or null if none within 366 days
|
|
120
|
+
*/
|
|
121
|
+
export function getNextCronMatch(expression, fromDate = new Date()) {
|
|
122
|
+
const fields = expression.trim().split(/\s+/);
|
|
123
|
+
if (fields.length !== 5) {
|
|
124
|
+
throw new Error(`Cron expression must have 5 fields, got ${fields.length}: "${expression}"`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const minutes = parseCronField(fields[0], 0, 59);
|
|
128
|
+
const hours = parseCronField(fields[1], 0, 23);
|
|
129
|
+
const daysOfMonth = parseCronField(fields[2], 1, 31);
|
|
130
|
+
const months = parseCronField(fields[3], 1, 12);
|
|
131
|
+
const daysOfWeek = parseCronField(fields[4], 0, 6); // 0=Sun
|
|
132
|
+
|
|
133
|
+
// Start from the next minute
|
|
134
|
+
const candidate = new Date(fromDate);
|
|
135
|
+
candidate.setSeconds(0, 0);
|
|
136
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
137
|
+
|
|
138
|
+
// Cap search at 366 days
|
|
139
|
+
const maxDate = new Date(fromDate.getTime() + 366 * 86400000);
|
|
140
|
+
|
|
141
|
+
while (candidate <= maxDate) {
|
|
142
|
+
const month = candidate.getMonth() + 1; // 1-indexed
|
|
143
|
+
const dayOfMonth = candidate.getDate();
|
|
144
|
+
const dayOfWeek = candidate.getDay(); // 0=Sun
|
|
145
|
+
const hour = candidate.getHours();
|
|
146
|
+
const minute = candidate.getMinutes();
|
|
147
|
+
|
|
148
|
+
// Check month
|
|
149
|
+
if (!months.includes(month)) {
|
|
150
|
+
// Skip to first day of next valid month
|
|
151
|
+
candidate.setMonth(candidate.getMonth() + 1, 1);
|
|
152
|
+
candidate.setHours(0, 0, 0, 0);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check day of month AND day of week
|
|
157
|
+
// Standard cron: if both are restricted (not *), either can match (OR logic).
|
|
158
|
+
// If only one is restricted, it must match.
|
|
159
|
+
const domRestricted = fields[2] !== '*';
|
|
160
|
+
const dowRestricted = fields[4] !== '*';
|
|
161
|
+
|
|
162
|
+
let dayMatch;
|
|
163
|
+
if (domRestricted && dowRestricted) {
|
|
164
|
+
dayMatch = daysOfMonth.includes(dayOfMonth) || daysOfWeek.includes(dayOfWeek);
|
|
165
|
+
} else if (domRestricted) {
|
|
166
|
+
dayMatch = daysOfMonth.includes(dayOfMonth);
|
|
167
|
+
} else if (dowRestricted) {
|
|
168
|
+
dayMatch = daysOfWeek.includes(dayOfWeek);
|
|
169
|
+
} else {
|
|
170
|
+
dayMatch = true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!dayMatch) {
|
|
174
|
+
// Skip to next day
|
|
175
|
+
candidate.setDate(candidate.getDate() + 1);
|
|
176
|
+
candidate.setHours(0, 0, 0, 0);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check hour
|
|
181
|
+
if (!hours.includes(hour)) {
|
|
182
|
+
// Skip to next hour
|
|
183
|
+
candidate.setHours(candidate.getHours() + 1, 0, 0, 0);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check minute
|
|
188
|
+
if (!minutes.includes(minute)) {
|
|
189
|
+
candidate.setMinutes(candidate.getMinutes() + 1, 0, 0);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// All fields match
|
|
194
|
+
return candidate;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return null; // No match within 366 days
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ==================== Next Run Computation ====================
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Compute the next run time for a schedule.
|
|
204
|
+
*
|
|
205
|
+
* @param {{ type: string, value: string }} schedule
|
|
206
|
+
* @param {Date} [fromDate] - Compute relative to this date (default: now)
|
|
207
|
+
* @returns {string|null} ISO string of next run, or null if expired
|
|
208
|
+
*/
|
|
209
|
+
export function computeNextRun(schedule, fromDate = new Date()) {
|
|
210
|
+
switch (schedule.type) {
|
|
211
|
+
case 'at': {
|
|
212
|
+
const target = new Date(schedule.value);
|
|
213
|
+
return target > fromDate ? target.toISOString() : null;
|
|
214
|
+
}
|
|
215
|
+
case 'every': {
|
|
216
|
+
const ms = parseInterval(schedule.value);
|
|
217
|
+
return new Date(fromDate.getTime() + ms).toISOString();
|
|
218
|
+
}
|
|
219
|
+
case 'cron': {
|
|
220
|
+
const next = getNextCronMatch(schedule.value, fromDate);
|
|
221
|
+
return next ? next.toISOString() : null;
|
|
222
|
+
}
|
|
223
|
+
default:
|
|
224
|
+
throw new Error(`Unknown schedule type: ${schedule.type}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ==================== Job Management ====================
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Add a new cron job.
|
|
232
|
+
*
|
|
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
|
|
238
|
+
*/
|
|
239
|
+
export function addJob(config) {
|
|
240
|
+
const { name, schedule, action } = config;
|
|
241
|
+
|
|
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');
|
|
245
|
+
|
|
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`);
|
|
250
|
+
}
|
|
251
|
+
|
|
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
|
+
}
|
|
269
|
+
|
|
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
|
+
);
|
|
281
|
+
|
|
282
|
+
if (idx === -1) return false;
|
|
283
|
+
|
|
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
|
+
*/
|
|
305
|
+
async function executeAction(job, logFn) {
|
|
306
|
+
const log = logFn || (() => {});
|
|
307
|
+
|
|
308
|
+
switch (job.action.type) {
|
|
309
|
+
case 'notify':
|
|
310
|
+
sendMacNotification('Push Cron', job.action.content || job.name);
|
|
311
|
+
log(`Cron "${job.name}": notification sent`);
|
|
312
|
+
break;
|
|
313
|
+
|
|
314
|
+
case 'create-todo':
|
|
315
|
+
// Phase 2: notification-based (no Supabase edge function yet)
|
|
316
|
+
sendMacNotification('Push: Scheduled Todo', job.action.content || job.name);
|
|
317
|
+
log(`Cron "${job.name}": todo reminder sent (notification)`);
|
|
318
|
+
break;
|
|
319
|
+
|
|
320
|
+
case 'queue-execution':
|
|
321
|
+
// Requires todoId — log warning if missing
|
|
322
|
+
if (!job.action.todoId) {
|
|
323
|
+
log(`Cron "${job.name}": queue-execution requires todoId, skipping`);
|
|
324
|
+
} else {
|
|
325
|
+
log(`Cron "${job.name}": queue-execution for todo ${job.action.todoId} (not yet implemented)`);
|
|
326
|
+
}
|
|
327
|
+
break;
|
|
328
|
+
|
|
329
|
+
default:
|
|
330
|
+
log(`Cron "${job.name}": unknown action type "${job.action.type}"`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Check for and run any due cron jobs.
|
|
336
|
+
* Called from daemon poll loop on every cycle.
|
|
337
|
+
*
|
|
338
|
+
* @param {Function} [logFn] - Optional log function
|
|
339
|
+
*/
|
|
340
|
+
export async function checkAndRunDueJobs(logFn) {
|
|
341
|
+
const jobs = loadJobs();
|
|
342
|
+
if (jobs.length === 0) return;
|
|
343
|
+
|
|
344
|
+
const now = new Date();
|
|
345
|
+
let modified = false;
|
|
346
|
+
|
|
347
|
+
for (const job of jobs) {
|
|
348
|
+
if (!job.enabled) continue;
|
|
349
|
+
if (!job.nextRunAt) continue;
|
|
350
|
+
|
|
351
|
+
const nextRun = new Date(job.nextRunAt);
|
|
352
|
+
if (nextRun > now) continue;
|
|
353
|
+
|
|
354
|
+
// Job is due — execute
|
|
355
|
+
try {
|
|
356
|
+
await executeAction(job, logFn);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (logFn) logFn(`Cron "${job.name}" execution failed: ${error.message}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Update timing
|
|
362
|
+
job.lastRunAt = now.toISOString();
|
|
363
|
+
|
|
364
|
+
if (job.schedule.type === 'at') {
|
|
365
|
+
// One-shot: disable after run
|
|
366
|
+
job.enabled = false;
|
|
367
|
+
job.nextRunAt = null;
|
|
368
|
+
} else {
|
|
369
|
+
// Recurring: compute next run
|
|
370
|
+
job.nextRunAt = computeNextRun(job.schedule, now);
|
|
371
|
+
if (!job.nextRunAt) {
|
|
372
|
+
job.enabled = false; // No more future runs
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
modified = true;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (modified) {
|
|
380
|
+
saveJobs(jobs);
|
|
381
|
+
}
|
|
382
|
+
}
|
package/lib/daemon.js
CHANGED
|
@@ -20,6 +20,10 @@ import { join, dirname } from 'path';
|
|
|
20
20
|
import { fileURLToPath } from 'url';
|
|
21
21
|
|
|
22
22
|
import { checkForUpdate, performUpdate } from './self-update.js';
|
|
23
|
+
import { getProjectContext, buildSmartPrompt, invalidateCache } from './context-engine.js';
|
|
24
|
+
import { sendMacNotification } from './utils/notify.js';
|
|
25
|
+
import { checkAndRunDueJobs } from './cron.js';
|
|
26
|
+
import { runHeartbeatChecks } from './heartbeat.js';
|
|
23
27
|
|
|
24
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
25
29
|
const __dirname = dirname(__filename);
|
|
@@ -193,26 +197,6 @@ function releaseLock() {
|
|
|
193
197
|
try { unlinkSync(LOCK_FILE); } catch {}
|
|
194
198
|
}
|
|
195
199
|
|
|
196
|
-
// ==================== Mac Notifications ====================
|
|
197
|
-
|
|
198
|
-
function sendMacNotification(title, message, sound = 'default') {
|
|
199
|
-
if (platform() !== 'darwin') return;
|
|
200
|
-
|
|
201
|
-
try {
|
|
202
|
-
const escapedTitle = title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
203
|
-
const escapedMessage = message.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
204
|
-
|
|
205
|
-
let script = `display notification "${escapedMessage}" with title "${escapedTitle}"`;
|
|
206
|
-
if (sound && sound !== 'default') {
|
|
207
|
-
script += ` sound name "${sound}"`;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
execSync(`osascript -e '${script}'`, { timeout: 5000, stdio: 'pipe' });
|
|
211
|
-
} catch {
|
|
212
|
-
// Non-critical
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
200
|
// ==================== Config ====================
|
|
217
201
|
|
|
218
202
|
function getApiKey() {
|
|
@@ -601,6 +585,42 @@ function getProjectPath(gitRemote, actionType) {
|
|
|
601
585
|
}
|
|
602
586
|
}
|
|
603
587
|
|
|
588
|
+
/**
|
|
589
|
+
* Get project info including path and action metadata.
|
|
590
|
+
* Like getProjectPath but also returns actionName for the prompt engine.
|
|
591
|
+
*/
|
|
592
|
+
function getProjectInfo(gitRemote, actionType) {
|
|
593
|
+
if (!existsSync(REGISTRY_FILE)) return null;
|
|
594
|
+
try {
|
|
595
|
+
const data = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
|
596
|
+
const projects = data.projects || {};
|
|
597
|
+
|
|
598
|
+
// Try exact composite key first (V2 format)
|
|
599
|
+
if (actionType) {
|
|
600
|
+
const key = `${gitRemote}::${actionType}`;
|
|
601
|
+
if (projects[key]) {
|
|
602
|
+
return {
|
|
603
|
+
path: projects[key].localPath || projects[key].local_path || null,
|
|
604
|
+
actionName: projects[key].actionName || null,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Fallback scan
|
|
610
|
+
for (const [key, info] of Object.entries(projects)) {
|
|
611
|
+
if ((info.gitRemote || key) === gitRemote) {
|
|
612
|
+
return {
|
|
613
|
+
path: info.localPath || info.local_path || null,
|
|
614
|
+
actionName: info.actionName || null,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
619
|
+
} catch {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
604
624
|
/**
|
|
605
625
|
* List all registered projects (backward-compatible: gitRemote -> localPath).
|
|
606
626
|
*/
|
|
@@ -1011,6 +1031,24 @@ async function markTaskAsCompleted(displayNumber, taskId, comment) {
|
|
|
1011
1031
|
}
|
|
1012
1032
|
}
|
|
1013
1033
|
|
|
1034
|
+
async function hasApprovedConfirmation(displayNumber) {
|
|
1035
|
+
try {
|
|
1036
|
+
const response = await apiRequest(`synced-todos?display_number=${displayNumber}`);
|
|
1037
|
+
if (!response.ok) return false;
|
|
1038
|
+
const data = await response.json();
|
|
1039
|
+
const todos = data.todos || [];
|
|
1040
|
+
if (todos.length === 0) return false;
|
|
1041
|
+
const events = parseJsonField(todos[0].executionEventsJson);
|
|
1042
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
1043
|
+
if (events[i].type === 'confirmation_approved') return true;
|
|
1044
|
+
if (events[i].type === 'confirmation_rejected') return false;
|
|
1045
|
+
}
|
|
1046
|
+
return false;
|
|
1047
|
+
} catch {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1014
1052
|
/**
|
|
1015
1053
|
* Auto-heal: detect if a previous execution already completed work for this task.
|
|
1016
1054
|
* Checks for existing branch commits and PRs to avoid redundant re-execution.
|
|
@@ -1342,15 +1380,18 @@ async function executeTask(task) {
|
|
|
1342
1380
|
// Extract UUID early — needed for claim and all status updates
|
|
1343
1381
|
const taskId = task.id || task.todo_id || '';
|
|
1344
1382
|
|
|
1345
|
-
// Get project path (use action_type for multi-agent routing)
|
|
1383
|
+
// Get project path + metadata (use action_type for multi-agent routing)
|
|
1346
1384
|
let projectPath = null;
|
|
1385
|
+
let actionName = null;
|
|
1347
1386
|
if (gitRemote) {
|
|
1348
|
-
|
|
1349
|
-
if (!
|
|
1387
|
+
const projectInfo = getProjectInfo(gitRemote, taskActionType);
|
|
1388
|
+
if (!projectInfo || !projectInfo.path) {
|
|
1350
1389
|
log(`Task #${displayNumber}: Project not registered: ${gitRemote}`);
|
|
1351
1390
|
log("Run '/push-todo connect' in the project directory to register");
|
|
1352
1391
|
return null;
|
|
1353
1392
|
}
|
|
1393
|
+
projectPath = projectInfo.path;
|
|
1394
|
+
actionName = projectInfo.actionName;
|
|
1354
1395
|
|
|
1355
1396
|
if (!existsSync(projectPath)) {
|
|
1356
1397
|
logError(`Task #${displayNumber}: Project path does not exist: ${projectPath}`);
|
|
@@ -1424,15 +1465,14 @@ async function executeTask(task) {
|
|
|
1424
1465
|
// Non-critical — continue without attachment context
|
|
1425
1466
|
}
|
|
1426
1467
|
|
|
1427
|
-
// Build prompt
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
3. When you're done, the SessionEnd hook will automatically report completion to Supabase.`;
|
|
1468
|
+
// Build context-rich prompt (skill scanning, project state, confirmation flags)
|
|
1469
|
+
if (projectPath) invalidateCache(projectPath); // Fresh context for each task
|
|
1470
|
+
const projectContext = projectPath ? getProjectContext(projectPath) : { skills: [], state: {} };
|
|
1471
|
+
const contextApp = task.contextApp || task.context_app || null;
|
|
1472
|
+
const prompt = buildSmartPrompt(
|
|
1473
|
+
{ displayNumber, content, attachmentContext, actionName, contextApp },
|
|
1474
|
+
projectContext
|
|
1475
|
+
);
|
|
1436
1476
|
|
|
1437
1477
|
// Note: claimTask() already set status to 'running' with atomic: true
|
|
1438
1478
|
// No duplicate status update needed here (was causing race conditions)
|
|
@@ -1653,15 +1693,36 @@ async function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1653
1693
|
}
|
|
1654
1694
|
}
|
|
1655
1695
|
|
|
1656
|
-
// Auto-complete task
|
|
1696
|
+
// Auto-complete task (configurable, default ON)
|
|
1657
1697
|
const taskId = info.taskId;
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1698
|
+
let autoCompleted = false;
|
|
1699
|
+
|
|
1700
|
+
if (getAutoCompleteEnabled() && taskId) {
|
|
1701
|
+
// Path 1: PR merge (code tasks)
|
|
1702
|
+
if (merged) {
|
|
1703
|
+
const comment = semanticSummary
|
|
1704
|
+
? `${semanticSummary} (${durationStr} on ${machineName})`
|
|
1705
|
+
: `Completed in ${durationStr} on ${machineName}`;
|
|
1706
|
+
autoCompleted = await markTaskAsCompleted(displayNumber, taskId, comment);
|
|
1707
|
+
if (!autoCompleted) {
|
|
1708
|
+
logError(`Task #${displayNumber}: Failed to mark as completed after merge`);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// Path 2: Confirmation approval (content tasks — tweets, emails, etc.)
|
|
1713
|
+
// If user approved a confirmation AND Claude exited cleanly, the task is done.
|
|
1714
|
+
if (!autoCompleted && !merged) {
|
|
1715
|
+
const approved = await hasApprovedConfirmation(displayNumber);
|
|
1716
|
+
if (approved) {
|
|
1717
|
+
log(`Task #${displayNumber}: user-approved confirmation detected, auto-completing`);
|
|
1718
|
+
const comment = semanticSummary
|
|
1719
|
+
? `${semanticSummary} (${durationStr} on ${machineName})`
|
|
1720
|
+
: `Confirmed and completed in ${durationStr} on ${machineName}`;
|
|
1721
|
+
autoCompleted = await markTaskAsCompleted(displayNumber, taskId, comment);
|
|
1722
|
+
if (!autoCompleted) {
|
|
1723
|
+
logError(`Task #${displayNumber}: Failed to mark as completed after confirmation`);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1665
1726
|
}
|
|
1666
1727
|
}
|
|
1667
1728
|
|
|
@@ -1670,7 +1731,7 @@ async function handleTaskCompletion(displayNumber, exitCode) {
|
|
|
1670
1731
|
summary,
|
|
1671
1732
|
completedAt: new Date().toISOString(),
|
|
1672
1733
|
duration,
|
|
1673
|
-
status:
|
|
1734
|
+
status: autoCompleted ? 'completed' : 'session_finished',
|
|
1674
1735
|
prUrl,
|
|
1675
1736
|
sessionId
|
|
1676
1737
|
});
|
|
@@ -2066,6 +2127,25 @@ async function mainLoop() {
|
|
|
2066
2127
|
await checkTimeouts();
|
|
2067
2128
|
await pollAndExecute();
|
|
2068
2129
|
|
|
2130
|
+
// Cron jobs (check every poll cycle, execution throttled by nextRunAt)
|
|
2131
|
+
try {
|
|
2132
|
+
await checkAndRunDueJobs(log);
|
|
2133
|
+
} catch (error) {
|
|
2134
|
+
logError(`Cron check error: ${error.message}`);
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// Heartbeat checks (internally throttled: 10min fast, 1hr slow)
|
|
2138
|
+
try {
|
|
2139
|
+
const projects = getListedProjects();
|
|
2140
|
+
await runHeartbeatChecks({
|
|
2141
|
+
projectPaths: Object.values(projects),
|
|
2142
|
+
apiRequest,
|
|
2143
|
+
log,
|
|
2144
|
+
});
|
|
2145
|
+
} catch (error) {
|
|
2146
|
+
logError(`Heartbeat error: ${error.message}`);
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2069
2149
|
// Self-update check (throttled to once per hour, only applies when idle)
|
|
2070
2150
|
if (getAutoUpdateEnabled()) {
|
|
2071
2151
|
checkAndApplyUpdate();
|
package/lib/heartbeat.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat health checks for Push daemon.
|
|
3
|
+
*
|
|
4
|
+
* Tier 1 checks: deterministic, no LLM, fast, no cost.
|
|
5
|
+
* Runs on configurable intervals from the daemon poll loop.
|
|
6
|
+
*
|
|
7
|
+
* Architecture: docs/20260214_push_daemon_evolution_complete_architecture.md §23
|
|
8
|
+
* Pattern: Follow self-update.js — pure functions, non-fatal.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
import { sendMacNotification } from './utils/notify.js';
|
|
13
|
+
|
|
14
|
+
// ==================== Throttle Configuration ====================
|
|
15
|
+
|
|
16
|
+
const FAST_CHECK_INTERVAL = 600000; // 10 minutes
|
|
17
|
+
const SLOW_CHECK_INTERVAL = 3600000; // 1 hour
|
|
18
|
+
|
|
19
|
+
// ==================== Thresholds ====================
|
|
20
|
+
|
|
21
|
+
const CONFIRMATION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
22
|
+
const QUEUED_TASK_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
23
|
+
const STALE_PR_HOURS = 48;
|
|
24
|
+
|
|
25
|
+
// ==================== Internal State ====================
|
|
26
|
+
|
|
27
|
+
let lastFastCheck = 0;
|
|
28
|
+
let lastSlowCheck = 0;
|
|
29
|
+
|
|
30
|
+
// Track already-notified items to avoid repeat alerts
|
|
31
|
+
const notifiedItems = new Map(); // key -> timestamp (auto-expire after 24h)
|
|
32
|
+
const NOTIFICATION_COOLDOWN = 24 * 60 * 60 * 1000; // 24 hours
|
|
33
|
+
|
|
34
|
+
function shouldNotify(key) {
|
|
35
|
+
const last = notifiedItems.get(key);
|
|
36
|
+
if (last && (Date.now() - last) < NOTIFICATION_COOLDOWN) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
notifiedItems.set(key, Date.now());
|
|
40
|
+
|
|
41
|
+
// Prune old entries
|
|
42
|
+
if (notifiedItems.size > 200) {
|
|
43
|
+
const cutoff = Date.now() - NOTIFICATION_COOLDOWN;
|
|
44
|
+
for (const [k, t] of notifiedItems) {
|
|
45
|
+
if (t < cutoff) notifiedItems.delete(k);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ==================== Fast Checks (every 10 min) ====================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check for tasks stuck in awaiting_confirmation.
|
|
56
|
+
*
|
|
57
|
+
* @param {Function} apiRequest - Daemon's authenticated API request function
|
|
58
|
+
* @param {Function} log - Log function
|
|
59
|
+
*/
|
|
60
|
+
async function checkConfirmationTimeouts(apiRequest, log) {
|
|
61
|
+
try {
|
|
62
|
+
const params = new URLSearchParams();
|
|
63
|
+
params.set('execution_status', 'awaiting_confirmation');
|
|
64
|
+
const response = await apiRequest(`synced-todos?${params}`);
|
|
65
|
+
if (!response.ok) return;
|
|
66
|
+
|
|
67
|
+
const data = await response.json();
|
|
68
|
+
const tasks = data.todos || [];
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
|
|
71
|
+
for (const task of tasks) {
|
|
72
|
+
const events = task.executionEvents || task.execution_events || [];
|
|
73
|
+
const confirmEvent = events.find(e =>
|
|
74
|
+
e.type === 'confirmation_needed' || e.type === 'awaiting_confirmation'
|
|
75
|
+
);
|
|
76
|
+
if (!confirmEvent?.timestamp) continue;
|
|
77
|
+
|
|
78
|
+
const elapsed = now - new Date(confirmEvent.timestamp).getTime();
|
|
79
|
+
if (elapsed > CONFIRMATION_TIMEOUT_MS) {
|
|
80
|
+
const dn = task.displayNumber || task.display_number;
|
|
81
|
+
const minutes = Math.round(elapsed / 60000);
|
|
82
|
+
const key = `confirm-timeout-${dn}`;
|
|
83
|
+
|
|
84
|
+
if (shouldNotify(key)) {
|
|
85
|
+
sendMacNotification(
|
|
86
|
+
'Push: Confirmation Waiting',
|
|
87
|
+
`Task #${dn} has been awaiting confirmation for ${minutes} minutes`,
|
|
88
|
+
'Basso'
|
|
89
|
+
);
|
|
90
|
+
log(`Heartbeat: Task #${dn} confirmation timeout (${minutes}min)`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
log(`Heartbeat: confirmation timeout check failed: ${error.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check for tasks queued for too long.
|
|
101
|
+
*
|
|
102
|
+
* @param {Function} apiRequest - Daemon's authenticated API request function
|
|
103
|
+
* @param {Function} log - Log function
|
|
104
|
+
*/
|
|
105
|
+
async function checkQueuedTaskAge(apiRequest, log) {
|
|
106
|
+
try {
|
|
107
|
+
const params = new URLSearchParams();
|
|
108
|
+
params.set('execution_status', 'queued');
|
|
109
|
+
const response = await apiRequest(`synced-todos?${params}`);
|
|
110
|
+
if (!response.ok) return;
|
|
111
|
+
|
|
112
|
+
const data = await response.json();
|
|
113
|
+
const tasks = data.todos || [];
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
|
|
116
|
+
for (const task of tasks) {
|
|
117
|
+
// Use the first "queued" event timestamp, or fall back to modified_at
|
|
118
|
+
const events = task.executionEvents || task.execution_events || [];
|
|
119
|
+
const queuedEvent = events.find(e => e.type === 'queued' || e.type === 'requeued');
|
|
120
|
+
const queuedAt = queuedEvent?.timestamp || task.modified_at || task.modifiedAt;
|
|
121
|
+
if (!queuedAt) continue;
|
|
122
|
+
|
|
123
|
+
const elapsed = now - new Date(queuedAt).getTime();
|
|
124
|
+
if (elapsed > QUEUED_TASK_TIMEOUT_MS) {
|
|
125
|
+
const dn = task.displayNumber || task.display_number;
|
|
126
|
+
const hours = Math.round(elapsed / 3600000);
|
|
127
|
+
const key = `queued-age-${dn}`;
|
|
128
|
+
|
|
129
|
+
if (shouldNotify(key)) {
|
|
130
|
+
sendMacNotification(
|
|
131
|
+
'Push: Stale Queued Task',
|
|
132
|
+
`Task #${dn} has been queued for ${hours} hours`,
|
|
133
|
+
'Basso'
|
|
134
|
+
);
|
|
135
|
+
log(`Heartbeat: Task #${dn} queued for ${hours}h`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
log(`Heartbeat: queued task age check failed: ${error.message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ==================== Slow Checks (every 1 hour) ====================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check for stale PRs across registered projects.
|
|
148
|
+
*
|
|
149
|
+
* @param {string[]} projectPaths - Registered project local paths
|
|
150
|
+
* @param {Function} log - Log function
|
|
151
|
+
*/
|
|
152
|
+
function checkStalePRs(projectPaths, log) {
|
|
153
|
+
for (const projectPath of projectPaths) {
|
|
154
|
+
try {
|
|
155
|
+
const output = execFileSync('gh', [
|
|
156
|
+
'pr', 'list',
|
|
157
|
+
'--json', 'number,title,updatedAt',
|
|
158
|
+
'--limit', '10',
|
|
159
|
+
], {
|
|
160
|
+
cwd: projectPath,
|
|
161
|
+
timeout: 15000,
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
164
|
+
}).trim();
|
|
165
|
+
|
|
166
|
+
if (!output) continue;
|
|
167
|
+
|
|
168
|
+
const prs = JSON.parse(output);
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const threshold = STALE_PR_HOURS * 3600000;
|
|
171
|
+
|
|
172
|
+
for (const pr of prs) {
|
|
173
|
+
if (!pr.updatedAt) continue;
|
|
174
|
+
const age = now - new Date(pr.updatedAt).getTime();
|
|
175
|
+
|
|
176
|
+
if (age > threshold) {
|
|
177
|
+
const hours = Math.round(age / 3600000);
|
|
178
|
+
const key = `stale-pr-${pr.number}`;
|
|
179
|
+
|
|
180
|
+
if (shouldNotify(key)) {
|
|
181
|
+
sendMacNotification(
|
|
182
|
+
'Push: Stale PR',
|
|
183
|
+
`PR #${pr.number} "${pr.title}" has been open ${hours}h with no activity`
|
|
184
|
+
);
|
|
185
|
+
log(`Heartbeat: Stale PR #${pr.number} (${hours}h)`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// gh not available or not authenticated for this project — skip
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Check for CI failures on recent PRs.
|
|
197
|
+
*
|
|
198
|
+
* @param {string[]} projectPaths - Registered project local paths
|
|
199
|
+
* @param {Function} log - Log function
|
|
200
|
+
*/
|
|
201
|
+
function checkCIFailures(projectPaths, log) {
|
|
202
|
+
for (const projectPath of projectPaths) {
|
|
203
|
+
try {
|
|
204
|
+
// Get recent PRs
|
|
205
|
+
const prOutput = execFileSync('gh', [
|
|
206
|
+
'pr', 'list',
|
|
207
|
+
'--json', 'number,headRefName',
|
|
208
|
+
'--limit', '3',
|
|
209
|
+
], {
|
|
210
|
+
cwd: projectPath,
|
|
211
|
+
timeout: 10000,
|
|
212
|
+
encoding: 'utf8',
|
|
213
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
214
|
+
}).trim();
|
|
215
|
+
|
|
216
|
+
if (!prOutput) continue;
|
|
217
|
+
const prs = JSON.parse(prOutput);
|
|
218
|
+
|
|
219
|
+
for (const pr of prs) {
|
|
220
|
+
try {
|
|
221
|
+
const checksOutput = execFileSync('gh', [
|
|
222
|
+
'pr', 'checks', String(pr.number),
|
|
223
|
+
'--json', 'name,state',
|
|
224
|
+
], {
|
|
225
|
+
cwd: projectPath,
|
|
226
|
+
timeout: 10000,
|
|
227
|
+
encoding: 'utf8',
|
|
228
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
229
|
+
}).trim();
|
|
230
|
+
|
|
231
|
+
if (!checksOutput) continue;
|
|
232
|
+
const checks = JSON.parse(checksOutput);
|
|
233
|
+
const failures = checks.filter(c => c.state === 'FAILURE');
|
|
234
|
+
|
|
235
|
+
if (failures.length > 0) {
|
|
236
|
+
const key = `ci-fail-${pr.number}`;
|
|
237
|
+
if (shouldNotify(key)) {
|
|
238
|
+
const names = failures.map(f => f.name).join(', ');
|
|
239
|
+
sendMacNotification(
|
|
240
|
+
'Push: CI Failure',
|
|
241
|
+
`PR #${pr.number}: ${names} failed`,
|
|
242
|
+
'Glass'
|
|
243
|
+
);
|
|
244
|
+
log(`Heartbeat: CI failure on PR #${pr.number}: ${names}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// Individual PR check failed — skip
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
// gh not available — skip
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ==================== Main Entry ====================
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Run heartbeat checks (internally throttled).
|
|
261
|
+
*
|
|
262
|
+
* @param {Object} config
|
|
263
|
+
* @param {string[]} config.projectPaths - Registered project local paths
|
|
264
|
+
* @param {Function} config.apiRequest - Daemon's authenticated API request function
|
|
265
|
+
* @param {Function} config.log - Daemon's log function
|
|
266
|
+
*/
|
|
267
|
+
export async function runHeartbeatChecks(config) {
|
|
268
|
+
const { projectPaths, apiRequest, log } = config;
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
|
|
271
|
+
// Fast checks (every 10 min)
|
|
272
|
+
if (now - lastFastCheck >= FAST_CHECK_INTERVAL) {
|
|
273
|
+
lastFastCheck = now;
|
|
274
|
+
|
|
275
|
+
await checkConfirmationTimeouts(apiRequest, log);
|
|
276
|
+
await checkQueuedTaskAge(apiRequest, log);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Slow checks (every 1 hour)
|
|
280
|
+
if (now - lastSlowCheck >= SLOW_CHECK_INTERVAL) {
|
|
281
|
+
lastSlowCheck = now;
|
|
282
|
+
|
|
283
|
+
checkStalePRs(projectPaths, log);
|
|
284
|
+
checkCIFailures(projectPaths, log);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mac notification utility for Push daemon.
|
|
3
|
+
* Shared by daemon.js, cron.js, and heartbeat.js.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
7
|
+
import { platform } from 'os';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Send a macOS notification via osascript.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} title - Notification title
|
|
13
|
+
* @param {string} message - Notification body
|
|
14
|
+
* @param {string} [sound='default'] - Sound name ('default', 'Basso', 'Glass', etc.)
|
|
15
|
+
*/
|
|
16
|
+
export function sendMacNotification(title, message, sound = 'default') {
|
|
17
|
+
if (platform() !== 'darwin') return;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const escapedTitle = title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
21
|
+
const escapedMessage = message.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
22
|
+
|
|
23
|
+
let script = `display notification "${escapedMessage}" with title "${escapedTitle}"`;
|
|
24
|
+
if (sound && sound !== 'default') {
|
|
25
|
+
script += ` sound name "${sound}"`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
execFileSync('osascript', ['-e', script], { timeout: 5000, stdio: 'pipe' });
|
|
29
|
+
} catch {
|
|
30
|
+
// Non-critical
|
|
31
|
+
}
|
|
32
|
+
}
|