codewake 0.0.1
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/LICENSE +21 -0
- package/README.md +159 -0
- package/bin/codewake.js +7 -0
- package/lib/agents.js +119 -0
- package/lib/cli.js +245 -0
- package/lib/jobs.js +327 -0
- package/lib/proc.js +37 -0
- package/lib/registry.js +151 -0
- package/lib/sessions.js +156 -0
- package/lib/time.js +88 -0
- package/lib/tui.js +187 -0
- package/lib/wizard.js +264 -0
- package/package.json +54 -0
package/lib/time.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const UNIT_SECONDS = { d: 86400, h: 3600, m: 60, s: 1 };
|
|
2
|
+
|
|
3
|
+
// Full unit words only: matching just the first letter would silently turn
|
|
4
|
+
// "1 month" into one minute — unacceptable for a scheduler.
|
|
5
|
+
const UNIT_WORDS = new Map(Object.entries({
|
|
6
|
+
d: 'd', day: 'd', days: 'd',
|
|
7
|
+
h: 'h', hr: 'h', hrs: 'h', hour: 'h', hours: 'h',
|
|
8
|
+
m: 'm', min: 'm', mins: 'm', minute: 'm', minutes: 'm',
|
|
9
|
+
s: 's', sec: 's', secs: 's', second: 's', seconds: 's',
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse a human duration ("2h 30m", "45m", "1d", "90") into seconds.
|
|
14
|
+
* A bare number is interpreted as minutes. Unknown units are an error.
|
|
15
|
+
*/
|
|
16
|
+
export function parseDuration(text) {
|
|
17
|
+
const raw = String(text ?? '').trim().toLowerCase();
|
|
18
|
+
if (!raw) throw new Error('Duration is empty. Try something like "2h 30m".');
|
|
19
|
+
if (/^\d+$/.test(raw)) return Number(raw) * 60;
|
|
20
|
+
let total = 0;
|
|
21
|
+
let unknownUnit = null;
|
|
22
|
+
const leftovers = raw.replace(/(\d+)\s*([a-z]+)/g, (match, amount, word) => {
|
|
23
|
+
const unit = UNIT_WORDS.get(word);
|
|
24
|
+
if (!unit) {
|
|
25
|
+
unknownUnit = word;
|
|
26
|
+
return match;
|
|
27
|
+
}
|
|
28
|
+
total += Number(amount) * UNIT_SECONDS[unit];
|
|
29
|
+
return '';
|
|
30
|
+
});
|
|
31
|
+
if (unknownUnit) {
|
|
32
|
+
throw new Error(`Unknown time unit "${unknownUnit}". Use d, h, m or s, e.g. "2h 30m".`);
|
|
33
|
+
}
|
|
34
|
+
if (leftovers.replace(/[\s,]+/g, '') !== '') {
|
|
35
|
+
throw new Error(`Could not understand "${text}". Try "2h 30m", "45m" or "90" (minutes).`);
|
|
36
|
+
}
|
|
37
|
+
if (total <= 0) throw new Error('Duration must be greater than zero.');
|
|
38
|
+
return total;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a 24h clock time ("14:30") into the next Date it occurs
|
|
43
|
+
* (today if still ahead, otherwise tomorrow).
|
|
44
|
+
*/
|
|
45
|
+
export function parseClockTime(text, now = new Date()) {
|
|
46
|
+
const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(String(text ?? '').trim());
|
|
47
|
+
if (!match) throw new Error(`"${text}" is not a valid time. Use 24h format, e.g. "14:30".`);
|
|
48
|
+
const at = new Date(now);
|
|
49
|
+
at.setHours(Number(match[1]), Number(match[2]), 0, 0);
|
|
50
|
+
if (at.getTime() <= now.getTime()) at.setDate(at.getDate() + 1);
|
|
51
|
+
return at;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatDuration(totalSeconds) {
|
|
55
|
+
const s = Math.max(0, Math.round(totalSeconds));
|
|
56
|
+
const days = Math.floor(s / 86400);
|
|
57
|
+
const hours = Math.floor((s % 86400) / 3600);
|
|
58
|
+
const minutes = Math.floor((s % 3600) / 60);
|
|
59
|
+
const seconds = s % 60;
|
|
60
|
+
const parts = [];
|
|
61
|
+
if (days) parts.push(`${days}d`);
|
|
62
|
+
if (hours) parts.push(`${hours}h`);
|
|
63
|
+
if (minutes) parts.push(`${minutes}m`);
|
|
64
|
+
if (!parts.length || (seconds && !days && !hours)) parts.push(`${seconds}s`);
|
|
65
|
+
return parts.join(' ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function timeAgo(date, now = new Date()) {
|
|
69
|
+
const s = Math.max(0, (now.getTime() - new Date(date).getTime()) / 1000);
|
|
70
|
+
if (s < 60) return 'just now';
|
|
71
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
72
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
73
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function describeRunAt(runAt, now = new Date()) {
|
|
77
|
+
const at = new Date(runAt);
|
|
78
|
+
const delta = (at.getTime() - now.getTime()) / 1000;
|
|
79
|
+
if (delta <= -60) return `due ${formatDuration(-delta)} ago`;
|
|
80
|
+
if (delta < 60) return 'now';
|
|
81
|
+
const clock = at.toLocaleString(undefined, {
|
|
82
|
+
hour: '2-digit',
|
|
83
|
+
minute: '2-digit',
|
|
84
|
+
day: '2-digit',
|
|
85
|
+
month: 'short',
|
|
86
|
+
});
|
|
87
|
+
return `in ${formatDuration(delta)} (${clock})`;
|
|
88
|
+
}
|
package/lib/tui.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { createInterface, emitKeypressEvents } from 'node:readline';
|
|
2
|
+
|
|
3
|
+
const useColor = (stream = process.stdout) => Boolean(stream.isTTY) && !('NO_COLOR' in process.env);
|
|
4
|
+
|
|
5
|
+
const paint = (code, close = 39) => (text, stream) =>
|
|
6
|
+
useColor(stream) ? `\x1b[${code}m${text}\x1b[${close}m` : String(text);
|
|
7
|
+
|
|
8
|
+
export const color = {
|
|
9
|
+
bold: paint(1, 22),
|
|
10
|
+
dim: paint(2, 22),
|
|
11
|
+
red: paint(31),
|
|
12
|
+
green: paint(32),
|
|
13
|
+
yellow: paint(33),
|
|
14
|
+
cyan: paint(36),
|
|
15
|
+
magenta: paint(35),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Arrow-key select menu. Returns the chosen value, or null when the user
|
|
20
|
+
* cancels (esc / ctrl+c / q). Choices are strings or
|
|
21
|
+
* { label, value, hint } objects. Long lists scroll inside `pageSize`.
|
|
22
|
+
*/
|
|
23
|
+
export function select(message, choices, {
|
|
24
|
+
stdin = process.stdin,
|
|
25
|
+
stdout = process.stdout,
|
|
26
|
+
pageSize = 10,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const items = choices.map((choice) =>
|
|
29
|
+
typeof choice === 'string' ? { label: choice, value: choice } : choice,
|
|
30
|
+
);
|
|
31
|
+
if (!items.length) return Promise.resolve(null);
|
|
32
|
+
return new Promise((resolvePromise) => {
|
|
33
|
+
let index = 0;
|
|
34
|
+
let top = 0;
|
|
35
|
+
let renderedLines = 0;
|
|
36
|
+
emitKeypressEvents(stdin);
|
|
37
|
+
const raw = typeof stdin.setRawMode === 'function';
|
|
38
|
+
if (raw) stdin.setRawMode(true);
|
|
39
|
+
stdin.resume();
|
|
40
|
+
if (stdout.isTTY) stdout.write('\x1b[?25l');
|
|
41
|
+
|
|
42
|
+
const render = () => {
|
|
43
|
+
if (renderedLines) stdout.write(`\x1b[${renderedLines}A`);
|
|
44
|
+
if (stdout.isTTY) stdout.write('\x1b[0J');
|
|
45
|
+
const lines = [
|
|
46
|
+
`${color.green('?', stdout)} ${color.bold(message, stdout)} ${color.dim('(↑/↓ · enter · esc cancels)', stdout)}`,
|
|
47
|
+
];
|
|
48
|
+
if (top > 0) lines.push(color.dim(` ↑ ${top} more`, stdout));
|
|
49
|
+
const end = Math.min(items.length, top + pageSize);
|
|
50
|
+
for (let i = top; i < end; i += 1) {
|
|
51
|
+
const item = items[i];
|
|
52
|
+
const active = i === index;
|
|
53
|
+
const pointer = active ? color.cyan('❯', stdout) : ' ';
|
|
54
|
+
const label = active ? color.cyan(item.label, stdout) : item.label;
|
|
55
|
+
const hint = item.hint ? ` ${color.dim(item.hint, stdout)}` : '';
|
|
56
|
+
lines.push(` ${pointer} ${label}${hint}`);
|
|
57
|
+
}
|
|
58
|
+
if (end < items.length) lines.push(color.dim(` ↓ ${items.length - end} more`, stdout));
|
|
59
|
+
renderedLines = lines.length;
|
|
60
|
+
stdout.write(`${lines.join('\n')}\n`);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const finish = (value) => {
|
|
64
|
+
stdin.removeListener('keypress', onKeypress);
|
|
65
|
+
if (raw) stdin.setRawMode(false);
|
|
66
|
+
stdin.pause();
|
|
67
|
+
if (stdout.isTTY) {
|
|
68
|
+
stdout.write(`\x1b[${renderedLines}A\x1b[0J`);
|
|
69
|
+
const answer = value === null
|
|
70
|
+
? color.dim('cancelled', stdout)
|
|
71
|
+
: color.cyan(items[index].label, stdout);
|
|
72
|
+
stdout.write(`${color.green('✔', stdout)} ${color.bold(message, stdout)} ${answer}\n`);
|
|
73
|
+
stdout.write('\x1b[?25h');
|
|
74
|
+
}
|
|
75
|
+
resolvePromise(value);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const move = (delta) => {
|
|
79
|
+
index = (index + delta + items.length) % items.length;
|
|
80
|
+
if (index < top) top = index;
|
|
81
|
+
if (index >= top + pageSize) top = index - pageSize + 1;
|
|
82
|
+
render();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const onKeypress = (str, key = {}) => {
|
|
86
|
+
if (key.name === 'up' || str === 'k') move(-1);
|
|
87
|
+
else if (key.name === 'down' || str === 'j') move(1);
|
|
88
|
+
else if (key.name === 'return' || key.name === 'enter') finish(items[index].value);
|
|
89
|
+
else if (key.name === 'escape' || str === 'q' || (key.ctrl && key.name === 'c')) finish(null);
|
|
90
|
+
else if (/^[1-9]$/.test(str ?? '') && Number(str) <= items.length) {
|
|
91
|
+
// Highlight only — instant-select would let a pasted answer like
|
|
92
|
+
// "2h 30m" pick item 2 and leak "h 30m" into the next prompt.
|
|
93
|
+
index = Number(str) - 1;
|
|
94
|
+
top = Math.max(0, Math.min(index, items.length - pageSize));
|
|
95
|
+
render();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
stdin.on('keypress', onKeypress);
|
|
100
|
+
render();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Free-text prompt. Returns the value (trimmed), the default when the
|
|
106
|
+
* answer is empty, or null when cancelled with ctrl+c.
|
|
107
|
+
*/
|
|
108
|
+
export async function input(message, {
|
|
109
|
+
def,
|
|
110
|
+
required = false,
|
|
111
|
+
validate,
|
|
112
|
+
stdin = process.stdin,
|
|
113
|
+
stdout = process.stdout,
|
|
114
|
+
} = {}) {
|
|
115
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
116
|
+
// Queue lines instead of using rl.question(): fast input (or a paste with
|
|
117
|
+
// newlines) can deliver several lines in one chunk, and question() drops
|
|
118
|
+
// every line it is not currently waiting for.
|
|
119
|
+
const lines = [];
|
|
120
|
+
let waiter = null;
|
|
121
|
+
let closed = false;
|
|
122
|
+
rl.on('line', (line) => {
|
|
123
|
+
if (waiter) {
|
|
124
|
+
const deliver = waiter;
|
|
125
|
+
waiter = null;
|
|
126
|
+
deliver(line);
|
|
127
|
+
} else {
|
|
128
|
+
lines.push(line);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
rl.on('close', () => {
|
|
132
|
+
closed = true;
|
|
133
|
+
if (waiter) {
|
|
134
|
+
const deliver = waiter;
|
|
135
|
+
waiter = null;
|
|
136
|
+
deliver(null);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
rl.on('SIGINT', () => rl.close());
|
|
140
|
+
const suffix = def ? ` ${color.dim(`(${def})`, stdout)}` : '';
|
|
141
|
+
const question = `${color.green('?', stdout)} ${color.bold(message, stdout)}${suffix} `;
|
|
142
|
+
const ask = () => {
|
|
143
|
+
rl.setPrompt(question);
|
|
144
|
+
rl.prompt();
|
|
145
|
+
if (lines.length) return Promise.resolve(lines.shift());
|
|
146
|
+
if (closed) return Promise.resolve(null);
|
|
147
|
+
return new Promise((resolveLine) => {
|
|
148
|
+
waiter = resolveLine;
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
try {
|
|
152
|
+
for (;;) {
|
|
153
|
+
const answer = await ask();
|
|
154
|
+
if (answer === null) {
|
|
155
|
+
stdout.write('\n');
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const value = answer.trim() || def || '';
|
|
159
|
+
if (required && !value) {
|
|
160
|
+
stdout.write(color.red(' This field is required.\n', stdout));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (validate && value !== '') {
|
|
164
|
+
const error = validate(value);
|
|
165
|
+
if (error) {
|
|
166
|
+
stdout.write(color.red(` ${error}\n`, stdout));
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
rl.close();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Yes/no prompt. Returns boolean, or null when cancelled. */
|
|
178
|
+
export async function confirm(message, { def = false, stdin, stdout } = {}) {
|
|
179
|
+
const answer = await input(`${message} ${color.dim(def ? '[Y/n]' : '[y/N]')}`, {
|
|
180
|
+
stdin,
|
|
181
|
+
stdout,
|
|
182
|
+
validate: (value) => (/^(y|yes|n|no)$/i.test(value) ? null : 'Answer "y" or "n".'),
|
|
183
|
+
});
|
|
184
|
+
if (answer === null) return null;
|
|
185
|
+
if (answer === '') return def;
|
|
186
|
+
return /^y/i.test(answer);
|
|
187
|
+
}
|
package/lib/wizard.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { buildCommand, commandPreview, configPath, detectAgents, loadAgents } from './agents.js';
|
|
3
|
+
import {
|
|
4
|
+
cancelJob,
|
|
5
|
+
clearJobs,
|
|
6
|
+
displayStatus,
|
|
7
|
+
findPendingForSession,
|
|
8
|
+
listJobs,
|
|
9
|
+
scheduleJob,
|
|
10
|
+
} from './jobs.js';
|
|
11
|
+
import { listSessions } from './sessions.js';
|
|
12
|
+
import { describeRunAt, parseClockTime, parseDuration, timeAgo } from './time.js';
|
|
13
|
+
import { color, confirm, input, select } from './tui.js';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_RESUME_PROMPT = 'continue';
|
|
16
|
+
|
|
17
|
+
/** Interactive console. `io` lets tests inject stdin/stdout. */
|
|
18
|
+
export async function runWizard({ version = '', io = {}, env = process.env } = {}) {
|
|
19
|
+
const out = io.stdout ?? process.stdout;
|
|
20
|
+
out.write(`\n${color.bold('codewake')}${version ? color.dim(` v${version}`) : ''} ${color.dim('— schedule your coding agents to pick up where they left off')}\n\n`);
|
|
21
|
+
for (;;) {
|
|
22
|
+
const action = await select('What do you want to do?', [
|
|
23
|
+
{ label: 'Schedule a resume job', value: 'schedule', hint: 'pick agent → session → prompt → time' },
|
|
24
|
+
{ label: 'View jobs', value: 'jobs' },
|
|
25
|
+
{ label: 'Cancel a job', value: 'cancel' },
|
|
26
|
+
{ label: 'Clean up finished jobs', value: 'clear' },
|
|
27
|
+
{ label: 'Exit', value: 'exit' },
|
|
28
|
+
], io);
|
|
29
|
+
if (!action || action === 'exit') {
|
|
30
|
+
out.write(color.dim('Bye!\n'));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (action === 'schedule') await scheduleFlow({ io, env, out });
|
|
34
|
+
else if (action === 'jobs') viewJobs(out, env);
|
|
35
|
+
else if (action === 'cancel') await cancelFlow({ io, env, out });
|
|
36
|
+
else if (action === 'clear') {
|
|
37
|
+
const removed = clearJobs(env);
|
|
38
|
+
out.write(removed.length
|
|
39
|
+
? `${color.green('✔')} Removed ${removed.length} finished job${removed.length === 1 ? '' : 's'}.\n\n`
|
|
40
|
+
: color.dim('Nothing to clean up.\n\n'));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function scheduleFlow({ io, env, out }) {
|
|
46
|
+
const detected = detectAgents(loadAgents({ env }), { env });
|
|
47
|
+
const installed = detected.filter((entry) => entry.bin);
|
|
48
|
+
if (!installed.length) {
|
|
49
|
+
out.write(`${color.yellow('!')} No supported agents found on your PATH.\n`);
|
|
50
|
+
out.write(color.dim(` Known agents: ${detected.map((entry) => entry.agent.bins[0]).join(', ')}\n`));
|
|
51
|
+
out.write(color.dim(` Add your own in ${configPath(env)}\n\n`));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const picked = await select('Which agent do you want to launch?', installed.map((entry) => ({
|
|
56
|
+
label: entry.agent.name,
|
|
57
|
+
value: entry,
|
|
58
|
+
hint: `(${entry.bin})`,
|
|
59
|
+
})), io);
|
|
60
|
+
if (!picked) return;
|
|
61
|
+
const { agent, bin } = picked;
|
|
62
|
+
|
|
63
|
+
// Session: existing, manual id, or brand new.
|
|
64
|
+
let mode = 'create';
|
|
65
|
+
let sessionId = null;
|
|
66
|
+
if (agent.resume) {
|
|
67
|
+
const sessions = listSessions(agent, { project: process.cwd(), home: env.HOME || homedir() });
|
|
68
|
+
const choices = sessions.slice(0, 25).map((session) => ({
|
|
69
|
+
label: session.title,
|
|
70
|
+
value: { mode: 'resume', sessionId: session.id },
|
|
71
|
+
hint: `${session.id.slice(0, 8)} · ${timeAgo(session.updatedAt)}`,
|
|
72
|
+
}));
|
|
73
|
+
choices.push({ label: 'Enter a session id manually', value: { mode: 'manual' } });
|
|
74
|
+
if (agent.create) choices.push({ label: 'Start a new session', value: { mode: 'create' } });
|
|
75
|
+
const chosen = await select(
|
|
76
|
+
sessions.length ? `Which session do you want to resume?` : `No sessions found for ${agent.name} here — what now?`,
|
|
77
|
+
choices,
|
|
78
|
+
io,
|
|
79
|
+
);
|
|
80
|
+
if (!chosen) return;
|
|
81
|
+
if (chosen.mode === 'manual') {
|
|
82
|
+
const manual = await input('Session id', { required: true, ...io });
|
|
83
|
+
if (manual === null) return;
|
|
84
|
+
mode = 'resume';
|
|
85
|
+
sessionId = manual;
|
|
86
|
+
} else {
|
|
87
|
+
mode = chosen.mode;
|
|
88
|
+
sessionId = chosen.sessionId ?? null;
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
out.write(color.dim(` ${agent.name} only supports starting new sessions from here.\n`));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Two jobs resuming the same session would race each other — refuse early.
|
|
95
|
+
const duplicate = findPendingForSession(agent.id, sessionId, env);
|
|
96
|
+
if (duplicate) {
|
|
97
|
+
out.write(`${color.yellow('!')} This session already has a pending job (${color.bold(duplicate.id)}, ${describeRunAt(duplicate.runAt)}).\n`);
|
|
98
|
+
out.write(color.dim(` Cancel it first if you want to reschedule: codewake cancel ${duplicate.id}\n\n`));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Prompt: optional when resuming (defaults to "continue"), required for new sessions.
|
|
103
|
+
const prompt = mode === 'resume'
|
|
104
|
+
? await input('Prompt to send', { def: DEFAULT_RESUME_PROMPT, ...io })
|
|
105
|
+
: await input('Prompt for the new session', { required: true, ...io });
|
|
106
|
+
if (prompt === null) return;
|
|
107
|
+
|
|
108
|
+
// Permissions.
|
|
109
|
+
const spec = mode === 'resume' ? agent.resume : agent.create;
|
|
110
|
+
let skipPermissions = false;
|
|
111
|
+
if (spec.skip) {
|
|
112
|
+
const answer = await confirm('Skip permission prompts? (the agent will run fully unattended)', { def: false, ...io });
|
|
113
|
+
if (answer === null) return;
|
|
114
|
+
skipPermissions = answer;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// When to launch.
|
|
118
|
+
const runAt = await askWhen(io);
|
|
119
|
+
if (runAt === null) return;
|
|
120
|
+
|
|
121
|
+
const args = buildCommand(agent, { mode, sessionId, prompt, skipPermissions });
|
|
122
|
+
const project = process.cwd();
|
|
123
|
+
|
|
124
|
+
out.write(`\n${color.bold('Summary')}\n`);
|
|
125
|
+
out.write(` ${color.dim('Agent')} ${agent.name}\n`);
|
|
126
|
+
out.write(` ${color.dim('Session')} ${mode === 'resume' ? sessionId : 'new session'}\n`);
|
|
127
|
+
out.write(` ${color.dim('Prompt')} ${prompt}\n`);
|
|
128
|
+
out.write(` ${color.dim('Permissions')} ${skipPermissions ? color.yellow('skipped (unattended)') : 'interactive'}\n`);
|
|
129
|
+
out.write(` ${color.dim('Launch')} ${describeRunAt(runAt)}\n`);
|
|
130
|
+
out.write(` ${color.dim('Project')} ${project}\n`);
|
|
131
|
+
out.write(` ${color.dim('Command')} ${commandPreview(bin, args)}\n\n`);
|
|
132
|
+
|
|
133
|
+
const go = await confirm('Schedule it?', { def: true, ...io });
|
|
134
|
+
if (!go) {
|
|
135
|
+
out.write(color.dim('Discarded.\n\n'));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let job;
|
|
139
|
+
try {
|
|
140
|
+
job = scheduleJob({ agent, bin, args, mode, sessionId, prompt, skipPermissions, project, runAt }, { env });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
out.write(`${color.red('✘')} ${error.message}\n\n`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
out.write(`${color.green('✔')} Job ${color.bold(job.id)} scheduled ${describeRunAt(job.runAt)}.\n`);
|
|
146
|
+
out.write(color.dim(` Manage it with "codewake jobs", "codewake logs ${job.id}" or "codewake cancel ${job.id}".\n\n`));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function askWhen(io) {
|
|
150
|
+
const kind = await select('When should it launch?', [
|
|
151
|
+
{ label: 'Right now', value: 'now' },
|
|
152
|
+
{ label: 'After a delay', value: 'in', hint: 'e.g. 2h 30m' },
|
|
153
|
+
{ label: 'At a specific time', value: 'at', hint: 'e.g. 14:30' },
|
|
154
|
+
], io);
|
|
155
|
+
if (!kind) return null;
|
|
156
|
+
if (kind === 'now') return new Date();
|
|
157
|
+
if (kind === 'in') {
|
|
158
|
+
const text = await input('Delay', {
|
|
159
|
+
required: true,
|
|
160
|
+
validate: (value) => tryError(() => parseDuration(value)),
|
|
161
|
+
...io,
|
|
162
|
+
});
|
|
163
|
+
if (text === null) return null;
|
|
164
|
+
return new Date(Date.now() + parseDuration(text) * 1000);
|
|
165
|
+
}
|
|
166
|
+
const text = await input('Time (24h)', {
|
|
167
|
+
required: true,
|
|
168
|
+
validate: (value) => tryError(() => parseClockTime(value)),
|
|
169
|
+
...io,
|
|
170
|
+
});
|
|
171
|
+
if (text === null) return null;
|
|
172
|
+
return parseClockTime(text);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function tryError(fn) {
|
|
176
|
+
try {
|
|
177
|
+
fn();
|
|
178
|
+
return null;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return error.message;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function viewJobs(out, env = process.env) {
|
|
185
|
+
const jobs = listJobs(env);
|
|
186
|
+
if (!jobs.length) {
|
|
187
|
+
out.write(color.dim('No jobs yet. Schedule one first.\n\n'));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
out.write(`${renderJobsTable(jobs, Date.now(), { width: out.columns })}\n\n`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Shorten a path for display: home becomes ~, long paths keep their tail. */
|
|
194
|
+
export function shortenPath(path, max, home = homedir()) {
|
|
195
|
+
let short = path;
|
|
196
|
+
if (home && short.startsWith(home)) short = `~${short.slice(home.length)}`;
|
|
197
|
+
if (short.length > max) short = `…${short.slice(short.length - max + 1)}`;
|
|
198
|
+
return short;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const truncate = (text, max) =>
|
|
202
|
+
(text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Fixed-width jobs table that always fits `width` columns: the PROJECT
|
|
206
|
+
* column absorbs the remaining space (shortened from the left), and is
|
|
207
|
+
* dropped entirely when the terminal is too narrow for it.
|
|
208
|
+
*/
|
|
209
|
+
export function renderJobsTable(jobs, now = Date.now(), { width = process.stdout.columns || 120 } = {}) {
|
|
210
|
+
const statusColor = { pending: color.cyan, running: color.yellow, done: color.green, failed: color.red, cancelled: color.dim, overdue: color.red };
|
|
211
|
+
const rows = jobs.map((job) => [
|
|
212
|
+
job.id,
|
|
213
|
+
truncate(job.agentName, 20),
|
|
214
|
+
displayStatus(job, now),
|
|
215
|
+
truncate(job.status === 'pending' ? describeRunAt(job.runAt, new Date(now)) : new Date(job.runAt).toLocaleString(), 28),
|
|
216
|
+
job.mode === 'resume' ? truncate(job.sessionId ?? '', 12) : 'new session',
|
|
217
|
+
job.project,
|
|
218
|
+
]);
|
|
219
|
+
let header = ['ID', 'AGENT', 'STATUS', 'LAUNCH', 'SESSION', 'PROJECT'];
|
|
220
|
+
const columnWidth = (column) =>
|
|
221
|
+
Math.max(header[column].length, ...rows.map((row) => String(row[column]).length));
|
|
222
|
+
const gap = 2;
|
|
223
|
+
const fixedWidth = header.slice(0, -1).reduce((sum, _title, column) => sum + columnWidth(column) + gap, 0);
|
|
224
|
+
const projectSpace = width - fixedWidth;
|
|
225
|
+
if (projectSpace >= 'PROJECT'.length) {
|
|
226
|
+
for (const row of rows) row[5] = shortenPath(row[5], projectSpace);
|
|
227
|
+
} else {
|
|
228
|
+
header = header.slice(0, 5);
|
|
229
|
+
for (const row of rows) row.length = 5;
|
|
230
|
+
}
|
|
231
|
+
const widths = header.map((_title, column) => columnWidth(column));
|
|
232
|
+
const line = (cells, decorate) => cells
|
|
233
|
+
.map((cell, column) => {
|
|
234
|
+
const text = String(cell).padEnd(widths[column]);
|
|
235
|
+
return decorate && column === 2 ? (statusColor[cell] ?? color.dim)(text) : text;
|
|
236
|
+
})
|
|
237
|
+
.join(' '.repeat(gap))
|
|
238
|
+
.trimEnd();
|
|
239
|
+
return [color.dim(line(header)), ...rows.map((row) => line(row, true))].join('\n');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function cancelFlow({ io, env, out }) {
|
|
243
|
+
const pending = listJobs(env).filter((job) => job.status === 'pending');
|
|
244
|
+
if (!pending.length) {
|
|
245
|
+
out.write(color.dim('No pending jobs to cancel.\n\n'));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const jobId = await select('Which job do you want to cancel?', pending.map((job) => ({
|
|
249
|
+
label: `${job.id} · ${job.agentName}`,
|
|
250
|
+
value: job.id,
|
|
251
|
+
hint: describeRunAt(job.runAt),
|
|
252
|
+
})), io);
|
|
253
|
+
if (!jobId) return;
|
|
254
|
+
const sure = await confirm(`Cancel ${jobId}?`, { def: true, ...io });
|
|
255
|
+
if (!sure) return;
|
|
256
|
+
try {
|
|
257
|
+
const { warning } = cancelJob(jobId, env);
|
|
258
|
+
out.write(`${color.green('✔')} Cancelled ${jobId}.\n`);
|
|
259
|
+
if (warning) out.write(`${color.yellow('!')} ${warning}\n`);
|
|
260
|
+
out.write('\n');
|
|
261
|
+
} catch (error) {
|
|
262
|
+
out.write(`${color.red('✘')} ${error.message}\n\n`);
|
|
263
|
+
}
|
|
264
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codewake",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Interactive CLI to schedule your coding agents (Claude Code, Codex, Copilot, Gemini, Cursor, Aider, ...) to wake up and pick up where they left off.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"agents",
|
|
8
|
+
"scheduler",
|
|
9
|
+
"resume",
|
|
10
|
+
"codewake",
|
|
11
|
+
"claude-code",
|
|
12
|
+
"codex",
|
|
13
|
+
"copilot",
|
|
14
|
+
"gemini",
|
|
15
|
+
"cursor",
|
|
16
|
+
"aider",
|
|
17
|
+
"opencode",
|
|
18
|
+
"goose"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"codewake": "bin/codewake.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"lib",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=24"
|
|
33
|
+
},
|
|
34
|
+
"os": [
|
|
35
|
+
"linux",
|
|
36
|
+
"darwin",
|
|
37
|
+
"win32"
|
|
38
|
+
],
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/luisobz/codewake.git"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/luisobz/codewake/issues"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/luisobz/codewake#readme",
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test",
|
|
49
|
+
"update:version": "npm version patch --no-git-tag-version",
|
|
50
|
+
"build": "node -e \"\"",
|
|
51
|
+
"publish:npm": "pnpm run test && pnpm run update:version && pnpm run build && pnpm publish --access public",
|
|
52
|
+
"git:tag": "git commit -am \"v$npm_package_version\" && git push origin main && git tag v$npm_package_version && git push origin v$npm_package_version"
|
|
53
|
+
}
|
|
54
|
+
}
|