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/README.md CHANGED
@@ -4,9 +4,11 @@
4
4
 
5
5
  `codewake` is an interactive CLI that detects the coding agents installed on your machine — Claude Code, OpenAI Codex CLI, GitHub Copilot CLI, Gemini CLI, Cursor CLI, OpenCode, Aider, Goose, Amp, Qwen Code — lets you pick a session (or start a new one), choose a prompt, and launches the agent at the time you choose. Hit a usage limit at 2 AM? Schedule the resume for when it resets and go to sleep.
6
6
 
7
- - **Interactive console** — arrow keys, sensible defaults, a summary before anything is scheduled.
7
+ - **Interactive console** — two entries: *Schedule / wake an agent* and *Waked agents*, a job browser with agent/text filters where every action is one key (open, retry, cancel, sweep) and the shortcuts are always visible. Leave with a double ctrl+c/ctrl+d.
8
+ - **Open a job's session** — jump from any job straight into the agent's own terminal UI (`claude --resume …`, `codex resume …`, …) to watch or take over what the agent did.
8
9
  - **Agent auto-detection** — scans your `PATH` for every supported agent; extend or override the list with a small JSON config.
9
10
  - **Session discovery** — lists your existing Claude Code and Codex sessions (with titles and timestamps) for the current project; other agents accept a session id or start fresh.
11
+ - **Right-place resumes** — agents like Claude Code can only resume a session from the project directory it belongs to. codewake locates the session across all your projects and runs the job from the right directory (and tells you), instead of failing later with "No conversation found". If the project moved between scheduling and launch, the job follows it.
10
12
  - **Job management** — list, inspect logs, cancel, and clean up scheduled jobs from the console or with subcommands.
11
13
  - **Suspend-safe scheduling** — timers track the wall clock, so a laptop that sleeps through the launch time fires the job right after waking instead of drifting.
12
14
  - **Cross-platform** — Linux (systemd user timers), macOS, and Windows (detached waiter, `.cmd`-shim aware); CI runs on all three.
@@ -65,12 +67,17 @@ From the console (`codewake`) or directly:
65
67
 
66
68
  ```bash
67
69
  codewake jobs # list scheduled and finished jobs
68
- codewake logs <job-id> # output captured from the agent run
70
+ codewake open <job-id> # reopen the job's session in its agent's own UI
71
+ codewake logs <job-id> # job status plus the output captured from the run
69
72
  codewake cancel <job-id> # cancel a pending job
73
+ codewake retry <job-id> # reschedule a failed/finished/overdue job
74
+ # (--now, --in, --at, --prompt, --project)
70
75
  codewake clear # drop finished/cancelled jobs from the list
71
76
  codewake agents # which agents were detected, and where
72
77
  ```
73
78
 
79
+ Or interactively: `codewake` → **Waked agents** browses every job — `↑↓` move, `enter` opens the session in the agent's own terminal, `r` retries (asks when + prompt), `x` cancels, `s` sweeps finished jobs, `f` filters by agent, `/` searches, `esc` goes back. The shortcut bar at the bottom always shows what each key does.
80
+
74
81
  ## Non-interactive scheduling
75
82
 
76
83
  Everything the console does is available as flags — handy for scripts:
@@ -127,7 +134,7 @@ Add or override agents in `~/.config/codewake/config.json` (or point `CODEWAKE_C
127
134
  - On **Linux with systemd**, jobs are armed with a transient user timer (`systemd-run --user --on-calendar=…`). Wall-clock timers keep ticking through laptop suspend and fire on resume if their time passed while the lid was closed. They survive the terminal closing; if your machine logs you out completely, enable lingering once: `loginctl enable-linger "$USER"`.
128
135
  - On **macOS, Windows, and Linux without systemd**, a small detached Node process waits for the launch time. It re-checks the wall clock every minute rather than sleeping once, so a machine that suspends overnight launches the job right after waking instead of drifting by the suspended time. It survives the terminal closing, but not a reboot.
129
136
 
130
- A session can only have **one pending job** at a time — scheduling a second resume for the same session is refused until you cancel the first, so two agents never race each other over one transcript. Jobs whose launch time passed without running (e.g. after a reboot) show up as `overdue` in `codewake jobs`, with how late they are reschedule them.
137
+ A session can only have **one pending job** at a time — scheduling a second resume for the same session is refused until you cancel the first, so two agents never race each other over one transcript. Jobs whose launch time passed without running (e.g. after a reboot) show up as `overdue` in `codewake jobs` reschedule them with `codewake retry <job-id>`.
131
138
 
132
139
  ### Where state and logs live
133
140
 
@@ -155,5 +162,3 @@ The test suite covers argument parsing, duration/time parsing, the agent registr
155
162
  ## License
156
163
 
157
164
  MIT
158
-
159
- This is a new line for testing purposes.
package/lib/actions.js ADDED
@@ -0,0 +1,165 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { buildCommand, commandPreview, detectAgents, loadAgents } from './agents.js';
4
+ import { cancelJob, displayStatus, loadJob, scheduleJob } from './jobs.js';
5
+ import { spawnAgent } from './proc.js';
6
+ import { resolveSessionPlacement } from './sessions.js';
7
+ import { describeRunAt, parseClockTime, parseDuration } from './time.js';
8
+
9
+ export const DEFAULT_RESUME_PROMPT = 'continue';
10
+
11
+ /** Turn a when-descriptor ({type: now|in|at}) or a Date into a launch Date. */
12
+ export function resolveRunAt(when, now = new Date()) {
13
+ if (when instanceof Date) return when;
14
+ if (when.type === 'in') return new Date(now.getTime() + parseDuration(when.value) * 1000);
15
+ if (when.type === 'at') return parseClockTime(when.value, now);
16
+ return now;
17
+ }
18
+
19
+ /**
20
+ * Validate and schedule a job from a spec: { agentId, sessionId, isNew,
21
+ * prompt, skipPermissions, project, when, bin, fallbackBin, dryRun }.
22
+ * Returns { job, preview, note }; `note` explains a corrected project.
23
+ */
24
+ export function scheduleFromSpec(args, env = process.env) {
25
+ const detected = detectAgents(loadAgents({ env }), { env });
26
+ const entry = detected.find((candidate) => candidate.agent.id === args.agentId);
27
+ if (!entry) {
28
+ const known = detected.map((candidate) => candidate.agent.id).join(', ');
29
+ throw new Error(`Unknown agent "${args.agentId}". Known agents: ${known}.`);
30
+ }
31
+ const bin = args.bin ?? entry.bin ?? args.fallbackBin ?? null;
32
+ if (!bin) {
33
+ throw new Error(`${entry.agent.name} is not installed (looked for: ${entry.agent.bins.join(', ')}). Use --bin to point at it.`);
34
+ }
35
+ const mode = args.isNew ? 'create' : 'resume';
36
+ const prompt = args.prompt?.trim() || DEFAULT_RESUME_PROMPT;
37
+ let project = args.project;
38
+ let note = null;
39
+ if (mode === 'resume') {
40
+ const placement = resolveSessionPlacement(entry.agent, args.sessionId, {
41
+ project,
42
+ home: env.HOME || homedir(),
43
+ });
44
+ project = placement.project;
45
+ note = placement.note;
46
+ }
47
+ const commandArgs = buildCommand(entry.agent, {
48
+ mode,
49
+ sessionId: args.sessionId,
50
+ prompt,
51
+ skipPermissions: args.skipPermissions,
52
+ });
53
+ const job = scheduleJob({
54
+ agent: entry.agent,
55
+ bin,
56
+ args: commandArgs,
57
+ mode,
58
+ sessionId: args.sessionId,
59
+ prompt,
60
+ skipPermissions: args.skipPermissions,
61
+ project,
62
+ runAt: resolveRunAt(args.when),
63
+ }, { dryRun: args.dryRun, env });
64
+ return { job, preview: commandPreview(bin, commandArgs), note };
65
+ }
66
+
67
+ /**
68
+ * Re-schedule an existing job through the normal validation path, so the
69
+ * session is located again and a moved project is corrected instead of
70
+ * reproducing the original failure. `args`: { jobId, when, prompt, project,
71
+ * dryRun } — prompt/project default to the previous job's values.
72
+ */
73
+ export function retryFromSpec(args, env = process.env) {
74
+ const previous = loadJob(args.jobId, env);
75
+ if (previous.status === 'running') {
76
+ throw new Error(`Job ${args.jobId} is still running; nothing to retry.`);
77
+ }
78
+ if (previous.status === 'pending') {
79
+ if (displayStatus(previous) !== 'overdue') {
80
+ throw new Error(
81
+ `Job ${args.jobId} is still pending (launching ${describeRunAt(previous.runAt)}). ` +
82
+ `Cancel it first: codewake cancel ${args.jobId}.`,
83
+ );
84
+ }
85
+ // Its launch window silently passed (reboot, killed waiter): disarm the
86
+ // stale schedule before arming a fresh one.
87
+ cancelJob(args.jobId, env);
88
+ }
89
+ return scheduleFromSpec({
90
+ agentId: previous.agentId,
91
+ sessionId: previous.sessionId,
92
+ isNew: previous.mode === 'create',
93
+ prompt: args.prompt ?? previous.prompt,
94
+ skipPermissions: previous.skipPermissions,
95
+ project: args.project ?? previous.project,
96
+ when: args.when,
97
+ // The bin the job was created with wins (it may have been a --bin
98
+ // override); if it disappeared, fall back to fresh detection.
99
+ bin: existsSync(previous.bin) ? previous.bin : null,
100
+ fallbackBin: previous.bin,
101
+ dryRun: args.dryRun,
102
+ }, env);
103
+ }
104
+
105
+ /**
106
+ * Work out how to reopen a job's session in its agent's own interactive
107
+ * terminal UI. Returns { agent, bin, args, project, note, preview }.
108
+ * Throws with a human explanation when the job cannot be opened.
109
+ */
110
+ export function prepareOpen(job, env = process.env) {
111
+ if (!job.sessionId) {
112
+ throw new Error(`Job ${job.id} started a fresh session; there is no session id to reopen.`);
113
+ }
114
+ const detected = detectAgents(loadAgents({ env }), { env });
115
+ const entry = detected.find((candidate) => candidate.agent.id === job.agentId);
116
+ if (!entry) {
117
+ throw new Error(`Agent "${job.agentId}" is not configured anymore (check your codewake config).`);
118
+ }
119
+ const bin = entry.bin ?? (existsSync(job.bin) ? job.bin : null);
120
+ if (!bin) {
121
+ throw new Error(`${entry.agent.name} is not installed anymore (looked for: ${entry.agent.bins.join(', ')}).`);
122
+ }
123
+ const args = buildCommand(entry.agent, { mode: 'open', sessionId: job.sessionId });
124
+ // Same relocation as launches: the agent only finds the session from the
125
+ // project it belongs to, and the directory may have moved since.
126
+ let placement = null;
127
+ try {
128
+ placement = resolveSessionPlacement(entry.agent, job.sessionId, {
129
+ project: job.project,
130
+ home: env.HOME || homedir(),
131
+ });
132
+ } catch {
133
+ // Best effort: open from the recorded project and let the agent decide.
134
+ }
135
+ const project = placement?.project ?? job.project;
136
+ if (!existsSync(project)) {
137
+ throw new Error(`Project directory ${project} no longer exists (was it moved or renamed?).`);
138
+ }
139
+ return {
140
+ agent: entry.agent,
141
+ bin,
142
+ args,
143
+ project,
144
+ note: placement?.note ?? null,
145
+ preview: commandPreview(bin, args),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Hand the terminal over to the agent for a prepared open. Resolves with
151
+ * the agent's exit code once the user leaves it.
152
+ */
153
+ export function attachOpen(prepared, { stdio = 'inherit' } = {}) {
154
+ return new Promise((resolvePromise, rejectPromise) => {
155
+ let child;
156
+ try {
157
+ child = spawnAgent(prepared.bin, prepared.args, { cwd: prepared.project, stdio });
158
+ } catch (error) {
159
+ rejectPromise(error);
160
+ return;
161
+ }
162
+ child.on('error', rejectPromise);
163
+ child.on('exit', (code) => resolvePromise(code ?? 0));
164
+ });
165
+ }
package/lib/agents.js CHANGED
@@ -41,7 +41,7 @@ function validateAgent(agent, source) {
41
41
  if (!agent.name || typeof agent.name !== 'string') throw problem('missing "name".');
42
42
  if (!Array.isArray(agent.bins) || !agent.bins.length) throw problem('"bins" must be a non-empty array.');
43
43
  if (!agent.resume && !agent.create) throw problem('needs a "resume" or "create" command spec.');
44
- for (const key of ['resume', 'create']) {
44
+ for (const key of ['resume', 'create', 'open']) {
45
45
  const spec = agent[key];
46
46
  if (spec && (!Array.isArray(spec.args) || !spec.args.length)) {
47
47
  throw problem(`"${key}.args" must be a non-empty array.`);
@@ -62,9 +62,14 @@ export function which(command, env = process.env) {
62
62
  if (command.includes('/') || command.includes('\\')) {
63
63
  return tryPath(isAbsolute(command) ? command : resolve(command));
64
64
  }
65
- const extensions = process.platform === 'win32'
66
- ? (env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').map((ext) => ext.toLowerCase())
67
- : [''];
65
+ let extensions = [''];
66
+ if (process.platform === 'win32') {
67
+ extensions = (env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').map((ext) => ext.toLowerCase());
68
+ // "claude.cmd" already carries its extension: probe the bare name too,
69
+ // not just claude.cmd.exe / claude.cmd.cmd / ...
70
+ const lower = command.toLowerCase();
71
+ if (extensions.some((ext) => ext && lower.endsWith(ext))) extensions = ['', ...extensions];
72
+ }
68
73
  for (const dir of (env.PATH || '').split(delimiter)) {
69
74
  if (!dir) continue;
70
75
  for (const ext of extensions) {
@@ -90,15 +95,22 @@ export function detectAgents(agents, { env = process.env } = {}) {
90
95
  });
91
96
  }
92
97
 
98
+ const MODE_ACTIONS = {
99
+ resume: 'resuming sessions',
100
+ create: 'starting new sessions',
101
+ open: 'opening sessions interactively',
102
+ };
103
+
93
104
  /**
94
- * Build the argv for a job. Placeholders are substituted inside argv
95
- * elements the command is spawned directly, never through a shell.
105
+ * Build the argv for a job. `mode` is 'resume', 'create' or 'open' (the
106
+ * agent's own interactive terminal attached to a session). Placeholders are
107
+ * substituted inside argv elements — the command is spawned directly, never
108
+ * through a shell.
96
109
  */
97
110
  export function buildCommand(agent, { mode, sessionId, prompt, skipPermissions = false }) {
98
- const spec = mode === 'resume' ? agent.resume : agent.create;
111
+ const spec = agent[mode];
99
112
  if (!spec) {
100
- const action = mode === 'resume' ? 'resuming sessions' : 'starting new sessions';
101
- throw new Error(`${agent.name} does not support ${action} from codewake.`);
113
+ throw new Error(`${agent.name} does not support ${MODE_ACTIONS[mode] ?? mode} from codewake.`);
102
114
  }
103
115
  // Single-pass substitution: substituted values are never re-scanned, so a
104
116
  // session id containing the literal text "{prompt}" (or vice versa) cannot
package/lib/browser.js ADDED
@@ -0,0 +1,347 @@
1
+ import { homedir } from 'node:os';
2
+ import { emitKeypressEvents } from 'node:readline';
3
+ import {
4
+ attachOpen,
5
+ DEFAULT_RESUME_PROMPT,
6
+ prepareOpen,
7
+ retryFromSpec,
8
+ } from './actions.js';
9
+ import { cancelJob, clearJobs, displayStatus, listJobs } from './jobs.js';
10
+ import { describeRunAt, parseClockTime, parseDuration } from './time.js';
11
+ import { cancelKeyName, color, confirm, input, select } from './tui.js';
12
+
13
+ const EXIT_WINDOW_MS = 5000;
14
+ const PAGE_SIZE = 10;
15
+
16
+ /** Shorten a path for display: home becomes ~, long paths keep their tail. */
17
+ export function shortenPath(path, max, home = homedir()) {
18
+ let short = path;
19
+ if (home && short.startsWith(home)) short = `~${short.slice(home.length)}`;
20
+ if (short.length > max) short = `…${short.slice(short.length - max + 1)}`;
21
+ return short;
22
+ }
23
+
24
+ const truncate = (text, max) =>
25
+ (text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text);
26
+
27
+ /**
28
+ * Fixed-width jobs table that always fits `width` columns: the PROJECT
29
+ * column absorbs the remaining space (shortened from the left), and is
30
+ * dropped entirely when the terminal is too narrow for it. With
31
+ * `selected >= 0` that row gets a pointer and highlight (browser mode).
32
+ */
33
+ export function renderJobsTable(jobs, now = Date.now(), {
34
+ width = process.stdout.columns || 120,
35
+ selected = -1,
36
+ } = {}) {
37
+ const statusColor = { pending: color.cyan, running: color.yellow, done: color.green, failed: color.red, cancelled: color.dim, overdue: color.red };
38
+ const pointerPad = selected >= 0 ? 2 : 0;
39
+ const rows = jobs.map((job) => [
40
+ job.id,
41
+ truncate(job.agentName, 20),
42
+ displayStatus(job, now),
43
+ truncate(job.status === 'pending' ? describeRunAt(job.runAt, new Date(now)) : new Date(job.runAt).toLocaleString(), 28),
44
+ job.mode === 'resume' ? truncate(job.sessionId ?? '', 12) : 'new session',
45
+ job.project,
46
+ ]);
47
+ let header = ['ID', 'AGENT', 'STATUS', 'LAUNCH', 'SESSION', 'PROJECT'];
48
+ const columnWidth = (column) =>
49
+ Math.max(header[column].length, ...rows.map((row) => String(row[column]).length));
50
+ const gap = 2;
51
+ const fixedWidth = header.slice(0, -1).reduce((sum, _title, column) => sum + columnWidth(column) + gap, 0);
52
+ const projectSpace = width - pointerPad - fixedWidth;
53
+ if (projectSpace >= 'PROJECT'.length) {
54
+ for (const row of rows) row[5] = shortenPath(row[5], projectSpace);
55
+ } else {
56
+ header = header.slice(0, 5);
57
+ for (const row of rows) row.length = 5;
58
+ }
59
+ const widths = header.map((_title, column) => columnWidth(column));
60
+ const line = (cells, decorate, isSelected = false) => cells
61
+ .map((cell, column) => {
62
+ const text = String(cell).padEnd(widths[column]);
63
+ if (decorate && column === 2) return (statusColor[cell] ?? color.dim)(text);
64
+ return isSelected ? color.cyan(text) : text;
65
+ })
66
+ .join(' '.repeat(gap))
67
+ .trimEnd();
68
+ const pointer = (i) => {
69
+ if (selected < 0) return '';
70
+ return i === selected ? color.cyan('❯ ') : ' ';
71
+ };
72
+ return [
73
+ color.dim(`${selected >= 0 ? ' ' : ''}${line(header)}`),
74
+ ...rows.map((row, i) => `${pointer(i)}${line(row, true, i === selected)}`),
75
+ ].join('\n');
76
+ }
77
+
78
+ /** Ask when a job should launch: now, after a delay, or at a clock time. */
79
+ export async function askWhen(io) {
80
+ const kind = await select('When should it launch?', [
81
+ { label: 'Right now', value: 'now' },
82
+ { label: 'After a delay', value: 'in', hint: 'e.g. 2h 30m' },
83
+ { label: 'At a specific time', value: 'at', hint: 'e.g. 14:30' },
84
+ ], io);
85
+ if (!kind) return null;
86
+ if (kind === 'now') return new Date();
87
+ if (kind === 'in') {
88
+ const text = await input('Delay', {
89
+ required: true,
90
+ validate: (value) => tryError(() => parseDuration(value)),
91
+ ...io,
92
+ });
93
+ if (text === null) return null;
94
+ return new Date(Date.now() + parseDuration(text) * 1000);
95
+ }
96
+ const text = await input('Time (24h)', {
97
+ required: true,
98
+ validate: (value) => tryError(() => parseClockTime(value)),
99
+ ...io,
100
+ });
101
+ if (text === null) return null;
102
+ return parseClockTime(text);
103
+ }
104
+
105
+ function tryError(fn) {
106
+ try {
107
+ fn();
108
+ return null;
109
+ } catch (error) {
110
+ return error.message;
111
+ }
112
+ }
113
+
114
+ const HINTS = '↑↓ move · enter open · r retry · x cancel · s sweep finished · f agent · / search · esc back · ctrl+c ×2 exit';
115
+ const SEARCH_HINTS = '(enter keep · esc clear · ↑↓ still move)';
116
+
117
+ /**
118
+ * Interactive job browser ("Waked agents"): navigate every job, filter by
119
+ * agent (f) or free text (/), open the selected job's session in its
120
+ * agent's own terminal UI (enter), retry (r), cancel (x) and sweep the
121
+ * finished ones (s). Resolves 'menu' (esc/q) or 'exit' (double ctrl+c/d).
122
+ */
123
+ export function browseJobs({ io = {}, env = process.env } = {}) {
124
+ const stdin = io.stdin ?? process.stdin;
125
+ const stdout = io.stdout ?? process.stdout;
126
+ // Tests drive fake streams; only a real terminal can be handed to agents.
127
+ const attachedStdio = stdin === process.stdin && stdout === process.stdout ? 'inherit' : 'ignore';
128
+
129
+ return new Promise((resolvePromise) => {
130
+ let jobs = listJobs(env);
131
+ let index = 0;
132
+ let top = 0;
133
+ let agentFilter = null;
134
+ let search = '';
135
+ let searchActive = false;
136
+ let flash = null;
137
+ let renderedLines = 0;
138
+ let busy = false;
139
+ let lastCancel = { key: null, at: 0 };
140
+
141
+ emitKeypressEvents(stdin);
142
+ const raw = typeof stdin.setRawMode === 'function';
143
+
144
+ const agentChoices = () => [...new Map(jobs.map((job) => [job.agentId, job.agentName])).entries()];
145
+
146
+ const visible = () => jobs.filter((job) => {
147
+ if (agentFilter && job.agentId !== agentFilter) return false;
148
+ if (!search) return true;
149
+ const haystack = [
150
+ job.id, job.agentName, job.sessionId ?? '', job.prompt ?? '', job.project, displayStatus(job),
151
+ ].join(' ').toLowerCase();
152
+ return haystack.includes(search.toLowerCase());
153
+ });
154
+
155
+ const render = () => {
156
+ const rows = visible();
157
+ if (index >= rows.length) index = Math.max(0, rows.length - 1);
158
+ if (index < top) top = index;
159
+ if (index >= top + PAGE_SIZE) top = index - PAGE_SIZE + 1;
160
+ if (top > 0 && top + PAGE_SIZE > rows.length) top = Math.max(0, rows.length - PAGE_SIZE);
161
+ if (renderedLines) stdout.write(`\x1b[${renderedLines}A`);
162
+ if (stdout.isTTY) stdout.write('\x1b[0J');
163
+ const width = stdout.columns || 120;
164
+ const filters = [
165
+ agentFilter ? `agent: ${agentChoices().find(([id]) => id === agentFilter)?.[1] ?? agentFilter}` : null,
166
+ search ? `search: "${search}"` : null,
167
+ ].filter(Boolean).join(' · ');
168
+ const lines = [
169
+ `${color.bold('Waked agents', stdout)} ${color.dim(`${rows.length}/${jobs.length} job${jobs.length === 1 ? '' : 's'}${filters ? ` · ${filters}` : ''}`, stdout)}`,
170
+ ];
171
+ if (!rows.length) {
172
+ lines.push(color.dim(jobs.length ? ' Nothing matches the current filters.' : ' No jobs yet — schedule one first.', stdout));
173
+ } else {
174
+ if (top > 0) lines.push(color.dim(` ↑ ${top} more`, stdout));
175
+ const page = rows.slice(top, top + PAGE_SIZE);
176
+ lines.push(...renderJobsTable(page, Date.now(), { width, selected: index - top }).split('\n'));
177
+ const below = rows.length - Math.min(rows.length, top + PAGE_SIZE);
178
+ if (below > 0) lines.push(color.dim(` ↓ ${below} more`, stdout));
179
+ }
180
+ if (flash) lines.push(...flash.split('\n'));
181
+ lines.push(color.dim(searchActive ? `search: ${search}▌ ${SEARCH_HINTS}` : HINTS, stdout));
182
+ renderedLines = lines.length;
183
+ stdout.write(`${lines.join('\n')}\n`);
184
+ };
185
+
186
+ const attach = () => {
187
+ if (raw) stdin.setRawMode(true);
188
+ stdin.resume();
189
+ stdin.on('keypress', onKeypress);
190
+ if (stdout.isTTY) stdout.write('\x1b[?25l');
191
+ render();
192
+ };
193
+
194
+ const detach = () => {
195
+ stdin.removeListener('keypress', onKeypress);
196
+ if (raw) stdin.setRawMode(false);
197
+ stdin.pause();
198
+ if (stdout.isTTY) {
199
+ stdout.write(`\x1b[${renderedLines}A\x1b[0J`);
200
+ stdout.write('\x1b[?25h');
201
+ }
202
+ renderedLines = 0;
203
+ };
204
+
205
+ const finish = (result) => {
206
+ detach();
207
+ resolvePromise(result);
208
+ };
209
+
210
+ /** Leave the raw-key loop, run a prompt-based flow, come back and redraw. */
211
+ const dialog = (fn) => {
212
+ busy = true;
213
+ detach();
214
+ (async () => {
215
+ try {
216
+ await fn();
217
+ } catch (error) {
218
+ flash = `${color.red('✘', stdout)} ${error.message}`;
219
+ } finally {
220
+ jobs = listJobs(env);
221
+ busy = false;
222
+ attach();
223
+ }
224
+ })();
225
+ };
226
+
227
+ const openAction = (job) => dialog(async () => {
228
+ if (job.status === 'running') {
229
+ const anyway = await confirm(
230
+ `Job ${job.id} is still running headless — open the same session alongside it?`,
231
+ { def: false, stdin, stdout },
232
+ );
233
+ if (!anyway) return;
234
+ }
235
+ const prepared = prepareOpen(job, env);
236
+ if (prepared.note) stdout.write(`${color.yellow('!', stdout)} ${prepared.note}\n`);
237
+ stdout.write(color.dim(`Opening ${prepared.agent.name} — leave it to come back here.\n`, stdout));
238
+ const code = await attachOpen(prepared, { stdio: attachedStdio });
239
+ flash = color.dim(`${prepared.agent.name} closed (exit ${code}).`, stdout);
240
+ });
241
+
242
+ const retryAction = (job) => dialog(async () => {
243
+ const status = displayStatus(job);
244
+ if (!['failed', 'done', 'cancelled', 'overdue'].includes(status)) {
245
+ flash = `${color.yellow('!', stdout)} Job ${job.id} is ${status}; only finished or overdue jobs can be retried.`;
246
+ return;
247
+ }
248
+ const runAt = await askWhen({ stdin, stdout });
249
+ if (runAt === null) return;
250
+ const prompt = job.mode === 'resume'
251
+ ? await input('Prompt to send', { def: job.prompt || DEFAULT_RESUME_PROMPT, stdin, stdout })
252
+ : await input('Prompt for the new session', { def: job.prompt, required: true, stdin, stdout });
253
+ if (prompt === null) return;
254
+ const { job: fresh, note } = retryFromSpec(
255
+ { jobId: job.id, when: runAt, prompt, project: null, dryRun: false },
256
+ env,
257
+ );
258
+ flash = [
259
+ note ? `${color.yellow('!', stdout)} ${note}` : null,
260
+ `${color.green('✔', stdout)} Rescheduled as ${color.bold(fresh.id, stdout)} (launching ${describeRunAt(fresh.runAt)}).`,
261
+ ].filter(Boolean).join('\n');
262
+ });
263
+
264
+ const cancelAction = (job) => dialog(async () => {
265
+ if (job.status !== 'pending') {
266
+ flash = `${color.yellow('!', stdout)} Job ${job.id} is ${displayStatus(job)}; only pending jobs can be cancelled.`;
267
+ return;
268
+ }
269
+ const sure = await confirm(`Cancel ${job.id}?`, { def: true, stdin, stdout });
270
+ if (!sure) return;
271
+ const { warning } = cancelJob(job.id, env);
272
+ flash = warning
273
+ ? `${color.yellow('!', stdout)} Cancelled ${job.id} — ${warning}`
274
+ : `${color.green('✔', stdout)} Cancelled ${job.id}.`;
275
+ });
276
+
277
+ const sweepAction = () => dialog(async () => {
278
+ const finished = jobs.filter((job) => ['done', 'failed', 'cancelled'].includes(job.status));
279
+ if (!finished.length) {
280
+ flash = color.dim('Nothing to clean up.', stdout);
281
+ return;
282
+ }
283
+ const sure = await confirm(
284
+ `Remove ${finished.length} finished job${finished.length === 1 ? '' : 's'}?`,
285
+ { def: true, stdin, stdout },
286
+ );
287
+ if (!sure) return;
288
+ const removed = clearJobs(env);
289
+ flash = `${color.green('✔', stdout)} Removed ${removed.length} job${removed.length === 1 ? '' : 's'}.`;
290
+ });
291
+
292
+ const move = (delta) => {
293
+ const rows = visible();
294
+ if (!rows.length) return;
295
+ index = (index + delta + rows.length) % rows.length;
296
+ render();
297
+ };
298
+
299
+ const onKeypress = (str, key = {}) => {
300
+ if (busy) return;
301
+ if (key.ctrl && (key.name === 'c' || key.name === 'd')) {
302
+ const name = cancelKeyName(str, key);
303
+ const now = Date.now();
304
+ if (lastCancel.key === name && now - lastCancel.at <= EXIT_WINDOW_MS) {
305
+ finish('exit');
306
+ return;
307
+ }
308
+ lastCancel = { key: name, at: now };
309
+ flash = color.dim(`Press ${name} again within 5s to exit.`, stdout);
310
+ render();
311
+ return;
312
+ }
313
+ if (key.name === 'up') { move(-1); return; }
314
+ if (key.name === 'down') { move(1); return; }
315
+ if (searchActive) {
316
+ if (key.name === 'return' || key.name === 'enter') searchActive = false;
317
+ else if (key.name === 'escape') { searchActive = false; search = ''; index = 0; top = 0; }
318
+ else if (key.name === 'backspace') search = search.slice(0, -1);
319
+ else if (str && !key.ctrl && !key.meta && str >= ' ') { search += str; index = 0; top = 0; }
320
+ else return;
321
+ render();
322
+ return;
323
+ }
324
+ if (key.name === 'escape' || str === 'q') { finish('menu'); return; }
325
+ if (str === 'k') { move(-1); return; }
326
+ if (str === 'j') { move(1); return; }
327
+ if (str === '/') { searchActive = true; flash = null; render(); return; }
328
+ if (str === 'f') {
329
+ const ids = agentChoices().map(([id]) => id);
330
+ const at = agentFilter ? ids.indexOf(agentFilter) : -1;
331
+ agentFilter = at + 1 >= ids.length ? null : ids[at + 1];
332
+ index = 0;
333
+ top = 0;
334
+ render();
335
+ return;
336
+ }
337
+ if (str === 's') { flash = null; sweepAction(); return; }
338
+ const job = visible()[index];
339
+ if (!job) return;
340
+ if (key.name === 'return' || key.name === 'enter') { flash = null; openAction(job); return; }
341
+ if (str === 'r') { flash = null; retryAction(job); return; }
342
+ if (str === 'x') { flash = null; cancelAction(job); return; }
343
+ };
344
+
345
+ attach();
346
+ });
347
+ }