@covibes/zeroshot 1.0.1 → 1.0.2
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/package.json +2 -1
- package/task-lib/attachable-watcher.js +202 -0
- package/task-lib/commands/clean.js +50 -0
- package/task-lib/commands/get-log-path.js +23 -0
- package/task-lib/commands/kill.js +32 -0
- package/task-lib/commands/list.js +105 -0
- package/task-lib/commands/logs.js +411 -0
- package/task-lib/commands/resume.js +41 -0
- package/task-lib/commands/run.js +48 -0
- package/task-lib/commands/schedule.js +105 -0
- package/task-lib/commands/scheduler-cmd.js +96 -0
- package/task-lib/commands/schedules.js +98 -0
- package/task-lib/commands/status.js +44 -0
- package/task-lib/commands/unschedule.js +16 -0
- package/task-lib/completion.js +9 -0
- package/task-lib/config.js +10 -0
- package/task-lib/name-generator.js +230 -0
- package/task-lib/package.json +3 -0
- package/task-lib/runner.js +123 -0
- package/task-lib/scheduler.js +252 -0
- package/task-lib/store.js +217 -0
- package/task-lib/tui/formatters.js +166 -0
- package/task-lib/tui/index.js +197 -0
- package/task-lib/tui/layout.js +111 -0
- package/task-lib/tui/renderer.js +119 -0
- package/task-lib/tui.js +384 -0
- package/task-lib/watcher.js +162 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scheduler daemon - runs as background process
|
|
5
|
+
* Checks for due scheduled tasks and spawns them
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, unlinkSync } from 'fs';
|
|
9
|
+
import { loadSchedules, updateSchedule } from './store.js';
|
|
10
|
+
import { spawnTask } from './runner.js';
|
|
11
|
+
import { SCHEDULER_PID_FILE, SCHEDULER_LOG } from './config.js';
|
|
12
|
+
|
|
13
|
+
const CHECK_INTERVAL = 60000; // 60 seconds
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse human-readable interval to milliseconds
|
|
17
|
+
* Supports: 30s, 5m, 2h, 1d, 1w
|
|
18
|
+
*/
|
|
19
|
+
export function parseInterval(str) {
|
|
20
|
+
const match = str.match(/^(\d+)(s|m|h|d|w)$/i);
|
|
21
|
+
if (!match) return null;
|
|
22
|
+
|
|
23
|
+
const value = parseInt(match[1], 10);
|
|
24
|
+
const unit = match[2].toLowerCase();
|
|
25
|
+
|
|
26
|
+
const multipliers = {
|
|
27
|
+
s: 1000,
|
|
28
|
+
m: 60 * 1000,
|
|
29
|
+
h: 60 * 60 * 1000,
|
|
30
|
+
d: 24 * 60 * 60 * 1000,
|
|
31
|
+
w: 7 * 24 * 60 * 60 * 1000,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return value * multipliers[unit];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse cron expression and get next run time
|
|
39
|
+
* Simple cron parser supporting: minute hour day month weekday
|
|
40
|
+
*/
|
|
41
|
+
export function getNextCronTime(cronExpr, fromDate = new Date()) {
|
|
42
|
+
const parts = cronExpr.trim().split(/\s+/);
|
|
43
|
+
if (parts.length !== 5) return null;
|
|
44
|
+
|
|
45
|
+
const [minute, hour] = parts;
|
|
46
|
+
|
|
47
|
+
// Simple implementation - just handle basic cases
|
|
48
|
+
// For full cron support, use cron-parser package
|
|
49
|
+
const next = new Date(fromDate);
|
|
50
|
+
next.setSeconds(0);
|
|
51
|
+
next.setMilliseconds(0);
|
|
52
|
+
|
|
53
|
+
// Handle simple cases
|
|
54
|
+
if (minute !== '*') {
|
|
55
|
+
const mins = minute.split(',').map((m) => parseInt(m, 10));
|
|
56
|
+
const currentMin = next.getMinutes();
|
|
57
|
+
const nextMin = mins.find((m) => m > currentMin) ?? mins[0];
|
|
58
|
+
if (nextMin <= currentMin) {
|
|
59
|
+
next.setHours(next.getHours() + 1);
|
|
60
|
+
}
|
|
61
|
+
next.setMinutes(nextMin);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (hour !== '*') {
|
|
65
|
+
const hours = hour.split(',').map((h) => parseInt(h, 10));
|
|
66
|
+
const currentHour = next.getHours();
|
|
67
|
+
const nextHour = hours.find((h) => h > currentHour) ?? hours[0];
|
|
68
|
+
if (nextHour <= currentHour && minute === '*') {
|
|
69
|
+
next.setDate(next.getDate() + 1);
|
|
70
|
+
}
|
|
71
|
+
if (nextHour !== currentHour) {
|
|
72
|
+
next.setMinutes(minute === '*' ? 0 : parseInt(minute, 10));
|
|
73
|
+
}
|
|
74
|
+
next.setHours(nextHour);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// For complex cron expressions, fall back to 1 hour from now
|
|
78
|
+
// Full implementation would need cron-parser
|
|
79
|
+
if (next <= fromDate) {
|
|
80
|
+
next.setTime(fromDate.getTime() + 60 * 60 * 1000);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Calculate next run time for a schedule
|
|
88
|
+
*/
|
|
89
|
+
export function calculateNextRun(schedule) {
|
|
90
|
+
if (schedule.interval) {
|
|
91
|
+
return new Date(Date.now() + schedule.interval);
|
|
92
|
+
}
|
|
93
|
+
if (schedule.cron) {
|
|
94
|
+
return getNextCronTime(schedule.cron);
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Log to scheduler log file
|
|
101
|
+
*/
|
|
102
|
+
function log(msg) {
|
|
103
|
+
const timestamp = new Date().toISOString();
|
|
104
|
+
const line = `[${timestamp}] ${msg}\n`;
|
|
105
|
+
appendFileSync(SCHEDULER_LOG, line);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check and run due schedules
|
|
110
|
+
*/
|
|
111
|
+
function checkSchedules() {
|
|
112
|
+
const schedules = loadSchedules();
|
|
113
|
+
const now = new Date();
|
|
114
|
+
|
|
115
|
+
for (const schedule of Object.values(schedules)) {
|
|
116
|
+
if (!schedule.enabled) continue;
|
|
117
|
+
|
|
118
|
+
const nextRun = new Date(schedule.nextRunAt);
|
|
119
|
+
if (nextRun > now) continue;
|
|
120
|
+
|
|
121
|
+
// Schedule is due - spawn task
|
|
122
|
+
log(`Running scheduled task: ${schedule.id} - "${schedule.prompt.slice(0, 50)}..."`);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const task = spawnTask(schedule.prompt, {
|
|
126
|
+
cwd: schedule.cwd,
|
|
127
|
+
scheduleId: schedule.id,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Update schedule with next run time
|
|
131
|
+
const nextRunAt = calculateNextRun(schedule);
|
|
132
|
+
updateSchedule(schedule.id, {
|
|
133
|
+
lastRunAt: now.toISOString(),
|
|
134
|
+
lastTaskId: task.id,
|
|
135
|
+
nextRunAt: nextRunAt ? nextRunAt.toISOString() : null,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
log(
|
|
139
|
+
`Spawned task ${task.id} for schedule ${schedule.id}, next run: ${nextRunAt?.toISOString() || 'none'}`
|
|
140
|
+
);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log(`Error spawning task for schedule ${schedule.id}: ${err.message}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Start the scheduler daemon
|
|
149
|
+
*/
|
|
150
|
+
export function startDaemon() {
|
|
151
|
+
// Check if already running
|
|
152
|
+
if (existsSync(SCHEDULER_PID_FILE)) {
|
|
153
|
+
const existingPid = parseInt(readFileSync(SCHEDULER_PID_FILE, 'utf-8').trim(), 10);
|
|
154
|
+
try {
|
|
155
|
+
process.kill(existingPid, 0);
|
|
156
|
+
console.log(`Scheduler already running (PID: ${existingPid})`);
|
|
157
|
+
return false;
|
|
158
|
+
} catch {
|
|
159
|
+
// Process not running, clean up stale PID file
|
|
160
|
+
unlinkSync(SCHEDULER_PID_FILE);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Write PID file
|
|
165
|
+
writeFileSync(SCHEDULER_PID_FILE, String(process.pid));
|
|
166
|
+
|
|
167
|
+
log(`Scheduler daemon started (PID: ${process.pid})`);
|
|
168
|
+
console.log(`Scheduler daemon started (PID: ${process.pid})`);
|
|
169
|
+
|
|
170
|
+
// Run check loop
|
|
171
|
+
const runLoop = async () => {
|
|
172
|
+
while (true) {
|
|
173
|
+
try {
|
|
174
|
+
await checkSchedules();
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// Log error with full stack trace - scheduler errors are critical bugs
|
|
177
|
+
const errorMsg = `SCHEDULER ERROR: ${err.message}\nStack: ${err.stack}`;
|
|
178
|
+
log(errorMsg);
|
|
179
|
+
console.error(errorMsg);
|
|
180
|
+
}
|
|
181
|
+
await new Promise((r) => setTimeout(r, CHECK_INTERVAL));
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Handle shutdown
|
|
186
|
+
process.on('SIGTERM', () => {
|
|
187
|
+
log('Scheduler daemon stopping (SIGTERM)');
|
|
188
|
+
if (existsSync(SCHEDULER_PID_FILE)) {
|
|
189
|
+
unlinkSync(SCHEDULER_PID_FILE);
|
|
190
|
+
}
|
|
191
|
+
process.exit(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
process.on('SIGINT', () => {
|
|
195
|
+
log('Scheduler daemon stopping (SIGINT)');
|
|
196
|
+
if (existsSync(SCHEDULER_PID_FILE)) {
|
|
197
|
+
unlinkSync(SCHEDULER_PID_FILE);
|
|
198
|
+
}
|
|
199
|
+
process.exit(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
runLoop();
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Stop the scheduler daemon
|
|
208
|
+
*/
|
|
209
|
+
export function stopDaemon() {
|
|
210
|
+
if (!existsSync(SCHEDULER_PID_FILE)) {
|
|
211
|
+
console.log('Scheduler is not running');
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const pid = parseInt(readFileSync(SCHEDULER_PID_FILE, 'utf-8').trim(), 10);
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
process.kill(pid, 'SIGTERM');
|
|
219
|
+
unlinkSync(SCHEDULER_PID_FILE);
|
|
220
|
+
console.log(`Scheduler stopped (PID: ${pid})`);
|
|
221
|
+
log(`Scheduler daemon stopped by user (PID: ${pid})`);
|
|
222
|
+
return true;
|
|
223
|
+
} catch {
|
|
224
|
+
// Process not running, clean up
|
|
225
|
+
unlinkSync(SCHEDULER_PID_FILE);
|
|
226
|
+
console.log('Scheduler was not running (cleaned up stale PID file)');
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get daemon status
|
|
233
|
+
*/
|
|
234
|
+
export function getDaemonStatus() {
|
|
235
|
+
if (!existsSync(SCHEDULER_PID_FILE)) {
|
|
236
|
+
return { running: false, pid: null };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const pid = parseInt(readFileSync(SCHEDULER_PID_FILE, 'utf-8').trim(), 10);
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
process.kill(pid, 0);
|
|
243
|
+
return { running: true, pid };
|
|
244
|
+
} catch {
|
|
245
|
+
return { running: false, pid: null, stale: true };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// If run directly, start daemon
|
|
250
|
+
if (process.argv[1]?.endsWith('scheduler.js')) {
|
|
251
|
+
startDaemon();
|
|
252
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { TASKS_DIR, TASKS_FILE, LOGS_DIR, SCHEDULES_FILE } from './config.js';
|
|
3
|
+
import { generateName } from './name-generator.js';
|
|
4
|
+
import lockfile from 'proper-lockfile';
|
|
5
|
+
|
|
6
|
+
// Lock options for sync API (no retries allowed)
|
|
7
|
+
const LOCK_OPTIONS = {
|
|
8
|
+
stale: 30000, // Consider lock stale after 30s
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Retry wrapper for sync lock acquisition
|
|
12
|
+
function lockWithRetry(file, options, maxRetries = 100, delayMs = 100) {
|
|
13
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
14
|
+
try {
|
|
15
|
+
return lockfile.lockSync(file, options);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err.code === 'ELOCKED' && i < maxRetries - 1) {
|
|
18
|
+
// File is locked, wait and retry
|
|
19
|
+
const start = Date.now();
|
|
20
|
+
while (Date.now() - start < delayMs) {
|
|
21
|
+
// Busy wait (sync)
|
|
22
|
+
}
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Failed to acquire lock after ${maxRetries} retries`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ensureDirs() {
|
|
32
|
+
if (!existsSync(TASKS_DIR)) mkdirSync(TASKS_DIR, { recursive: true });
|
|
33
|
+
if (!existsSync(LOGS_DIR)) mkdirSync(LOGS_DIR, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read tasks.json (no locking - use for read-only operations)
|
|
38
|
+
*/
|
|
39
|
+
export function loadTasks() {
|
|
40
|
+
ensureDirs();
|
|
41
|
+
if (!existsSync(TASKS_FILE)) return {};
|
|
42
|
+
const content = readFileSync(TASKS_FILE, 'utf-8');
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(content);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`CRITICAL: tasks.json is corrupted and cannot be parsed. Error: ${error.message}. Content: ${content.slice(0, 200)}...`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Write tasks.json (no locking - internal use only)
|
|
54
|
+
*/
|
|
55
|
+
export function saveTasks(tasks) {
|
|
56
|
+
ensureDirs();
|
|
57
|
+
writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Atomic read-modify-write with file locking
|
|
62
|
+
* @param {Function} modifier - Function that receives tasks object and returns modified tasks
|
|
63
|
+
* @returns {any} - Return value from modifier function
|
|
64
|
+
*/
|
|
65
|
+
export function withTasksLock(modifier) {
|
|
66
|
+
ensureDirs();
|
|
67
|
+
|
|
68
|
+
// Create file if it doesn't exist (needed for locking)
|
|
69
|
+
if (!existsSync(TASKS_FILE)) {
|
|
70
|
+
writeFileSync(TASKS_FILE, '{}');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let release;
|
|
74
|
+
try {
|
|
75
|
+
// Acquire lock (blocks until available)
|
|
76
|
+
release = lockWithRetry(TASKS_FILE, LOCK_OPTIONS);
|
|
77
|
+
|
|
78
|
+
// Read current state
|
|
79
|
+
const content = readFileSync(TASKS_FILE, 'utf-8');
|
|
80
|
+
let tasks;
|
|
81
|
+
try {
|
|
82
|
+
tasks = JSON.parse(content);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw new Error(`CRITICAL: tasks.json is corrupted. Error: ${error.message}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Apply modification
|
|
88
|
+
const result = modifier(tasks);
|
|
89
|
+
|
|
90
|
+
// Write back
|
|
91
|
+
writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
} finally {
|
|
95
|
+
if (release) {
|
|
96
|
+
release();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getTask(id) {
|
|
102
|
+
const tasks = loadTasks();
|
|
103
|
+
return tasks[id];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function updateTask(id, updates) {
|
|
107
|
+
return withTasksLock((tasks) => {
|
|
108
|
+
if (!tasks[id]) return null;
|
|
109
|
+
tasks[id] = {
|
|
110
|
+
...tasks[id],
|
|
111
|
+
...updates,
|
|
112
|
+
updatedAt: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
return tasks[id];
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function addTask(task) {
|
|
119
|
+
return withTasksLock((tasks) => {
|
|
120
|
+
tasks[task.id] = task;
|
|
121
|
+
return task;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function removeTask(id) {
|
|
126
|
+
withTasksLock((tasks) => {
|
|
127
|
+
delete tasks[id];
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function generateId() {
|
|
132
|
+
return generateName('task');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function generateScheduleId() {
|
|
136
|
+
return generateName('sched');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Schedule management - same pattern with locking
|
|
140
|
+
|
|
141
|
+
function withSchedulesLock(modifier) {
|
|
142
|
+
ensureDirs();
|
|
143
|
+
|
|
144
|
+
if (!existsSync(SCHEDULES_FILE)) {
|
|
145
|
+
writeFileSync(SCHEDULES_FILE, '{}');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let release;
|
|
149
|
+
try {
|
|
150
|
+
release = lockWithRetry(SCHEDULES_FILE, LOCK_OPTIONS);
|
|
151
|
+
|
|
152
|
+
const content = readFileSync(SCHEDULES_FILE, 'utf-8');
|
|
153
|
+
let schedules;
|
|
154
|
+
try {
|
|
155
|
+
schedules = JSON.parse(content);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw new Error(`CRITICAL: schedules.json is corrupted. Error: ${error.message}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const result = modifier(schedules);
|
|
161
|
+
writeFileSync(SCHEDULES_FILE, JSON.stringify(schedules, null, 2));
|
|
162
|
+
|
|
163
|
+
return result;
|
|
164
|
+
} finally {
|
|
165
|
+
if (release) {
|
|
166
|
+
release();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function loadSchedules() {
|
|
172
|
+
ensureDirs();
|
|
173
|
+
if (!existsSync(SCHEDULES_FILE)) return {};
|
|
174
|
+
const content = readFileSync(SCHEDULES_FILE, 'utf-8');
|
|
175
|
+
try {
|
|
176
|
+
return JSON.parse(content);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`CRITICAL: schedules.json is corrupted and cannot be parsed. Error: ${error.message}. Content: ${content.slice(0, 200)}...`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function saveSchedules(schedules) {
|
|
185
|
+
ensureDirs();
|
|
186
|
+
writeFileSync(SCHEDULES_FILE, JSON.stringify(schedules, null, 2));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function getSchedule(id) {
|
|
190
|
+
const schedules = loadSchedules();
|
|
191
|
+
return schedules[id];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function addSchedule(schedule) {
|
|
195
|
+
return withSchedulesLock((schedules) => {
|
|
196
|
+
schedules[schedule.id] = schedule;
|
|
197
|
+
return schedule;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function updateSchedule(id, updates) {
|
|
202
|
+
return withSchedulesLock((schedules) => {
|
|
203
|
+
if (!schedules[id]) return null;
|
|
204
|
+
schedules[id] = {
|
|
205
|
+
...schedules[id],
|
|
206
|
+
...updates,
|
|
207
|
+
updatedAt: new Date().toISOString(),
|
|
208
|
+
};
|
|
209
|
+
return schedules[id];
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function removeSchedule(id) {
|
|
214
|
+
withSchedulesLock((schedules) => {
|
|
215
|
+
delete schedules[id];
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting utilities for TUI display
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format timestamp as human-readable relative time
|
|
7
|
+
* @param {number} ms - Milliseconds
|
|
8
|
+
* @returns {string} Formatted time (e.g., "2m 30s", "1h 15m")
|
|
9
|
+
*/
|
|
10
|
+
function formatTimestamp(ms) {
|
|
11
|
+
if (!ms || ms < 0) return '-';
|
|
12
|
+
|
|
13
|
+
const seconds = Math.floor(ms / 1000);
|
|
14
|
+
const minutes = Math.floor(seconds / 60);
|
|
15
|
+
const hours = Math.floor(minutes / 60);
|
|
16
|
+
const days = Math.floor(hours / 24);
|
|
17
|
+
|
|
18
|
+
if (days > 0) return `${days}d ${hours % 24}h`;
|
|
19
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
|
20
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
21
|
+
return `${seconds}s`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Format bytes as human-readable size
|
|
26
|
+
* @param {number} bytes - Bytes
|
|
27
|
+
* @returns {string} Formatted size (e.g., "1.5 MB", "512 KB")
|
|
28
|
+
*/
|
|
29
|
+
function formatBytes(bytes) {
|
|
30
|
+
if (!bytes || bytes === 0) return '0 B';
|
|
31
|
+
if (bytes < 0) return '-';
|
|
32
|
+
|
|
33
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
34
|
+
let size = bytes;
|
|
35
|
+
let unitIndex = 0;
|
|
36
|
+
|
|
37
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
38
|
+
size /= 1024;
|
|
39
|
+
unitIndex++;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Format CPU percentage
|
|
47
|
+
* @param {number} cpu - CPU percentage (0-100)
|
|
48
|
+
* @returns {string} Formatted CPU (e.g., "23.5%", "0.1%")
|
|
49
|
+
*/
|
|
50
|
+
function formatCPU(cpu) {
|
|
51
|
+
if (cpu === undefined || cpu === null || cpu < 0) return '0.0%';
|
|
52
|
+
return `${cpu.toFixed(1)}%`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get state icon and color
|
|
57
|
+
* @param {string} state - Task state (pending, running, completed, failed, etc.)
|
|
58
|
+
* @returns {string} Colored icon
|
|
59
|
+
*/
|
|
60
|
+
function stateIcon(state) {
|
|
61
|
+
const icons = {
|
|
62
|
+
pending: '○',
|
|
63
|
+
running: '●',
|
|
64
|
+
completed: '✓',
|
|
65
|
+
failed: '✗',
|
|
66
|
+
killed: '⊗',
|
|
67
|
+
unknown: '?',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const colors = {
|
|
71
|
+
pending: 'gray',
|
|
72
|
+
running: 'cyan',
|
|
73
|
+
completed: 'green',
|
|
74
|
+
failed: 'red',
|
|
75
|
+
killed: 'red',
|
|
76
|
+
unknown: 'gray',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const icon = icons[state] || icons.unknown;
|
|
80
|
+
const color = colors[state] || colors.unknown;
|
|
81
|
+
|
|
82
|
+
return `{${color}-fg}${icon}{/}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Truncate string to max length with ellipsis
|
|
87
|
+
* @param {string} str - String to truncate
|
|
88
|
+
* @param {number} maxLen - Maximum length
|
|
89
|
+
* @returns {string} Truncated string
|
|
90
|
+
*/
|
|
91
|
+
function truncate(str, maxLen) {
|
|
92
|
+
if (!str) return '';
|
|
93
|
+
if (str.length <= maxLen) return str;
|
|
94
|
+
return str.substring(0, maxLen - 1) + '…';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse event type from Claude JSON stream
|
|
99
|
+
* @param {string} line - Raw log line
|
|
100
|
+
* @returns {object|null} Parsed event with type, text, toolName, error
|
|
101
|
+
*/
|
|
102
|
+
function parseEvent(line) {
|
|
103
|
+
let trimmed = line.trim();
|
|
104
|
+
|
|
105
|
+
// Strip timestamp prefix if present: [1234567890]{...} -> {...}
|
|
106
|
+
const timestampMatch = trimmed.match(/^\[(\d+)\](.*)$/);
|
|
107
|
+
let timestamp = Date.now();
|
|
108
|
+
if (timestampMatch) {
|
|
109
|
+
timestamp = parseInt(timestampMatch[1]);
|
|
110
|
+
trimmed = timestampMatch[2];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Keep non-JSON lines as-is
|
|
114
|
+
if (!trimmed.startsWith('{')) {
|
|
115
|
+
return trimmed ? { type: 'raw', text: trimmed, timestamp } : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Parse JSON events
|
|
119
|
+
try {
|
|
120
|
+
const event = JSON.parse(trimmed);
|
|
121
|
+
|
|
122
|
+
// Text delta
|
|
123
|
+
if (event.type === 'stream_event' && event.event?.type === 'content_block_delta') {
|
|
124
|
+
return {
|
|
125
|
+
type: 'text',
|
|
126
|
+
text: event.event?.delta?.text || '',
|
|
127
|
+
timestamp,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// Tool use
|
|
131
|
+
else if (event.type === 'stream_event' && event.event?.type === 'content_block_start') {
|
|
132
|
+
const block = event.event?.content_block;
|
|
133
|
+
if (block?.type === 'tool_use' && block?.name) {
|
|
134
|
+
return {
|
|
135
|
+
type: 'tool',
|
|
136
|
+
toolName: block.name,
|
|
137
|
+
timestamp,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Assistant message
|
|
142
|
+
else if (event.type === 'assistant' && event.message?.content) {
|
|
143
|
+
let text = '';
|
|
144
|
+
for (const content of event.message.content) {
|
|
145
|
+
if (content.type === 'text') {
|
|
146
|
+
text += content.text;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return text ? { type: 'text', text, timestamp } : null;
|
|
150
|
+
}
|
|
151
|
+
// Error
|
|
152
|
+
else if (event.type === 'result' && event.is_error) {
|
|
153
|
+
return {
|
|
154
|
+
type: 'error',
|
|
155
|
+
text: event.result || 'Unknown error',
|
|
156
|
+
timestamp,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// Parse error - skip
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export { formatTimestamp, formatBytes, formatCPU, stateIcon, truncate, parseEvent };
|