@masslessai/push-todo 4.5.2 → 4.5.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
@@ -17,7 +17,7 @@ import { showSettings, toggleSetting, setMaxBatchSize } from './config.js';
17
17
  import { ensureDaemonRunning, getDaemonStatus, startDaemon, stopDaemon } from './daemon-health.js';
18
18
  import { install as installLaunchAgent, uninstall as uninstallLaunchAgent, getStatus as getLaunchAgentStatus } from './launchagent.js';
19
19
  import { getScreenshotPath, screenshotExists, openScreenshot } from './utils/screenshots.js';
20
- import { bold, red, cyan, dim, green } from './utils/colors.js';
20
+ import { bold, red, cyan, dim, green, yellow } from './utils/colors.js';
21
21
  import { getMachineId } from './machine-id.js';
22
22
  import { parseReminder } from './reminder-parser.js';
23
23
 
@@ -139,6 +139,12 @@ ${bold('SCHEDULE (remote scheduled jobs):')}
139
139
  push-todo schedule enable <id> Enable a schedule
140
140
  push-todo schedule disable <id> Disable a schedule
141
141
 
142
+ ${bold('JOURNAL (daily automated journal):')}
143
+ push-todo journal setup Install daily journal cron (default: 9pm)
144
+ --time <HH:MM> Time to run daily (e.g., "21:00", "09:30")
145
+ push-todo journal status Show journal cron status
146
+ push-todo journal remove Uninstall journal cron
147
+
142
148
  ${bold('SETTINGS:')}
143
149
  push-todo setting Show all settings
144
150
  push-todo setting auto-commit Toggle auto-commit
@@ -212,6 +218,8 @@ const options = {
212
218
  'check-messages': { type: 'string' },
213
219
  'request-input': { type: 'string' },
214
220
  'question': { type: 'string' },
221
+ // Journal cron options
222
+ 'time': { type: 'string' },
215
223
  };
216
224
 
217
225
  /**
@@ -970,6 +978,63 @@ ${bold('Examples:')}
970
978
  return;
971
979
  }
972
980
 
981
+ // Journal cron command
982
+ if (command === 'journal') {
983
+ const { install, uninstall, getStatus, parseTime, formatTime, DEFAULT_HOUR, DEFAULT_MINUTE } = await import('./journal-cron.js');
984
+ const subCommand = positionals[1] || 'setup';
985
+
986
+ if (subCommand === 'setup') {
987
+ let hour = DEFAULT_HOUR;
988
+ let minute = DEFAULT_MINUTE;
989
+ if (values.time) {
990
+ try {
991
+ ({ hour, minute } = parseTime(values.time));
992
+ } catch (error) {
993
+ console.error(red(error.message));
994
+ process.exit(1);
995
+ }
996
+ }
997
+ const result = install({ hour, minute });
998
+ if (result.success) {
999
+ console.log(green(result.message));
1000
+ console.log(dim(`Journal entries will be written to ~/journal/`));
1001
+ console.log(dim(`Logs: ~/.push/journal.log`));
1002
+ } else {
1003
+ console.error(red(result.message));
1004
+ process.exit(1);
1005
+ }
1006
+ return;
1007
+ }
1008
+
1009
+ if (subCommand === 'status') {
1010
+ const status = getStatus();
1011
+ if (!status.installed) {
1012
+ console.log(dim('Journal cron not installed. Run: push-todo journal setup'));
1013
+ return;
1014
+ }
1015
+ const timeStr = typeof status.hour === 'number' ? formatTime(status.hour, status.minute) : 'unknown';
1016
+ console.log(`Journal cron: ${status.loaded ? green('active') : yellow('installed (not loaded)')}`);
1017
+ console.log(` Runs daily at: ${bold(timeStr)}`);
1018
+ console.log(` Logs: ~/.push/journal.log`);
1019
+ return;
1020
+ }
1021
+
1022
+ if (subCommand === 'remove') {
1023
+ const result = uninstall();
1024
+ if (result.success) {
1025
+ console.log(green(result.message));
1026
+ } else {
1027
+ console.error(red(result.message));
1028
+ process.exit(1);
1029
+ }
1030
+ return;
1031
+ }
1032
+
1033
+ console.error(red(`Unknown journal subcommand: ${subCommand}`));
1034
+ console.log(dim('Usage: push-todo journal [setup|status|remove]'));
1035
+ process.exit(1);
1036
+ }
1037
+
973
1038
  // Connect command
974
1039
  if (command === 'connect') {
975
1040
  if (values.auto) {
@@ -111,8 +111,10 @@ export function scanProjectSkills(projectPath) {
111
111
  const { frontmatter, body } = parseFrontmatter(content);
112
112
 
113
113
  // Parse tools field: comma-separated list of tool names/patterns
114
- const tools = frontmatter.tools
115
- ? frontmatter.tools.split(',').map(t => t.trim()).filter(Boolean)
114
+ // Skills use "allowed-tools" in frontmatter, fall back to "tools" for compatibility
115
+ const toolsRaw = frontmatter['allowed-tools'] || frontmatter.tools;
116
+ const tools = toolsRaw
117
+ ? toolsRaw.split(',').map(t => t.trim()).filter(Boolean)
116
118
  : [];
117
119
 
118
120
  skills.push({
package/lib/daemon.js CHANGED
@@ -1621,7 +1621,9 @@ function respawnWithInjectedMessage(displayNumber) {
1621
1621
  'Bash(npm *)', 'Bash(npx *)', 'Bash(yarn *)',
1622
1622
  'Bash(python *)', 'Bash(python3 *)', 'Bash(pip *)', 'Bash(pip3 *)',
1623
1623
  'Bash(push-todo *)',
1624
- 'Task',
1624
+ 'Bash(bird *)', 'Bash(gh *)',
1625
+ 'Bash(redbook *)',
1626
+ 'Task', 'Agent',
1625
1627
  'WebSearch', 'WebFetch',
1626
1628
  'ToolSearch',
1627
1629
  ];
@@ -1943,8 +1945,15 @@ async function killIdleTasks() {
1943
1945
  if (!lastOutput) continue; // No output tracking yet — not idle, just starting
1944
1946
 
1945
1947
  const idleMs = now - lastOutput;
1946
- if (idleMs > IDLE_TIMEOUT_MS) {
1947
- log(`Task #${displayNumber} IDLE TIMEOUT: ${Math.floor(idleMs / 1000)}s since last output`);
1948
+
1949
+ // Agent subprocesses can run for minutes without producing stdout.
1950
+ // Use a longer timeout (3x) when the last known tool is Agent.
1951
+ const activity = taskActivityState.get(displayNumber) || {};
1952
+ const isAgentRunning = activity.currentTool === 'Agent';
1953
+ const effectiveTimeout = isAgentRunning ? IDLE_TIMEOUT_MS * 3 : IDLE_TIMEOUT_MS;
1954
+
1955
+ if (idleMs > effectiveTimeout) {
1956
+ log(`Task #${displayNumber} IDLE TIMEOUT: ${Math.floor(idleMs / 1000)}s since last output${isAgentRunning ? ' (Agent extended)' : ''}`);
1948
1957
  idleTimedOut.push(displayNumber);
1949
1958
  }
1950
1959
  }
@@ -2274,9 +2283,11 @@ async function executeTask(task) {
2274
2283
  parts.push('Links:\n' + links.map(l => ` - ${l.url}${l.title ? ` (${l.title})` : ''}`).join('\n'));
2275
2284
  }
2276
2285
  if (screenshots.length > 0) {
2277
- parts.push('Screenshots:\n' + screenshots.map(s =>
2278
- ` - ${s.imageFilename || s.image_filename}${s.sourceApp ? ` (from ${s.sourceApp})` : ''}`
2279
- ).join('\n'));
2286
+ const screenshotDir = join(homedir(), 'Library', 'Mobile Documents', 'iCloud~ai~massless~push', 'Documents', 'Screenshots');
2287
+ parts.push('Screenshots (use Read tool on these paths):\n' + screenshots.map(s => {
2288
+ const filename = s.imageFilename || s.image_filename;
2289
+ return ` - ${join(screenshotDir, filename)}${s.sourceApp ? ` (from ${s.sourceApp})` : ''}`;
2290
+ }).join('\n'));
2280
2291
  }
2281
2292
 
2282
2293
  if (parts.length > 0) {
@@ -2306,7 +2317,9 @@ async function executeTask(task) {
2306
2317
  'Bash(npm *)', 'Bash(npx *)', 'Bash(yarn *)',
2307
2318
  'Bash(python *)', 'Bash(python3 *)', 'Bash(pip *)', 'Bash(pip3 *)',
2308
2319
  'Bash(push-todo *)',
2309
- 'Task',
2320
+ 'Bash(bird *)', 'Bash(gh *)',
2321
+ 'Bash(redbook *)',
2322
+ 'Task', 'Agent',
2310
2323
  'WebSearch', 'WebFetch',
2311
2324
  'ToolSearch',
2312
2325
  ];
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Daily journal cron via LaunchAgent.
3
+ *
4
+ * Installs a user-level LaunchAgent that fires claude with /journal today
5
+ * at a configurable daily time. More reliable than Supabase remote schedules:
6
+ * - System-level: survives daemon restarts and Supabase outages
7
+ * - StartCalendarInterval: native macOS daily scheduling (no polling)
8
+ * - Writes to ~/journal/ in the user's home directory
9
+ *
10
+ * Usage:
11
+ * push-todo journal setup Install at 9pm (default)
12
+ * push-todo journal setup --time 21:30 Install at 9:30pm
13
+ * push-todo journal status Show status
14
+ * push-todo journal remove Uninstall
15
+ */
16
+
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
18
+ import { execFileSync } from 'child_process';
19
+ import { homedir } from 'os';
20
+ import { join } from 'path';
21
+
22
+ const LABEL = 'ai.massless.push.journal';
23
+ const LAUNCH_AGENTS_DIR = join(homedir(), 'Library', 'LaunchAgents');
24
+ const PLIST_PATH = join(LAUNCH_AGENTS_DIR, `${LABEL}.plist`);
25
+ const PUSH_DIR = join(homedir(), '.push');
26
+ const LOG_FILE = join(PUSH_DIR, 'journal.log');
27
+
28
+ // Default: 9pm daily
29
+ const DEFAULT_HOUR = 21;
30
+ const DEFAULT_MINUTE = 0;
31
+
32
+ /**
33
+ * Find the claude binary (real binary, not shell function).
34
+ * Tries ~/.local/bin/claude first, then PATH.
35
+ */
36
+ function findClaudeBinary() {
37
+ const candidates = [
38
+ join(homedir(), '.local', 'bin', 'claude'),
39
+ '/usr/local/bin/claude',
40
+ '/opt/homebrew/bin/claude',
41
+ ];
42
+ for (const p of candidates) {
43
+ if (existsSync(p)) return p;
44
+ }
45
+ // Fallback: search PATH via which
46
+ try {
47
+ const found = execFileSync('which', ['claude'], { encoding: 'utf8', timeout: 5000 }).trim();
48
+ if (found && existsSync(found)) return found;
49
+ } catch {}
50
+ return 'claude'; // last resort
51
+ }
52
+
53
+ /**
54
+ * Parse "HH:MM" string into { hour, minute }.
55
+ * Throws if format is invalid.
56
+ */
57
+ export function parseTime(timeStr) {
58
+ const match = timeStr.match(/^(\d{1,2}):(\d{2})$/);
59
+ if (!match) throw new Error(`Invalid time format: "${timeStr}". Use HH:MM (e.g., "21:00", "09:30")`);
60
+ const hour = parseInt(match[1], 10);
61
+ const minute = parseInt(match[2], 10);
62
+ if (hour < 0 || hour > 23) throw new Error(`Hour must be 0-23, got ${hour}`);
63
+ if (minute < 0 || minute > 59) throw new Error(`Minute must be 0-59, got ${minute}`);
64
+ return { hour, minute };
65
+ }
66
+
67
+ /**
68
+ * Generate the plist XML for the daily journal LaunchAgent.
69
+ */
70
+ function generatePlist(hour, minute) {
71
+ const claudeBin = findClaudeBinary();
72
+ const nodePath = join(homedir(), '.nvm', 'versions', 'node', 'v24.9.0', 'bin');
73
+
74
+ return `<?xml version="1.0" encoding="UTF-8"?>
75
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
76
+ <plist version="1.0">
77
+ <dict>
78
+ <key>Label</key>
79
+ <string>${LABEL}</string>
80
+
81
+ <key>ProgramArguments</key>
82
+ <array>
83
+ <string>${claudeBin}</string>
84
+ <string>-p</string>
85
+ <string>Run /journal today - generate today's journal entry summarizing the day's work, experiments, and learnings. Use the journal skill to write the entry to ~/journal/ with today's date.</string>
86
+ <string>--dangerously-skip-permissions</string>
87
+ <string>--allowedTools</string>
88
+ <string>Read,Write,Glob,Grep</string>
89
+ </array>
90
+
91
+ <key>EnvironmentVariables</key>
92
+ <dict>
93
+ <key>PATH</key>
94
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${nodePath}:${join(homedir(), '.local', 'bin')}</string>
95
+ <key>HOME</key>
96
+ <string>${homedir()}</string>
97
+ </dict>
98
+
99
+ <key>WorkingDirectory</key>
100
+ <string>${homedir()}</string>
101
+
102
+ <key>StartCalendarInterval</key>
103
+ <dict>
104
+ <key>Hour</key>
105
+ <integer>${hour}</integer>
106
+ <key>Minute</key>
107
+ <integer>${minute}</integer>
108
+ </dict>
109
+
110
+ <key>StandardOutPath</key>
111
+ <string>${LOG_FILE}</string>
112
+
113
+ <key>StandardErrorPath</key>
114
+ <string>${LOG_FILE}</string>
115
+ </dict>
116
+ </plist>
117
+ `;
118
+ }
119
+
120
+ /**
121
+ * Read the hour/minute from an installed plist.
122
+ * Returns null if not installed or can't parse.
123
+ */
124
+ function readInstalledTime() {
125
+ if (!existsSync(PLIST_PATH)) return null;
126
+ try {
127
+ const content = readFileSync(PLIST_PATH, 'utf8');
128
+ const hourMatch = content.match(/<key>Hour<\/key>\s*<integer>(\d+)<\/integer>/);
129
+ const minMatch = content.match(/<key>Minute<\/key>\s*<integer>(\d+)<\/integer>/);
130
+ if (hourMatch && minMatch) {
131
+ return { hour: parseInt(hourMatch[1], 10), minute: parseInt(minMatch[1], 10) };
132
+ }
133
+ } catch {}
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Format hour/minute as "HH:MM am/pm".
139
+ */
140
+ function formatTime(hour, minute) {
141
+ const suffix = hour >= 12 ? 'pm' : 'am';
142
+ const displayHour = hour % 12 || 12;
143
+ const displayMin = String(minute).padStart(2, '0');
144
+ return `${displayHour}:${displayMin}${suffix}`;
145
+ }
146
+
147
+ /**
148
+ * Check installation status.
149
+ * @returns {{ installed: boolean, loaded: boolean, hour?: number, minute?: number }}
150
+ */
151
+ export function getStatus() {
152
+ const installed = existsSync(PLIST_PATH);
153
+ let loaded = false;
154
+
155
+ if (installed) {
156
+ try {
157
+ execFileSync('launchctl', ['list', LABEL], {
158
+ timeout: 5000,
159
+ stdio: ['ignore', 'pipe', 'ignore'],
160
+ });
161
+ loaded = true;
162
+ } catch {}
163
+ }
164
+
165
+ const time = readInstalledTime();
166
+ return { installed, loaded, ...time };
167
+ }
168
+
169
+ /**
170
+ * Install (or update) the daily journal LaunchAgent.
171
+ *
172
+ * @param {{ hour?: number, minute?: number }} options
173
+ * @returns {{ success: boolean, message: string }}
174
+ */
175
+ export function install({ hour = DEFAULT_HOUR, minute = DEFAULT_MINUTE } = {}) {
176
+ try {
177
+ mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
178
+ mkdirSync(PUSH_DIR, { recursive: true });
179
+
180
+ const newContent = generatePlist(hour, minute);
181
+
182
+ // Unload existing if present (to apply time changes)
183
+ if (existsSync(PLIST_PATH)) {
184
+ try {
185
+ execFileSync('launchctl', ['unload', PLIST_PATH], {
186
+ timeout: 5000,
187
+ stdio: ['ignore', 'ignore', 'ignore'],
188
+ });
189
+ } catch {}
190
+ }
191
+
192
+ writeFileSync(PLIST_PATH, newContent);
193
+
194
+ execFileSync('launchctl', ['load', '-w', PLIST_PATH], {
195
+ timeout: 5000,
196
+ stdio: ['ignore', 'ignore', 'ignore'],
197
+ });
198
+
199
+ const timeStr = formatTime(hour, minute);
200
+ return {
201
+ success: true,
202
+ message: `Journal cron installed — runs daily at ${timeStr}`,
203
+ hour,
204
+ minute,
205
+ };
206
+ } catch (error) {
207
+ return { success: false, message: `Failed to install journal cron: ${error.message}` };
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Uninstall the journal LaunchAgent.
213
+ *
214
+ * @returns {{ success: boolean, message: string }}
215
+ */
216
+ export function uninstall() {
217
+ try {
218
+ if (!existsSync(PLIST_PATH)) {
219
+ return { success: true, message: 'Journal cron not installed (nothing to remove)' };
220
+ }
221
+
222
+ try {
223
+ execFileSync('launchctl', ['unload', PLIST_PATH], {
224
+ timeout: 5000,
225
+ stdio: ['ignore', 'ignore', 'ignore'],
226
+ });
227
+ } catch {}
228
+
229
+ unlinkSync(PLIST_PATH);
230
+ return { success: true, message: 'Journal cron removed' };
231
+ } catch (error) {
232
+ return { success: false, message: `Failed to remove journal cron: ${error.message}` };
233
+ }
234
+ }
235
+
236
+ export { PLIST_PATH, LABEL, LOG_FILE, DEFAULT_HOUR, DEFAULT_MINUTE, formatTime };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.5.2",
3
+ "version": "4.5.3",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {