codewake 0.0.1 → 0.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/lib/wizard.js CHANGED
@@ -1,43 +1,46 @@
1
1
  import { homedir } from 'node:os';
2
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';
3
+ import { askWhen, browseJobs, renderJobsTable } from './browser.js';
4
+ import { findPendingForSession, listJobs, scheduleJob } from './jobs.js';
5
+ import { listSessions, resolveSessionPlacement } from './sessions.js';
6
+ import { describeRunAt, timeAgo } from './time.js';
13
7
  import { color, confirm, input, select } from './tui.js';
14
8
 
9
+ // Re-exported so existing importers (tests, cli) keep one entry point.
10
+ export { renderJobsTable, shortenPath } from './browser.js';
11
+
15
12
  const DEFAULT_RESUME_PROMPT = 'continue';
13
+ const EXIT_WINDOW_MS = 5000;
16
14
 
17
- /** Interactive console. `io` lets tests inject stdin/stdout. */
15
+ /**
16
+ * Interactive console. `io` lets tests inject stdin/stdout. There is no
17
+ * Exit item: pressing a cancel key (ctrl+c, ctrl+d, esc, q) at the menu
18
+ * twice within 5 seconds leaves — the first press explains that.
19
+ */
18
20
  export async function runWizard({ version = '', io = {}, env = process.env } = {}) {
19
21
  const out = io.stdout ?? process.stdout;
20
22
  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`);
23
+ let lastCancel = { key: null, at: 0 };
21
24
  for (;;) {
22
25
  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;
26
+ { label: 'Schedule / wake an agent', value: 'schedule', hint: 'pick agent → session → prompt → time' },
27
+ { label: 'Waked agents', value: 'browse', hint: 'browse jobs: open, retry, cancel, sweep' },
28
+ ], { ...io, cancelInfo: true });
29
+ if (action?.cancelled) {
30
+ const now = Date.now();
31
+ if (lastCancel.key === action.key && now - lastCancel.at <= EXIT_WINDOW_MS) {
32
+ out.write(color.dim('Bye!\n'));
33
+ return;
34
+ }
35
+ lastCancel = { key: action.key, at: now };
36
+ out.write(color.dim(`Press ${action.key} again within 5s to exit.\n`));
37
+ continue;
32
38
  }
39
+ lastCancel = { key: null, at: 0 };
33
40
  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
+ else if (action === 'browse' && await browseJobs({ io, env }) === 'exit') {
42
+ out.write(color.dim('Bye!\n'));
43
+ return;
41
44
  }
42
45
  }
43
46
  }
@@ -91,6 +94,21 @@ async function scheduleFlow({ io, env, out }) {
91
94
  out.write(color.dim(` ${agent.name} only supports starting new sessions from here.\n`));
92
95
  }
93
96
 
97
+ // Agents like Claude Code can only resume a session from the project it
98
+ // belongs to; correct the working directory now instead of failing later.
99
+ let project = process.cwd();
100
+ if (mode === 'resume' && sessionId) {
101
+ let placement;
102
+ try {
103
+ placement = resolveSessionPlacement(agent, sessionId, { project, home: env.HOME || homedir() });
104
+ } catch (error) {
105
+ out.write(`${color.red('✘')} ${error.message}\n\n`);
106
+ return;
107
+ }
108
+ if (placement.note) out.write(`${color.yellow('!')} ${placement.note}\n`);
109
+ project = placement.project;
110
+ }
111
+
94
112
  // Two jobs resuming the same session would race each other — refuse early.
95
113
  const duplicate = findPendingForSession(agent.id, sessionId, env);
96
114
  if (duplicate) {
@@ -119,7 +137,6 @@ async function scheduleFlow({ io, env, out }) {
119
137
  if (runAt === null) return;
120
138
 
121
139
  const args = buildCommand(agent, { mode, sessionId, prompt, skipPermissions });
122
- const project = process.cwd();
123
140
 
124
141
  out.write(`\n${color.bold('Summary')}\n`);
125
142
  out.write(` ${color.dim('Agent')} ${agent.name}\n`);
@@ -146,41 +163,6 @@ async function scheduleFlow({ io, env, out }) {
146
163
  out.write(color.dim(` Manage it with "codewake jobs", "codewake logs ${job.id}" or "codewake cancel ${job.id}".\n\n`));
147
164
  }
148
165
 
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
166
  export function viewJobs(out, env = process.env) {
185
167
  const jobs = listJobs(env);
186
168
  if (!jobs.length) {
@@ -190,75 +172,3 @@ export function viewJobs(out, env = process.env) {
190
172
  out.write(`${renderJobsTable(jobs, Date.now(), { width: out.columns })}\n\n`);
191
173
  }
192
174
 
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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codewake",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
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
5
  "keywords": [
6
6
  "cli",
@@ -48,7 +48,7 @@
48
48
  "test": "node --test",
49
49
  "update:version": "npm version patch --no-git-tag-version",
50
50
  "build": "node -e \"\"",
51
- "publish:npm": "pnpm run test && pnpm run update:version && pnpm run build && pnpm publish --access public",
51
+ "publish:npm": "pnpm run test && pnpm run build && pnpm publish --access public",
52
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
53
  }
54
54
  }