@masslessai/push-todo 4.0.2 → 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 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
  */
@@ -1360,15 +1380,18 @@ async function executeTask(task) {
1360
1380
  // Extract UUID early — needed for claim and all status updates
1361
1381
  const taskId = task.id || task.todo_id || '';
1362
1382
 
1363
- // Get project path (use action_type for multi-agent routing)
1383
+ // Get project path + metadata (use action_type for multi-agent routing)
1364
1384
  let projectPath = null;
1385
+ let actionName = null;
1365
1386
  if (gitRemote) {
1366
- projectPath = getProjectPath(gitRemote, taskActionType);
1367
- if (!projectPath) {
1387
+ const projectInfo = getProjectInfo(gitRemote, taskActionType);
1388
+ if (!projectInfo || !projectInfo.path) {
1368
1389
  log(`Task #${displayNumber}: Project not registered: ${gitRemote}`);
1369
1390
  log("Run '/push-todo connect' in the project directory to register");
1370
1391
  return null;
1371
1392
  }
1393
+ projectPath = projectInfo.path;
1394
+ actionName = projectInfo.actionName;
1372
1395
 
1373
1396
  if (!existsSync(projectPath)) {
1374
1397
  logError(`Task #${displayNumber}: Project path does not exist: ${projectPath}`);
@@ -1442,15 +1465,14 @@ async function executeTask(task) {
1442
1465
  // Non-critical — continue without attachment context
1443
1466
  }
1444
1467
 
1445
- // Build prompt
1446
- const prompt = `Work on Push task #${displayNumber}:
1447
-
1448
- ${content}${attachmentContext}
1449
-
1450
- IMPORTANT:
1451
- 1. If you need to understand the codebase, start by reading the CLAUDE.md file if it exists.
1452
- 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.
1453
- 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
+ );
1454
1476
 
1455
1477
  // Note: claimTask() already set status to 'running' with atomic: true
1456
1478
  // No duplicate status update needed here (was causing race conditions)
@@ -2105,6 +2127,25 @@ async function mainLoop() {
2105
2127
  await checkTimeouts();
2106
2128
  await pollAndExecute();
2107
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
+
2108
2149
  // Self-update check (throttled to once per hour, only applies when idle)
2109
2150
  if (getAutoUpdateEnabled()) {
2110
2151
  checkAndApplyUpdate();
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.0.2",
3
+ "version": "4.0.3",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {