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/cli.js CHANGED
@@ -1,21 +1,28 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { buildCommand, commandPreview, detectAgents, loadAgents } from './agents.js';
4
+ import {
5
+ attachOpen,
6
+ DEFAULT_RESUME_PROMPT,
7
+ prepareOpen,
8
+ retryFromSpec,
9
+ scheduleFromSpec,
10
+ } from './actions.js';
5
11
  import {
6
12
  cancelJob,
7
13
  clearJobs,
8
- listJobs,
14
+ displayStatus,
15
+ loadJob,
9
16
  logFile,
10
17
  runJob,
11
- scheduleJob,
12
18
  waitJob,
13
19
  } from './jobs.js';
14
- import { describeRunAt, parseClockTime, parseDuration } from './time.js';
20
+ import { describeRunAt } from './time.js';
15
21
  import { color } from './tui.js';
16
22
  import { runWizard, viewJobs } from './wizard.js';
17
23
 
18
- const DEFAULT_RESUME_PROMPT = 'continue';
24
+ export { resolveRunAt } from './actions.js';
25
+
19
26
  const PACKAGE_JSON = fileURLToPath(new URL('../package.json', import.meta.url));
20
27
 
21
28
  export function version() {
@@ -29,8 +36,10 @@ Usage:
29
36
  codewake Interactive console (detect agents, pick a
30
37
  session, schedule the resume job)
31
38
  codewake jobs List scheduled and finished jobs
32
- codewake logs <job-id> Show the output of a job
39
+ codewake open <job-id> Reopen a job's session in its agent's own UI
40
+ codewake logs <job-id> Show a job's status and output
33
41
  codewake cancel <job-id> Cancel a pending job
42
+ codewake retry <job-id> Reschedule a failed, finished or overdue job
34
43
  codewake clear Remove finished jobs from the list
35
44
  codewake agents Show which agents are installed
36
45
  codewake schedule [options] Non-interactive scheduling
@@ -49,10 +58,16 @@ Schedule options:
49
58
  --bin <path> Override the detected executable
50
59
  --dry-run Print what would run without scheduling
51
60
 
61
+ Retry options:
62
+ --now | --in <duration> | --at <HH:MM> When to launch again (default: now)
63
+ --prompt <text> Override the prompt to send
64
+ --project <dir> Override the working directory
65
+
52
66
  Examples:
53
67
  codewake
54
68
  codewake schedule --agent claude --session 550e8400 --in 2h30m
55
- codewake schedule --agent codex --new --prompt "run the test suite" --at 06:00`;
69
+ codewake schedule --agent codex --new --prompt "run the test suite" --at 06:00
70
+ codewake retry mrouq7ln-rkv5 --in 5s`;
56
71
  }
57
72
 
