codewake 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/jobs.js ADDED
@@ -0,0 +1,327 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import {
3
+ chmodSync,
4
+ createWriteStream,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { which } from './agents.js';
16
+ import { spawnAgent } from './proc.js';
17
+
18
+ const CLI_BIN = fileURLToPath(new URL('../bin/codewake.js', import.meta.url));
19
+ const OVERDUE_GRACE_MS = 60_000;
20
+ const DEFAULT_POLL_MS = 60_000;
21
+
22
+ /** Job state directory, per platform convention. */
23
+ export function stateDir(env = process.env) {
24
+ if (env.CODEWAKE_STATE_DIR) return resolve(env.CODEWAKE_STATE_DIR);
25
+ if (process.platform === 'win32') {
26
+ const base = env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local');
27
+ return join(base, 'codewake');
28
+ }
29
+ if (process.platform === 'darwin') {
30
+ return join(homedir(), 'Library', 'Application Support', 'codewake');
31
+ }
32
+ const base = env.XDG_STATE_HOME || join(homedir(), '.local', 'state');
33
+ return join(base, 'codewake');
34
+ }
35
+
36
+ /** Where job execution logs go, per platform convention. */
37
+ export function logsDir(env = process.env) {
38
+ if (env.CODEWAKE_STATE_DIR) return join(resolve(env.CODEWAKE_STATE_DIR), 'logs');
39
+ if (process.platform === 'win32') {
40
+ const base = env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local');
41
+ return join(base, 'codewake', 'Logs');
42
+ }
43
+ if (process.platform === 'darwin') {
44
+ return join(homedir(), 'Library', 'Logs', 'codewake');
45
+ }
46
+ const base = env.XDG_STATE_HOME || join(homedir(), '.local', 'state');
47
+ return join(base, 'codewake', 'logs');
48
+ }
49
+
50
+ function assertJobId(jobId) {
51
+ if (!jobId || !/^[a-z0-9-]+$/i.test(jobId)) throw new Error('Provide a valid job id.');
52
+ return jobId;
53
+ }
54
+
55
+ export function jobFile(jobId, env = process.env) {
56
+ return join(stateDir(env), `${assertJobId(jobId)}.json`);
57
+ }
58
+
59
+ export function logFile(jobId, env = process.env) {
60
+ return join(logsDir(env), `${assertJobId(jobId)}.log`);
61
+ }
62
+
63
+ function unitName(jobId) {
64
+ return `codewake-${jobId}`;
65
+ }
66
+
67
+ export function makeJobId() {
68
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
69
+ }
70
+
71
+ function ensureDir(dir) {
72
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
73
+ try {
74
+ // mkdirSync does not tighten a pre-existing directory.
75
+ chmodSync(dir, 0o700);
76
+ } catch {
77
+ // Best effort (e.g. Windows).
78
+ }
79
+ }
80
+
81
+ export function saveJob(job, env = process.env) {
82
+ ensureDir(stateDir(env));
83
+ writeFileSync(jobFile(job.id, env), `${JSON.stringify(job, null, 2)}\n`, { mode: 0o600 });
84
+ return job;
85
+ }
86
+
87
+ export function loadJob(jobId, env = process.env) {
88
+ const path = jobFile(jobId, env);
89
+ if (!existsSync(path)) throw new Error(`Job ${jobId} does not exist.`);
90
+ return JSON.parse(readFileSync(path, 'utf8'));
91
+ }
92
+
93
+ export function listJobs(env = process.env) {
94
+ const dir = stateDir(env);
95
+ if (!existsSync(dir)) return [];
96
+ return readdirSync(dir)
97
+ .filter((file) => file.endsWith('.json'))
98
+ .map((file) => JSON.parse(readFileSync(join(dir, file), 'utf8')))
99
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
100
+ }
101
+
102
+ /** Remove finished jobs (done/failed/cancelled) and their logs. */
103
+ export function clearJobs(env = process.env) {
104
+ const removed = [];
105
+ for (const job of listJobs(env)) {
106
+ if (['done', 'failed', 'cancelled'].includes(job.status)) {
107
+ rmSync(jobFile(job.id, env), { force: true });
108
+ rmSync(logFile(job.id, env), { force: true });
109
+ removed.push(job);
110
+ }
111
+ }
112
+ return removed;
113
+ }
114
+
115
+ export function pickBackend(env = process.env) {
116
+ if (env.CODEWAKE_BACKEND === 'systemd' || env.CODEWAKE_BACKEND === 'spawn') {
117
+ return env.CODEWAKE_BACKEND;
118
+ }
119
+ return process.platform === 'linux' && which('systemd-run', env) ? 'systemd' : 'spawn';
120
+ }
121
+
122
+ /** Pending job already scheduled for the same agent session, if any. */
123
+ export function findPendingForSession(agentId, sessionId, env = process.env) {
124
+ if (!sessionId) return null;
125
+ return listJobs(env).find(
126
+ (job) => job.status === 'pending' && job.agentId === agentId && job.sessionId === sessionId,
127
+ ) ?? null;
128
+ }
129
+
130
+ /**
131
+ * Persist and arm a job. `spec` carries agent/bin/args/mode/sessionId/
132
+ * prompt/skipPermissions/project/runAt. With `dryRun` nothing is written.
133
+ */
134
+ export function scheduleJob(spec, { dryRun = false, env = process.env } = {}) {
135
+ const now = Date.now();
136
+ const runAt = spec.runAt instanceof Date ? spec.runAt : new Date(spec.runAt ?? now);
137
+ if (!existsSync(spec.project)) throw new Error(`Project directory does not exist: ${spec.project}`);
138
+ const duplicate = findPendingForSession(spec.agent.id, spec.sessionId ?? null, env);
139
+ if (duplicate) {
140
+ throw new Error(
141
+ `Session ${spec.sessionId} already has a pending job (${duplicate.id}, launching ${new Date(duplicate.runAt).toLocaleString()}). ` +
142
+ `Cancel it first with "codewake cancel ${duplicate.id}".`,
143
+ );
144
+ }
145
+ const job = {
146
+ id: makeJobId(),
147
+ agentId: spec.agent.id,
148
+ agentName: spec.agent.name,
149
+ bin: spec.bin,
150
+ args: spec.args,
151
+ mode: spec.mode,
152
+ sessionId: spec.sessionId ?? null,
153
+ prompt: spec.prompt,
154
+ skipPermissions: Boolean(spec.skipPermissions),
155
+ project: spec.project,
156
+ createdAt: new Date(now).toISOString(),
157
+ runAt: runAt.toISOString(),
158
+ backend: pickBackend(env),
159
+ status: 'pending',
160
+ };
161
+ if (dryRun) return job;
162
+ saveJob(job, env);
163
+ try {
164
+ if (job.backend === 'systemd') armSystemdTimer(job, now);
165
+ else armDetachedWaiter(job, env);
166
+ } catch (error) {
167
+ rmSync(jobFile(job.id, env), { force: true });
168
+ throw error;
169
+ }
170
+ return job;
171
+ }
172
+
173
+ /** "YYYY-MM-DD HH:MM:SS" in local time, for systemd's OnCalendar. */
174
+ export function systemdCalendarSpec(runAtMs, now = Date.now()) {
175
+ // Wall-clock timers keep ticking through suspend and fire on resume;
176
+ // --on-active uses the monotonic clock, which pauses while suspended.
177
+ const at = new Date(Math.max(runAtMs, now + 5000));
178
+ const pad = (value) => String(value).padStart(2, '0');
179
+ return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ` +
180
+ `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
181
+ }
182
+
183
+ function armSystemdTimer(job, now) {
184
+ const result = spawnSync('systemd-run', [
185
+ '--user',
186
+ `--unit=${unitName(job.id)}`,
187
+ `--on-calendar=${systemdCalendarSpec(Date.parse(job.runAt), now)}`,
188
+ '--collect',
189
+ '--quiet',
190
+ process.execPath,
191
+ CLI_BIN,
192
+ '--run',
193
+ job.id,
194
+ ], { encoding: 'utf8' });
195
+ if (result.error) throw new Error(`Could not run systemd-run: ${result.error.message}`);
196
+ if (result.status !== 0) {
197
+ throw new Error((result.stderr || result.stdout || 'systemd-run failed.').trim());
198
+ }
199
+ }
200
+
201
+ function armDetachedWaiter(job, env) {
202
+ const child = spawnAgent(process.execPath, [CLI_BIN, '--wait', job.id], {
203
+ detached: true,
204
+ stdio: 'ignore',
205
+ env: { ...process.env, CODEWAKE_STATE_DIR: stateDir(env) },
206
+ });
207
+ // Without a listener, an async spawn failure would crash the CLI later
208
+ // with an unhandled 'error' event instead of surfacing as a message.
209
+ child.on('error', (error) => {
210
+ console.error(`codewake: could not start the waiter for job ${job.id}: ${error.message}`);
211
+ });
212
+ job.pid = child.pid;
213
+ child.unref();
214
+ saveJob(job, env);
215
+ }
216
+
217
+ /** Launch the agent for a job right now. Resolves when the agent exits. */
218
+ export function runJob(jobId, env = process.env) {
219
+ const job = loadJob(jobId, env);
220
+ if (job.status === 'cancelled') return Promise.resolve(job);
221
+ job.status = 'running';
222
+ job.startedAt = new Date().toISOString();
223
+ job.log = logFile(job.id, env);
224
+ saveJob(job, env);
225
+ ensureDir(logsDir(env));
226
+ const log = createWriteStream(job.log, { mode: 0o600 });
227
+ return new Promise((resolvePromise) => {
228
+ let settled = false;
229
+ const finish = (patch) => {
230
+ if (settled) return;
231
+ settled = true;
232
+ Object.assign(job, patch, { finishedAt: new Date().toISOString() });
233
+ saveJob(job, env);
234
+ log.end();
235
+ resolvePromise(job);
236
+ };
237
+ let child;
238
+ try {
239
+ child = spawnAgent(job.bin, job.args, { cwd: job.project, stdio: ['ignore', 'pipe', 'pipe'] });
240
+ } catch (error) {
241
+ finish({ status: 'failed', error: error.message });
242
+ return;
243
+ }
244
+ child.stdout.pipe(log, { end: false });
245
+ child.stderr.pipe(log, { end: false });
246
+ child.on('error', (error) => finish({ status: 'failed', error: error.message }));
247
+ child.on('exit', (code, signal) => finish({
248
+ status: code === 0 ? 'done' : 'failed',
249
+ exitCode: code,
250
+ signal: signal ?? undefined,
251
+ }));
252
+ });
253
+ }
254
+
255
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
256
+
257
+ /**
258
+ * Wait until the job is due, then run it (used by the spawn backend).
259
+ *
260
+ * Polls the wall clock in short slices instead of one long setTimeout:
261
+ * a single timeout would drift by however long the machine was suspended
262
+ * (Node timers pause during suspend) and silently overflows past ~24.8
263
+ * days. Re-reading the job each slice also honors cancellation promptly.
264
+ */
265
+ export async function waitJob(jobId, env = process.env) {
266
+ const pollMs = Number(env.CODEWAKE_POLL_MS) > 0
267
+ ? Number(env.CODEWAKE_POLL_MS)
268
+ : DEFAULT_POLL_MS;
269
+ for (;;) {
270
+ let job;
271
+ try {
272
+ job = loadJob(jobId, env);
273
+ } catch {
274
+ return null;
275
+ }
276
+ if (job.status !== 'pending') return job;
277
+ const remaining = Date.parse(job.runAt) - Date.now();
278
+ if (remaining <= 0) return runJob(jobId, env);
279
+ await sleep(Math.min(remaining, pollMs));
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Cancel a pending job. Returns { job, warning }; `warning` is set when
285
+ * the scheduled launch could not be confirmed as disarmed.
286
+ */
287
+ export function cancelJob(jobId, env = process.env) {
288
+ const job = loadJob(jobId, env);
289
+ if (job.status !== 'pending') {
290
+ throw new Error(`Job ${jobId} is ${job.status}; only pending jobs can be cancelled.`);
291
+ }
292
+ let warning = null;
293
+ const overdue = displayStatus(job) === 'overdue';
294
+ if (job.backend === 'systemd') {
295
+ const stop = spawnSync('systemctl', ['--user', 'stop', `${unitName(job.id)}.timer`], { encoding: 'utf8' });
296
+ // If the timer already elapsed, the transient service may be what's live.
297
+ spawnSync('systemctl', ['--user', 'stop', `${unitName(job.id)}.service`], { encoding: 'utf8' });
298
+ if (!overdue && (stop.error || stop.status !== 0)) {
299
+ warning = 'Could not confirm the systemd timer was stopped; check "systemctl --user list-timers".';
300
+ }
301
+ } else if (job.pid && !overdue) {
302
+ // Never signal an overdue job's recorded pid: after a reboot the OS may
303
+ // have reassigned it to an unrelated process. The waiter re-checks the
304
+ // job status on every poll and exits on its own when it sees the cancel.
305
+ try {
306
+ process.kill(job.pid);
307
+ } catch {
308
+ // Waiter already gone; the status check below still protects us.
309
+ }
310
+ }
311
+ // Re-read before the final write: the waiter may have flipped the job to
312
+ // running/done in the meantime, and a stale write would hide a live agent.
313
+ const fresh = loadJob(jobId, env);
314
+ if (fresh.status !== 'pending') {
315
+ throw new Error(`Job ${jobId} is already ${fresh.status}; too late to cancel.`);
316
+ }
317
+ fresh.status = 'cancelled';
318
+ fresh.cancelledAt = new Date().toISOString();
319
+ saveJob(fresh, env);
320
+ return { job: fresh, warning };
321
+ }
322
+
323
+ /** Human status; flags pending jobs whose launch time silently passed. */
324
+ export function displayStatus(job, now = Date.now()) {
325
+ if (job.status === 'pending' && Date.parse(job.runAt) + OVERDUE_GRACE_MS < now) return 'overdue';
326
+ return job.status;
327
+ }
package/lib/proc.js ADDED
@@ -0,0 +1,37 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Escape one argument for cmd.exe (same algorithm as the cross-spawn
5
+ * package): backslash-escape quotes, quote the whole argument, then
6
+ * caret-escape cmd metacharacters so nothing is interpreted by the shell.
7
+ */
8
+ export function escapeCmdArg(argument) {
9
+ let escaped = String(argument);
10
+ // Double up backslashes that precede a quote, escape the quote itself.
11
+ escaped = escaped.replace(/(\\*)"/g, '$1$1\\"');
12
+ // Double up trailing backslashes so they don't escape the closing quote.
13
+ escaped = escaped.replace(/(\\*)$/, '$1$1');
14
+ escaped = `"${escaped}"`;
15
+ // Neutralize cmd.exe metacharacters.
16
+ escaped = escaped.replace(/[!^&|<>()%"]/g, '^$&');
17
+ return escaped;
18
+ }
19
+
20
+ const WINDOWS_SHIM = /\.(cmd|bat)$/i;
21
+
22
+ /**
23
+ * Spawn an agent binary portably. On Windows, npm installs CLIs as
24
+ * .cmd/.bat shims which cannot be spawned directly (Node raises EINVAL);
25
+ * those are run through cmd.exe with every argument escaped — never with
26
+ * `shell: true`, which would leave arguments uninterpreted-quoted.
27
+ */
28
+ export function spawnAgent(bin, args, options = {}) {
29
+ if (process.platform === 'win32' && WINDOWS_SHIM.test(bin)) {
30
+ const command = [escapeCmdArg(bin), ...args.map(escapeCmdArg)].join(' ');
31
+ return spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${command}"`], {
32
+ ...options,
33
+ windowsVerbatimArguments: true,
34
+ });
35
+ }
36
+ return spawn(bin, args, options);
37
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Built-in agent registry: the list of coding agents `codewake` knows
3
+ * how to detect and drive. Each entry is plain data so users can add or
4
+ * override agents from ~/.config/codewake/config.json with the same shape.
5
+ *
6
+ * Fields:
7
+ * id stable identifier used on the command line (`--agent claude`)
8
+ * name display name
9
+ * bins executable names probed on PATH, first hit wins
10
+ * sessions session-discovery strategy (see lib/sessions.js):
11
+ * 'claude-projects' | 'codex-rollouts' | 'copilot-history' |
12
+ * 'goose-sessions' | 'aider-history' | 'manual' | 'none'
13
+ * resume command spec to resume an existing session (omit if unsupported)
14
+ * create command spec to start a new session (omit if unsupported)
15
+ *
16
+ * Command specs:
17
+ * args argv template; '{session}' and '{prompt}' are substituted as
18
+ * whole argv elements (never through a shell, so no quoting bugs)
19
+ * skip extra argv appended when the user opts into skipping
20
+ * permission/approval prompts (omit if the agent has no such flag)
21
+ */
22
+ export const BUILTIN_AGENTS = [
23
+ {
24
+ id: 'claude',
25
+ name: 'Claude Code',
26
+ bins: ['claude'],
27
+ sessions: 'claude-projects',
28
+ resume: {
29
+ args: ['--resume', '{session}', '-p', '{prompt}'],
30
+ skip: ['--dangerously-skip-permissions'],
31
+ },
32
+ create: {
33
+ args: ['-p', '{prompt}'],
34
+ skip: ['--dangerously-skip-permissions'],
35
+ },
36
+ },
37
+ {
38
+ id: 'codex',
39
+ name: 'OpenAI Codex CLI',
40
+ bins: ['codex'],
41
+ sessions: 'codex-rollouts',
42
+ resume: {
43
+ args: ['exec', 'resume', '{session}', '{prompt}'],
44
+ skip: ['--dangerously-bypass-approvals-and-sandbox'],
45
+ },
46
+ create: {
47
+ args: ['exec', '{prompt}'],
48
+ skip: ['--dangerously-bypass-approvals-and-sandbox'],
49
+ },
50
+ },
51
+ {
52
+ id: 'gemini',
53
+ name: 'Gemini CLI',
54
+ bins: ['gemini'],
55
+ sessions: 'none',
56
+ create: {
57
+ args: ['-p', '{prompt}'],
58
+ skip: ['--yolo'],
59
+ },
60
+ },
61
+ {
62
+ id: 'copilot',
63
+ name: 'GitHub Copilot CLI',
64
+ bins: ['copilot'],
65
+ sessions: 'copilot-history',
66
+ resume: {
67
+ args: ['--resume', '{session}', '-p', '{prompt}'],
68
+ skip: ['--allow-all-tools'],
69
+ },
70
+ create: {
71
+ args: ['-p', '{prompt}'],
72
+ skip: ['--allow-all-tools'],
73
+ },
74
+ },
75
+ {
76
+ id: 'cursor',
77
+ name: 'Cursor CLI',
78
+ bins: ['cursor-agent'],
79
+ sessions: 'manual',
80
+ resume: {
81
+ args: ['--resume', '{session}', '-p', '{prompt}'],
82
+ skip: ['--force'],
83
+ },
84
+ create: {
85
+ args: ['-p', '{prompt}'],
86
+ skip: ['--force'],
87
+ },
88
+ },
89
+ {
90
+ id: 'opencode',
91
+ name: 'OpenCode',
92
+ bins: ['opencode'],
93
+ sessions: 'manual',
94
+ resume: {
95
+ args: ['run', '--session', '{session}', '{prompt}'],
96
+ },
97
+ create: {
98
+ args: ['run', '{prompt}'],
99
+ },
100
+ },
101
+ {
102
+ id: 'aider',
103
+ name: 'Aider',
104
+ bins: ['aider'],
105
+ sessions: 'aider-history',
106
+ resume: {
107
+ args: ['--restore-chat-history', '--message', '{prompt}'],
108
+ skip: ['--yes-always'],
109
+ },
110
+ create: {
111
+ args: ['--message', '{prompt}'],
112
+ skip: ['--yes-always'],
113
+ },
114
+ },
115
+ {
116
+ id: 'goose',
117
+ name: 'Goose',
118
+ bins: ['goose'],
119
+ sessions: 'goose-sessions',
120
+ resume: {
121
+ args: ['run', '--name', '{session}', '--resume', '--text', '{prompt}'],
122
+ },
123
+ create: {
124
+ args: ['run', '--text', '{prompt}'],
125
+ },
126
+ },
127
+ {
128
+ id: 'amp',
129
+ name: 'Amp',
130
+ bins: ['amp'],
131
+ sessions: 'manual',
132
+ resume: {
133
+ args: ['threads', 'continue', '{session}', '-x', '{prompt}'],
134
+ skip: ['--dangerously-allow-all'],
135
+ },
136
+ create: {
137
+ args: ['-x', '{prompt}'],
138
+ skip: ['--dangerously-allow-all'],
139
+ },
140
+ },
141
+ {
142
+ id: 'qwen',
143
+ name: 'Qwen Code',
144
+ bins: ['qwen'],
145
+ sessions: 'none',
146
+ create: {
147
+ args: ['-p', '{prompt}'],
148
+ skip: ['--yolo'],
149
+ },
150
+ },
151
+ ];
@@ -0,0 +1,156 @@
1
+ import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ const HEAD_BYTES = 128 * 1024;
6
+ const TITLE_LENGTH = 64;
7
+
8
+ /**
9
+ * Discover resumable sessions for an agent. Always returns an array
10
+ * (possibly empty), newest first: [{ id, title, updatedAt }].
11
+ */
12
+ export function listSessions(agent, { project = process.cwd(), home = homedir() } = {}) {
13
+ const strategy = STRATEGIES[agent.sessions] ?? STRATEGIES.none;
14
+ try {
15
+ return strategy({ project: resolve(project), home })
16
+ .sort((a, b) => (b.updatedAt?.getTime() ?? 0) - (a.updatedAt?.getTime() ?? 0));
17
+ } catch {
18
+ return [];
19
+ }
20
+ }
21
+
22
+ /** Claude Code encodes a project path by replacing non-alphanumerics with '-'. */
23
+ export function encodeClaudeProject(project) {
24
+ return project.replace(/[^a-zA-Z0-9]/g, '-');
25
+ }
26
+
27
+ const STRATEGIES = {
28
+ none: () => [],
29
+ manual: () => [],
30
+
31
+ 'claude-projects': ({ project, home }) => {
32
+ const dir = join(home, '.claude', 'projects', encodeClaudeProject(project));
33
+ return listFiles(dir, /\.jsonl$/).map(({ path, name, mtime }) => ({
34
+ id: name.replace(/\.jsonl$/, ''),
35
+ title: claudeTitle(path) ?? 'Untitled session',
36
+ updatedAt: mtime,
37
+ }));
38
+ },
39
+
40
+ 'codex-rollouts': ({ project, home }) => {
41
+ const root = join(home, '.codex', 'sessions');
42
+ const sessions = [];
43
+ for (const { path, name, mtime } of walkFiles(root, /^rollout-.*\.jsonl$/)) {
44
+ const meta = firstJsonLine(path);
45
+ const payload = meta?.payload ?? meta;
46
+ const id = payload?.id
47
+ ?? /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/.exec(name)?.[1];
48
+ if (!id) continue;
49
+ const cwd = typeof payload?.cwd === 'string' ? payload.cwd : null;
50
+ sessions.push({ id, title: cwd ?? 'Codex session', updatedAt: mtime, cwd });
51
+ }
52
+ const local = sessions.filter((session) => session.cwd === project);
53
+ return local.length ? local : sessions;
54
+ },
55
+
56
+ 'copilot-history': ({ home }) => {
57
+ const dir = join(home, '.copilot', 'history-session-state');
58
+ return listFiles(dir, /\.json$/).map(({ name, mtime }) => ({
59
+ id: name.replace(/\.json$/, ''),
60
+ title: 'Copilot session',
61
+ updatedAt: mtime,
62
+ }));
63
+ },
64
+
65
+ 'goose-sessions': ({ home }) => {
66
+ const dir = join(home, '.local', 'share', 'goose', 'sessions');
67
+ return listFiles(dir, /\.jsonl$/).map(({ path, name, mtime }) => ({
68
+ id: name.replace(/\.jsonl$/, ''),
69
+ title: cleanTitle(firstJsonLine(path)?.description) ?? 'Goose session',
70
+ updatedAt: mtime,
71
+ }));
72
+ },
73
+
74
+ 'aider-history': ({ project }) => {
75
+ const history = join(project, '.aider.chat.history.md');
76
+ if (!existsSync(history)) return [];
77
+ return [{
78
+ id: 'project-history',
79
+ title: 'Chat history of this project',
80
+ updatedAt: statSync(history).mtime,
81
+ }];
82
+ },
83
+ };
84
+
85
+ function listFiles(dir, pattern) {
86
+ if (!existsSync(dir)) return [];
87
+ return readdirSync(dir, { withFileTypes: true })
88
+ .filter((entry) => entry.isFile() && pattern.test(entry.name))
89
+ .map((entry) => {
90
+ const path = join(dir, entry.name);
91
+ return { path, name: entry.name, mtime: statSync(path).mtime };
92
+ });
93
+ }
94
+
95
+ function walkFiles(dir, pattern, depth = 4) {
96
+ if (depth < 0 || !existsSync(dir)) return [];
97
+ const found = [];
98
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
99
+ const path = join(dir, entry.name);
100
+ if (entry.isDirectory()) found.push(...walkFiles(path, pattern, depth - 1));
101
+ else if (entry.isFile() && pattern.test(entry.name)) {
102
+ found.push({ path, name: entry.name, mtime: statSync(path).mtime });
103
+ }
104
+ }
105
+ return found;
106
+ }
107
+
108
+ function head(path, bytes = HEAD_BYTES) {
109
+ const fd = openSync(path, 'r');
110
+ try {
111
+ const buffer = Buffer.alloc(bytes);
112
+ const read = readSync(fd, buffer, 0, bytes, 0);
113
+ return buffer.toString('utf8', 0, read);
114
+ } finally {
115
+ closeSync(fd);
116
+ }
117
+ }
118
+
119
+ function firstJsonLine(path) {
120
+ const line = head(path, 16 * 1024).split('\n').find((candidate) => candidate.trim());
121
+ if (!line) return null;
122
+ try {
123
+ return JSON.parse(line);
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function claudeTitle(path) {
130
+ let firstUserMessage = null;
131
+ for (const line of head(path).split('\n')) {
132
+ if (!line.trim()) continue;
133
+ let entry;
134
+ try {
135
+ entry = JSON.parse(line);
136
+ } catch {
137
+ continue;
138
+ }
139
+ if (entry.type === 'summary' && entry.summary) return cleanTitle(entry.summary);
140
+ if (!firstUserMessage && entry.type === 'user') {
141
+ const content = entry.message?.content;
142
+ if (typeof content === 'string') firstUserMessage = content;
143
+ else if (Array.isArray(content)) {
144
+ firstUserMessage = content.find((part) => part?.type === 'text')?.text ?? null;
145
+ }
146
+ }
147
+ }
148
+ return cleanTitle(firstUserMessage);
149
+ }
150
+
151
+ function cleanTitle(text) {
152
+ if (typeof text !== 'string') return null;
153
+ const flat = text.replace(/\s+/g, ' ').trim();
154
+ if (!flat) return null;
155
+ return flat.length > TITLE_LENGTH ? `${flat.slice(0, TITLE_LENGTH - 1)}…` : flat;
156
+ }