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 +10 -5
- package/lib/actions.js +165 -0
- package/lib/agents.js +21 -9
- package/lib/browser.js +347 -0
- package/lib/cli.js +98 -49
- package/lib/jobs.js +57 -6
- package/lib/registry.js +23 -0
- package/lib/sessions.js +104 -0
- package/lib/tui.js +19 -4
- package/lib/wizard.js +45 -135
- package/package.json +2 -2
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 {
|
|
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
|
-
|
|
14
|
+
displayStatus,
|
|
15
|
+
loadJob,
|
|
9
16
|
logFile,
|
|
10
17
|
runJob,
|
|
11
|
-
scheduleJob,
|
|
12
18
|
waitJob,
|
|
13
19
|
} from './jobs.js';
|
|
14
|
-
import { describeRunAt
|
|
20
|
+
import { describeRunAt } from './time.js';
|
|
15
21
|
import { color } from './tui.js';
|
|
16
22
|
import { runWizard, viewJobs } from './wizard.js';
|
|
17
23
|
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 '
|
|
231
|
-
|
|
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(`
|
|
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
|
|
235
|
-
|
|
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
|
-
|
|
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 /
|
|
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 (
|
|
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.
|