58
73
  export function parseArgs(argv) {
@@ -79,6 +94,11 @@ export function parseArgs(argv) {
79
94
  case 'cancel':
80
95
  if (!rest[0]) throw new Error('Usage: codewake cancel <job-id>');
81
96
  return { command: 'cancel', jobId: rest[0] };
97
+ case 'open':
98
+ if (!rest[0]) throw new Error('Usage: codewake open <job-id>');
99
+ return { command: 'open', jobId: rest[0] };
100
+ case 'retry':
101
+ return parseRetryArgs(rest);
82
102
  case 'schedule':
83
103
  return parseScheduleArgs(rest);
84
104
  case '--run':
@@ -129,43 +149,41 @@ function parseScheduleArgs(argv) {
129
149
  return result;
130
150
  }
131
151
 
132
- export function resolveRunAt(when, now = new Date()) {
133
- if (when.type === 'in') return new Date(now.getTime() + parseDuration(when.value) * 1000);
134
- if (when.type === 'at') return parseClockTime(when.value, now);
135
- return now;
136
- }
137
-
138
- function scheduleFromArgs(args, env = process.env) {
139
- const detected = detectAgents(loadAgents({ env }), { env });
140
- const entry = detected.find((candidate) => candidate.agent.id === args.agentId);
141
- if (!entry) {
142
- const known = detected.map((candidate) => candidate.agent.id).join(', ');
143
- throw new Error(`Unknown agent "${args.agentId}". Known agents: ${known}.`);
152
+ function parseRetryArgs(argv) {
153
+ const [jobId, ...rest] = argv;
154
+ if (!jobId || jobId.startsWith('-')) {
155
+ throw new Error('Usage: codewake retry <job-id> [--now | --in <duration> | --at <HH:MM>]');
144
156
  }
145
- const bin = args.bin ?? entry.bin;
146
- if (!bin) {
147
- throw new Error(`${entry.agent.name} is not installed (looked for: ${entry.agent.bins.join(', ')}). Use --bin to point at it.`);
157
+ const result = { command: 'retry', jobId, when: { type: 'now' }, prompt: null, project: null, dryRun: false };
158
+ const value = (i, flag) => {
159
+ if (rest[i + 1] === undefined) throw new Error(`${flag} needs a value.`);
160
+ return rest[i + 1];
161
+ };
162
+ for (let i = 0; i < rest.length; i += 1) {
163
+ const flag = rest[i];
164
+ if (flag === '--in') result.when = { type: 'in', value: value(i, flag) }, i += 1;
165
+ else if (flag === '--at') result.when = { type: 'at', value: value(i, flag) }, i += 1;
166
+ else if (flag === '--now') result.when = { type: 'now' };
167
+ else if (flag === '--prompt') result.prompt = value(i, flag), i += 1;
168
+ else if (flag === '--project') result.project = resolve(value(i, flag)), i += 1;
169
+ else if (flag === '--dry-run') result.dryRun = true;
170
+ else throw new Error(`Unknown option: ${flag}. Run "codewake --help".`);
148
171
  }
149
- const mode = args.isNew ? 'create' : 'resume';
150
- const prompt = args.prompt?.trim() || DEFAULT_RESUME_PROMPT;
151
- const commandArgs = buildCommand(entry.agent, {
152
- mode,
153
- sessionId: args.sessionId,
154
- prompt,
155
- skipPermissions: args.skipPermissions,
156
- });
157
- const job = scheduleJob({
158
- agent: entry.agent,
159
- bin,
160
- args: commandArgs,
161
- mode,
162
- sessionId: args.sessionId,
163
- prompt,
164
- skipPermissions: args.skipPermissions,
165
- project: args.project,
166
- runAt: resolveRunAt(args.when),
167
- }, { dryRun: args.dryRun, env });
168
- return { job, preview: commandPreview(bin, commandArgs) };
172
+ return result;
173
+ }
174
+
175
+ /** Status header shown by "codewake logs", separate from the raw output. */
176
+ export function describeJobHeader(job, now = Date.now()) {
177
+ const outcome = [
178
+ job.exitCode !== undefined && job.exitCode !== null ? `exit ${job.exitCode}` : null,
179
+ job.signal ? `signal ${job.signal}` : null,
180
+ ].filter(Boolean).join(', ');
181
+ const lines = [`Job ${job.id} · ${job.agentName} · ${displayStatus(job, now)}${outcome ? ` (${outcome})` : ''}`];
182
+ lines.push(job.startedAt
183
+ ? ` started ${new Date(job.startedAt).toLocaleString()} in ${job.project}`
184
+ : ` scheduled for ${new Date(job.runAt).toLocaleString()} in ${job.project}`);
185
+ if (job.error) lines.push(` error: ${job.error}`);
186
+ return lines.join('\n');
169
187
  }
170
188
 
171
189
  export function renderAgentsReport(detected) {
@@ -200,11 +218,24 @@ export async function main(argv) {
200
218
  viewJobs(process.stdout);
201
219
  return;
202
220
  case 'logs': {
203
- const path = logFile(args.jobId);
204
- if (!existsSync(path)) {
205
- throw new Error(`No log for job ${args.jobId} yet (it appears at ${path} once the job runs).`);
221
+ let job = null;
222
+ try {
223
+ job = loadJob(args.jobId);
224
+ } catch {
225
+ // Job record already cleared; the log may still be around.
226
+ }
227
+ const path = job?.log ?? logFile(args.jobId);
228
+ // Header goes to stderr so "codewake logs id > out.txt" stays clean.
229
+ if (job) process.stderr.write(`${color.dim(describeJobHeader(job))}\n`);
230
+ if (existsSync(path)) {
231
+ process.stdout.write(readFileSync(path, 'utf8'));
232
+ } else if (!job) {
233
+ throw new Error(`No job or log found for ${args.jobId}.`);
234
+ } else if (job.status === 'pending') {
235
+ process.stderr.write(color.dim(`No output yet — the job launches ${describeRunAt(job.runAt)}.\n`));
236
+ } else {
237
+ process.stderr.write(color.dim('The job produced no output.\n'));
206
238
  }
207
- process.stdout.write(readFileSync(path, 'utf8'));
208
239
  return;
209
240
  }
210
241
  case 'cancel': {
@@ -227,8 +258,26 @@ export async function main(argv) {
227
258
  case 'wait':
228
259
  await waitJob(args.jobId);
229
260
  return;
230
- case 'schedule': {
231
- const { job, preview } = scheduleFromArgs(args);
261
+ case 'open': {
262
+ if (!process.stdin.isTTY) {
263
+ throw new Error('"codewake open" attaches an interactive UI and needs a terminal.');
264
+ }
265
+ const job = loadJob(args.jobId);
266
+ if (job.status === 'running') {
267
+ console.log(`${color.yellow('!')} Job ${job.id} is still running headless; you are opening the same session alongside it.`);
268
+ }
269
+ const prepared = prepareOpen(job);
270
+ if (prepared.note) console.log(`${color.yellow('!')} ${prepared.note}`);
271
+ console.log(color.dim(`Opening ${prepared.agent.name}: ${prepared.preview}`));
272
+ await attachOpen(prepared);
273
+ return;
274
+ }
275
+ case 'schedule':
276
+ case 'retry': {
277
+ const { job, preview, note } = args.command === 'retry'
278
+ ? retryFromSpec(args)
279
+ : scheduleFromSpec(args);
280
+ if (note) console.log(`${color.yellow('!')} ${note}`);
232
281
  if (args.dryRun) {
233
282
  console.log(`Would launch ${describeRunAt(job.runAt)} in ${job.project}:`);
234
283
  console.log(` ${preview}`);
@@ -236,7 +285,7 @@ export async function main(argv) {
236
285
  }
237
286
  console.log(`${color.green('✔')} Job ${color.bold(job.id)} scheduled ${describeRunAt(job.runAt)}.`);
238
287
  console.log(color.dim(` ${preview}`));
239
- console.log(color.dim(` Manage it with "codewake jobs" or "codewake cancel ${job.id}".`));
288
+ console.log(color.dim(` Watch it with "codewake jobs" and "codewake logs ${job.id}".`));
240
289
  return;
241
290
  }
242
291
  default:
package/lib/jobs.js CHANGED
@@ -12,8 +12,9 @@ import {
12
12
  import { homedir } from 'node:os';
13
13
  import { join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { which } from './agents.js';
15
+ import { loadAgents, which } from './agents.js';
16
16
  import { spawnAgent } from './proc.js';
17
+ import { resolveSessionPlacement } from './sessions.js';
17
18
 
18
19
  const CLI_BIN = fileURLToPath(new URL('../bin/codewake.js', import.meta.url));
19
20
  const OVERDUE_GRACE_MS = 60_000;
@@ -161,7 +162,7 @@ export function scheduleJob(spec, { dryRun = false, env = process.env } = {}) {
161
162
  if (dryRun) return job;
162
163
  saveJob(job, env);
163
164
  try {
164
- if (job.backend === 'systemd') armSystemdTimer(job, now);
165
+ if (job.backend === 'systemd') armSystemdTimer(job, now, env);
165
166
  else armDetachedWaiter(job, env);
166
167
  } catch (error) {
167
168
  rmSync(jobFile(job.id, env), { force: true });
@@ -180,13 +181,20 @@ export function systemdCalendarSpec(runAtMs, now = Date.now()) {
180
181
  `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
181
182
  }
182
183
 
183
- function armSystemdTimer(job, now) {
184
+ function armSystemdTimer(job, now, env) {
184
185
  const result = spawnSync('systemd-run', [
185
186
  '--user',
186
187
  `--unit=${unitName(job.id)}`,
187
188
  `--on-calendar=${systemdCalendarSpec(Date.parse(job.runAt), now)}`,
189
+ // Transient user timers default to a 1-minute accuracy window; short
190
+ // delays ("--in 5s") would otherwise fire up to a minute late.
191
+ '--timer-property=AccuracySec=1s',
188
192
  '--collect',
189
193
  '--quiet',
194
+ // The unit does not inherit this process's environment: point it at the
195
+ // same state (and config) that scheduled the job.
196
+ `--setenv=CODEWAKE_STATE_DIR=${stateDir(env)}`,
197
+ ...(env.CODEWAKE_CONFIG ? [`--setenv=CODEWAKE_CONFIG=${resolve(env.CODEWAKE_CONFIG)}`] : []),
190
198
  process.execPath,
191
199
  CLI_BIN,
192
200
  '--run',
@@ -214,26 +222,68 @@ function armDetachedWaiter(job, env) {
214
222
  saveJob(job, env);
215
223
  }
216
224
 
225
+ /**
226
+ * Re-check where a resume job must run right before launch: the project may
227
+ * have been moved, renamed or deleted between scheduling and firing, and the
228
+ * session's own store knows the directory it truly belongs to. Lookup is
229
+ * best effort — when nothing better is known the scheduled project stands,
230
+ * and the agent reports its own error if it cannot find the session.
231
+ */
232
+ function placementForRun(job, env) {
233
+ let placement = null;
234
+ if (job.mode === 'resume' && job.sessionId) {
235
+ try {
236
+ const agent = loadAgents({ env }).find((candidate) => candidate.id === job.agentId);
237
+ if (agent) {
238
+ placement = resolveSessionPlacement(agent, job.sessionId, {
239
+ project: job.project,
240
+ home: env.HOME || homedir(),
241
+ });
242
+ }
243
+ } catch {
244
+ // A broken config or a session that vanished must not stop the launch.
245
+ }
246
+ }
247
+ const project = placement?.project ?? job.project;
248
+ if (!existsSync(project)) {
249
+ return {
250
+ error: `Project directory ${project} no longer exists (was it moved or renamed?). ` +
251
+ `Reschedule with "codewake retry ${job.id} --project <dir>".`,
252
+ };
253
+ }
254
+ return { project, note: placement?.note ?? null };
255
+ }
256
+
217
257
  /** Launch the agent for a job right now. Resolves when the agent exits. */
218
258
  export function runJob(jobId, env = process.env) {
219
259
  const job = loadJob(jobId, env);
220
260
  if (job.status === 'cancelled') return Promise.resolve(job);
261
+ const placement = placementForRun(job, env);
262
+ if (placement.project) job.project = placement.project;
221
263
  job.status = 'running';
222
264
  job.startedAt = new Date().toISOString();
223
265
  job.log = logFile(job.id, env);
224
266
  saveJob(job, env);
225
267
  ensureDir(logsDir(env));
226
268
  const log = createWriteStream(job.log, { mode: 0o600 });
269
+ if (placement.note) log.write(`codewake: ${placement.note}\n`);
227
270
  return new Promise((resolvePromise) => {
228
271
  let settled = false;
229
272
  const finish = (patch) => {
230
273
  if (settled) return;
231
274
  settled = true;
275
+ // Failures land in the log too, so "codewake logs" always explains.
276
+ if (patch.error) log.write(`codewake: ${patch.error}\n`);
232
277
  Object.assign(job, patch, { finishedAt: new Date().toISOString() });
233
278
  saveJob(job, env);
234
- log.end();
235
- resolvePromise(job);
279
+ // Resolve only once the log is flushed to disk, so a "codewake logs"
280
+ // right after completion never reads a partial (or missing) file.
281
+ log.end(() => resolvePromise(job));
236
282
  };
283
+ if (placement.error) {
284
+ finish({ status: 'failed', error: placement.error });
285
+ return;
286
+ }
237
287
  let child;
238
288
  try {
239
289
  child = spawnAgent(job.bin, job.args, { cwd: job.project, stdio: ['ignore', 'pipe', 'pipe'] });
@@ -244,7 +294,8 @@ export function runJob(jobId, env = process.env) {
244
294
  child.stdout.pipe(log, { end: false });
245
295
  child.stderr.pipe(log, { end: false });
246
296
  child.on('error', (error) => finish({ status: 'failed', error: error.message }));
247
- child.on('exit', (code, signal) => finish({
297
+ // 'close' (not 'exit') guarantees stdout/stderr finished piping first.
298
+ child.on('close', (code, signal) => finish({
248
299
  status: code === 0 ? 'done' : 'failed',
249
300
  exitCode: code,
250
301
  signal: signal ?? undefined,
package/lib/registry.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * 'goose-sessions' | 'aider-history' | 'manual' | 'none'
13
13
  * resume command spec to resume an existing session (omit if unsupported)
14
14
  * create command spec to start a new session (omit if unsupported)
15
+ * open command spec to reopen a session in the agent's own interactive
16
+ * terminal UI, attached to the user's tty (omit if unsupported)
15
17
  *
16
18
  * Command specs:
17
19
  * args argv template; '{session}' and '{prompt}' are substituted as
@@ -33,6 +35,9 @@ export const BUILTIN_AGENTS = [
33
35
  args: ['-p', '{prompt}'],
34
36
  skip: ['--dangerously-skip-permissions'],
35
37
  },
38
+ open: {
39
+ args: ['--resume', '{session}'],
40
+ },
36
41
  },
37
42
  {
38
43
  id: 'codex',
@@ -47,6 +52,9 @@ export const BUILTIN_AGENTS = [
47
52
  args: ['exec', '{prompt}'],
48
53
  skip: ['--dangerously-bypass-approvals-and-sandbox'],
49
54
  },
55
+ open: {
56
+ args: ['resume', '{session}'],
57
+ },
50
58
  },
51
59
  {
52
60
  id: 'gemini',
@@ -71,6 +79,9 @@ export const BUILTIN_AGENTS = [
71
79
  args: ['-p', '{prompt}'],
72
80
  skip: ['--allow-all-tools'],
73
81
  },
82
+ open: {
83
+ args: ['--resume', '{session}'],
84
+ },
74
85
  },
75
86
  {
76
87
  id: 'cursor',
@@ -85,6 +96,9 @@ export const BUILTIN_AGENTS = [
85
96
  args: ['-p', '{prompt}'],
86
97
  skip: ['--force'],
87
98
  },
99
+ open: {
100
+ args: ['--resume', '{session}'],
101
+ },
88
102
  },
89
103
  {
90
104
  id: 'opencode',
@@ -111,6 +125,9 @@ export const BUILTIN_AGENTS = [
111
125
  args: ['--message', '{prompt}'],
112
126
  skip: ['--yes-always'],
113
127
  },
128
+ open: {
129
+ args: ['--restore-chat-history'],
130
+ },
114
131
  },
115
132
  {
116
133
  id: 'goose',
@@ -123,6 +140,9 @@ export const BUILTIN_AGENTS = [
123
140
  create: {
124
141
  args: ['run', '--text', '{prompt}'],
125
142
  },
143
+ open: {
144
+ args: ['session', '--name', '{session}', '--resume'],
145
+ },
126
146
  },
127
147
  {
128
148
  id: 'amp',
@@ -137,6 +157,9 @@ export const BUILTIN_AGENTS = [
137
157
  args: ['-x', '{prompt}'],
138
158
  skip: ['--dangerously-allow-all'],
139
159
  },
160
+ open: {
161
+ args: ['threads', 'continue', '{session}'],
162
+ },
140
163
  },
141
164
  {
142
165
  id: 'qwen',
package/lib/sessions.js CHANGED
@@ -24,6 +24,110 @@ export function encodeClaudeProject(project) {
24
24
  return project.replace(/[^a-zA-Z0-9]/g, '-');
25
25
  }
26
26
 
27
+ /**
28
+ * Look a session id up across everything the agent has stored on this
29
+ * machine, regardless of project. Returns:
30
+ * null lookup is unsupported or nothing could be
31
+ * searched, so nothing can be concluded
32
+ * { found: false } the store was searched and the id is not there
33
+ * { found: true, project } found; `project` is the directory the session
34
+ * was recorded in, or null when unknown
35
+ */
36
+ export function locateSession(agent, sessionId, { home = homedir() } = {}) {
37
+ const lookup = LOCATORS[agent.sessions];
38
+ if (!lookup || typeof sessionId !== 'string' || !/^[\w.-]+$/.test(sessionId)) return null;
39
+ try {
40
+ return lookup(sessionId, home);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Decide the directory a resume job must run from. Agents like Claude Code
48
+ * only find a session when launched from the project it belongs to, so a
49
+ * mismatched working directory fails with "No conversation found". Returns
50
+ * { project, note }; `note` explains the correction when the requested
51
+ * project was overridden. Throws when the session provably does not exist
52
+ * on this machine, or when its own directory is gone.
53
+ */
54
+ export function resolveSessionPlacement(agent, sessionId, { project, home = homedir() } = {}) {
55
+ const requested = resolve(project);
56
+ const located = locateSession(agent, sessionId, { home });
57
+ if (!located) return { project: requested, note: null };
58
+ if (!located.found) {
59
+ throw new Error(
60
+ `No ${agent.name} session "${sessionId}" exists on this machine. ` +
61
+ 'Double-check the id (run "codewake" and pick the session from the list).',
62
+ );
63
+ }
64
+ const actual = located.project ? resolve(located.project) : null;
65
+ if (!actual || actual === requested) return { project: requested, note: null };
66
+ if (!existsSync(actual)) {
67
+ throw new Error(
68
+ `Session ${sessionId} belongs to ${actual}, but that directory no longer exists. ` +
69
+ `${agent.name} can only resume it from there — restore the directory first.`,
70
+ );
71
+ }
72
+ return {
73
+ project: actual,
74
+ note: `Session ${sessionId} belongs to ${actual}; the job will run there instead of ${requested}.`,
75
+ };
76
+ }
77
+
78
+ const LOCATORS = {
79
+ 'claude-projects': (sessionId, home) => {
80
+ const root = join(home, '.claude', 'projects');
81
+ if (!existsSync(root)) return null;
82
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
83
+ if (!entry.isDirectory()) continue;
84
+ const path = join(root, entry.name, `${sessionId}.jsonl`);
85
+ if (existsSync(path)) return { found: true, project: claudeCwd(path) };
86
+ }
87
+ return { found: false };
88
+ },
89
+
90
+ 'codex-rollouts': (sessionId, home) => {
91
+ const root = join(home, '.codex', 'sessions');
92
+ if (!existsSync(root)) return null;
93
+ for (const { path, name } of walkFiles(root, /^rollout-.*\.jsonl$/)) {
94
+ const meta = firstJsonLine(path);
95
+ const payload = meta?.payload ?? meta;
96
+ const id = payload?.id
97
+ ?? /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/.exec(name)?.[1];
98
+ if (id !== sessionId) continue;
99
+ return { found: true, project: typeof payload?.cwd === 'string' ? payload.cwd : null };
100
+ }
101
+ return { found: false };
102
+ },
103
+
104
+ 'copilot-history': (sessionId, home) => {
105
+ const dir = join(home, '.copilot', 'history-session-state');
106
+ if (!existsSync(dir)) return null;
107
+ return { found: existsSync(join(dir, `${sessionId}.json`)), project: null };
108
+ },
109
+
110
+ 'goose-sessions': (sessionId, home) => {
111
+ const dir = join(home, '.local', 'share', 'goose', 'sessions');
112
+ if (!existsSync(dir)) return null;
113
+ return { found: existsSync(join(dir, `${sessionId}.jsonl`)), project: null };
114
+ },
115
+ };
116
+
117
+ /** First working directory recorded inside a Claude Code session file. */
118
+ function claudeCwd(path) {
119
+ for (const line of head(path).split('\n')) {
120
+ if (!line.trim()) continue;
121
+ try {
122
+ const entry = JSON.parse(line);
123
+ if (typeof entry.cwd === 'string' && entry.cwd) return entry.cwd;
124
+ } catch {
125
+ continue;
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
27
131
  const STRATEGIES = {
28
132
  none: () => [],
29
133
  manual: () => [],
package/lib/tui.js CHANGED
@@ -15,15 +15,27 @@ export const color = {
15
15
  magenta: paint(35),
16
16
  };
17
17
 
18
+ /** Name of the key that cancelled a prompt, e.g. 'ctrl+c', 'esc', 'q'. */
19
+ export function cancelKeyName(str, key = {}) {
20
+ if (key.ctrl && key.name === 'c') return 'ctrl+c';
21
+ if (key.ctrl && key.name === 'd') return 'ctrl+d';
22
+ if (key.name === 'escape') return 'esc';
23
+ return str ?? key.name ?? '';
24
+ }
25
+
18
26
  /**
19
27
  * Arrow-key select menu. Returns the chosen value, or null when the user
20
- * cancels (esc / ctrl+c / q). Choices are strings or
28
+ * cancels (esc / q / ctrl+c / ctrl+d). Choices are strings or
21
29
  * { label, value, hint } objects. Long lists scroll inside `pageSize`.
30
+ * With `cancelInfo: true` a cancellation resolves to
31
+ * { cancelled: true, key } instead of null, so callers can react to the
32
+ * specific key (the main menu's press-twice-to-exit hint needs it).
22
33
  */
23
34
  export function select(message, choices, {
24
35
  stdin = process.stdin,
25
36
  stdout = process.stdout,
26
37
  pageSize = 10,
38
+ cancelInfo = false,
27
39
  } = {}) {
28
40
  const items = choices.map((choice) =>
29
41
  typeof choice === 'string' ? { label: choice, value: choice } : choice,
@@ -60,7 +72,7 @@ export function select(message, choices, {
60
72
  stdout.write(`${lines.join('\n')}\n`);
61
73
  };
62
74
 
63
- const finish = (value) => {
75
+ const finish = (value, cancelKey = null) => {
64
76
  stdin.removeListener('keypress', onKeypress);
65
77
  if (raw) stdin.setRawMode(false);
66
78
  stdin.pause();
@@ -72,7 +84,7 @@ export function select(message, choices, {
72
84
  stdout.write(`${color.green('✔', stdout)} ${color.bold(message, stdout)} ${answer}\n`);
73
85
  stdout.write('\x1b[?25h');
74
86
  }
75
- resolvePromise(value);
87
+ resolvePromise(value === null && cancelInfo ? { cancelled: true, key: cancelKey } : value);
76
88
  };
77
89
 
78
90
  const move = (delta) => {
@@ -86,7 +98,10 @@ export function select(message, choices, {
86
98
  if (key.name === 'up' || str === 'k') move(-1);
87
99
  else if (key.name === 'down' || str === 'j') move(1);
88
100
  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);
101
+ else if (
102
+ key.name === 'escape' || str === 'q'
103
+ || (key.ctrl && (key.name === 'c' || key.name === 'd'))
104
+ ) finish(null, cancelKeyName(str, key));
90
105
  else if (/^[1-9]$/.test(str ?? '') && Number(str) <= items.length) {
91
106
  // Highlight only — instant-select would let a pasted answer like
92
107
  // "2h 30m" pick item 2 and leak "h 30m" into the next prompt.