@event4u/agent-switch 1.0.0

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.
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Session inventory + per-session transfer ("takeover") between Claude profiles.
3
+ *
4
+ * Ground truth (docs + upstream issues, re-verified in scripts/spikes/): a
5
+ * Claude Code session is one file — `<config>/projects/<encoded-cwd>/<id>.jsonl`
6
+ * (plus an optional `<id>/` checkpoint subdir). An ACCOUNT handoff keeps the
7
+ * cwd constant, so the encoded project dir is identical in source and target
8
+ * profile; a takeover is moving that file between two same-named dirs and
9
+ * resuming under the target's CLAUDE_CONFIG_DIR.
10
+ *
11
+ * Iron rules (roadmap: road-to-session-handoff):
12
+ * - Transcripts are OPAQUE, version-unstable blobs FOR TRANSFER. The only
13
+ * read in THIS module is `readSessionHeader` — first line, capped,
14
+ * try/catch. There are exactly TWO sanctioned body readers, both guarded +
15
+ * capped + fenced + degraded-mode, and NO others: read-only TELEMETRY
16
+ * (context/token counts, `src/telemetry.ts`, road-to-agent-switch-session-
17
+ * telemetry gate D0) and read-only PREVIEW (the first few conversation
18
+ * turns for the GUI list, `src/session-preview.ts`, ADR-002). No other
19
+ * module parses a transcript body.
20
+ * - Transfers are copy → verify → delete (the `migrateLegacyLayout`
21
+ * precedent): no step ever leaves zero copies of a transcript.
22
+ * - Index files (`sessions-index.json`, `history.jsonl`) are never written —
23
+ * their format is not ours. Resume-by-id is the supported path.
24
+ * - Same-id divergence is forbidden by construction: move by default,
25
+ * `--fork-session` forced on keep-source, collision = hard refusal.
26
+ */
27
+ import { execFileSync } from "node:child_process";
28
+ import * as fs from "node:fs";
29
+ import * as path from "node:path";
30
+ import { liveSessionPids } from "./api.js";
31
+ /** The documented encoding of a project cwd into its transcript dir name:
32
+ * every non-alphanumeric character of the absolute path becomes `-`. */
33
+ export function encodeProjectDir(cwd) {
34
+ return cwd.replace(/[^A-Za-z0-9]/g, "-");
35
+ }
36
+ /** Best-effort metadata from a transcript's FIRST line only. The line is
37
+ * capped (a >64 KiB first line counts as unparseable), the parse is fenced,
38
+ * and absence degrades to nulls — the format is internal and version-unstable,
39
+ * so a failed parse must never fail a listing. */
40
+ const HEADER_CAP = 64 * 1024;
41
+ export function readSessionHeader(file) {
42
+ const none = { cwd: null, summary: null };
43
+ let fd;
44
+ try {
45
+ fd = fs.openSync(file, "r");
46
+ }
47
+ catch {
48
+ return none;
49
+ }
50
+ try {
51
+ const buf = Buffer.alloc(HEADER_CAP);
52
+ const n = fs.readSync(fd, buf, 0, HEADER_CAP, 0);
53
+ const chunk = buf.subarray(0, n).toString("utf8");
54
+ const nl = chunk.indexOf("\n");
55
+ if (nl < 0 && n === HEADER_CAP)
56
+ return none; // first line larger than the cap
57
+ const line = nl >= 0 ? chunk.slice(0, nl) : chunk;
58
+ const parsed = JSON.parse(line);
59
+ return {
60
+ cwd: typeof parsed?.cwd === "string" ? parsed.cwd : null,
61
+ summary: typeof parsed?.summary === "string" ? parsed.summary : null,
62
+ };
63
+ }
64
+ catch {
65
+ return none;
66
+ }
67
+ finally {
68
+ fs.closeSync(fd);
69
+ }
70
+ }
71
+ /** Recent sessions of one profile config dir, newest first, capped at `limit`.
72
+ * Pure directory scan — works on seeded fakes, shared trees, and win32. */
73
+ export function listSessions(configDir, limit) {
74
+ const projects = path.join(configDir, "projects");
75
+ let dirs;
76
+ try {
77
+ dirs = fs.readdirSync(projects, { withFileTypes: true });
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ const rows = [];
83
+ for (const d of dirs) {
84
+ // A shared-history tree links `projects/` itself, not its children — but be
85
+ // tolerant of either; only descend into directories (or links to them).
86
+ const dirPath = path.join(projects, d.name);
87
+ let entries;
88
+ try {
89
+ if (!fs.statSync(dirPath).isDirectory())
90
+ continue;
91
+ entries = fs.readdirSync(dirPath);
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ for (const entry of entries) {
97
+ if (!entry.endsWith(".jsonl"))
98
+ continue;
99
+ const file = path.join(dirPath, entry);
100
+ let st;
101
+ try {
102
+ st = fs.statSync(file);
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ if (!st.isFile())
108
+ continue;
109
+ const header = readSessionHeader(file);
110
+ rows.push({
111
+ sessionId: entry.slice(0, -".jsonl".length),
112
+ projectDir: d.name,
113
+ cwd: header.cwd,
114
+ summary: header.summary,
115
+ mtimeMs: st.mtimeMs,
116
+ birthtimeMs: st.birthtimeMs || st.mtimeMs,
117
+ live: false,
118
+ file,
119
+ });
120
+ }
121
+ }
122
+ rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
123
+ return rows.slice(0, limit);
124
+ }
125
+ /** A live pid's working directory, without elevated rights. Verified in
126
+ * scripts/spikes/g04: `/proc/<pid>/cwd` on linux, `lsof -d cwd` on darwin
127
+ * (works for same-user processes; claude/codex are node binaries, whose
128
+ * env/cwd are readable — unlike Apple platform binaries). win32: null
129
+ * (recent-only inventory there, per roadmap). */
130
+ export function pidCwd(pid) {
131
+ try {
132
+ if (process.platform === "linux")
133
+ return fs.readlinkSync(`/proc/${pid}/cwd`);
134
+ if (process.platform === "darwin") {
135
+ const out = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
136
+ encoding: "utf8",
137
+ stdio: ["ignore", "pipe", "ignore"],
138
+ });
139
+ const m = /^n(.+)$/m.exec(out);
140
+ return m ? m[1] : null;
141
+ }
142
+ }
143
+ catch {
144
+ /* fall through */
145
+ }
146
+ return null;
147
+ }
148
+ /**
149
+ * Mark the sessions that belong to a live Claude process: a row is live when
150
+ * its profile has a live pid whose cwd encodes to the row's project dir AND
151
+ * the row is that dir's newest transcript (a running session appends to the
152
+ * newest file; older siblings in the same dir are history).
153
+ */
154
+ export function markLive(configDir, rows, cwdOf = pidCwd) {
155
+ const pids = liveSessionPids(configDir);
156
+ if (pids.length === 0)
157
+ return;
158
+ const liveDirs = new Set();
159
+ for (const pid of pids) {
160
+ const cwd = cwdOf(pid);
161
+ if (cwd)
162
+ liveDirs.add(encodeProjectDir(cwd));
163
+ }
164
+ if (liveDirs.size === 0)
165
+ return;
166
+ const newestPerDir = new Map();
167
+ for (const r of rows) {
168
+ const seen = newestPerDir.get(r.projectDir);
169
+ if (!seen || r.mtimeMs > seen.mtimeMs)
170
+ newestPerDir.set(r.projectDir, r);
171
+ }
172
+ for (const [dir, row] of newestPerDir) {
173
+ if (liveDirs.has(dir))
174
+ row.live = true;
175
+ }
176
+ }
177
+ /** Where a session id lives inside ONE profile config dir, or null. */
178
+ export function locateSession(configDir, sessionId) {
179
+ const projects = path.join(configDir, "projects");
180
+ let dirs;
181
+ try {
182
+ dirs = fs.readdirSync(projects);
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ for (const d of dirs) {
188
+ const jsonl = path.join(projects, d, `${sessionId}.jsonl`);
189
+ if (!fs.existsSync(jsonl))
190
+ continue;
191
+ const sub = path.join(projects, d, sessionId);
192
+ return {
193
+ configDir,
194
+ projectDir: d,
195
+ jsonl,
196
+ checkpointDir: fs.existsSync(sub) && fs.statSync(sub).isDirectory() ? sub : null,
197
+ };
198
+ }
199
+ return null;
200
+ }
201
+ /** Do two profiles share one history tree (`share on --history`)? Then the
202
+ * transcript is already visible on the target and file ops would be self-moves
203
+ * — the takeover degrades to just the resume command. */
204
+ export function sharedHistory(srcConfigDir, tgtConfigDir) {
205
+ try {
206
+ const a = fs.realpathSync(path.join(srcConfigDir, "projects"));
207
+ const b = fs.realpathSync(path.join(tgtConfigDir, "projects"));
208
+ return a === b;
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ }
214
+ /**
215
+ * Transfer one session's files from its source location into the target
216
+ * profile. copy → verify → delete-source (unless keepSource): the checkpoint
217
+ * subdir first, the transcript LAST — the transcript is the resume trigger, so
218
+ * any crash mid-way leaves the source fully resumable. Verification is
219
+ * existence + byte size for the transcript (the blob is never read).
220
+ *
221
+ * Throws on a target collision — the caller surfaces it; an existing target
222
+ * file under the same id is a divergence about to happen, never overwritten.
223
+ */
224
+ export function transferSession(loc, tgtConfigDir, keepSource) {
225
+ const tgtProj = path.join(tgtConfigDir, "projects", loc.projectDir);
226
+ const tgtJsonl = path.join(tgtProj, path.basename(loc.jsonl));
227
+ if (fs.existsSync(tgtJsonl)) {
228
+ throw new Error(`target already has a transcript for this session id (${tgtJsonl}) — refusing to overwrite`);
229
+ }
230
+ const actions = [];
231
+ fs.mkdirSync(tgtProj, { recursive: true, mode: 0o700 });
232
+ if (loc.checkpointDir) {
233
+ const tgtSub = path.join(tgtProj, path.basename(loc.checkpointDir));
234
+ fs.cpSync(loc.checkpointDir, tgtSub, { recursive: true });
235
+ if (!fs.existsSync(tgtSub))
236
+ throw new Error(`checkpoint copy failed verification: ${tgtSub}`);
237
+ actions.push(`copied checkpoint dir ${path.basename(loc.checkpointDir)}/`);
238
+ }
239
+ fs.copyFileSync(loc.jsonl, tgtJsonl);
240
+ const srcSize = fs.statSync(loc.jsonl).size;
241
+ const tgtSize = fs.statSync(tgtJsonl).size;
242
+ if (srcSize !== tgtSize) {
243
+ // Bad copy: remove the partial target; the source is untouched.
244
+ fs.rmSync(tgtJsonl, { force: true });
245
+ throw new Error(`transcript copy failed verification (${srcSize} != ${tgtSize} bytes)`);
246
+ }
247
+ actions.push(`copied transcript (${srcSize} bytes, verified)`);
248
+ if (!keepSource) {
249
+ fs.rmSync(loc.jsonl, { force: true });
250
+ if (loc.checkpointDir)
251
+ fs.rmSync(loc.checkpointDir, { recursive: true, force: true });
252
+ actions.push("removed source copy (move complete)");
253
+ }
254
+ else {
255
+ actions.push("kept source (fork mode)");
256
+ }
257
+ return { actions, targetJsonl: tgtJsonl };
258
+ }
259
+ /** Remove the transfer copy that carries the ORIGINAL session id from the
260
+ * target after a keep-source fork: the fork got its own id, so the vehicle
261
+ * file must go, or two profiles would own the same id (the g02 trap). */
262
+ export function cleanupForkVehicle(tgtConfigDir, projectDir, sessionId) {
263
+ const proj = path.join(tgtConfigDir, "projects", projectDir);
264
+ fs.rmSync(path.join(proj, `${sessionId}.jsonl`), { force: true });
265
+ fs.rmSync(path.join(proj, sessionId), { recursive: true, force: true });
266
+ }
267
+ // ---------- per-session delete (Claude: files; Codex: native command) ---------
268
+ //
269
+ // Delete turns the latent takeover path-traversal (a raw id joined into an fs
270
+ // path) into DIRECT destruction, so a strict id gate runs before any fs access,
271
+ // and a realpath-prefix assertion is defense-in-depth behind it. Claude has no
272
+ // native session-delete, so deletion is a file op — trash-move by default
273
+ // (recoverable), true rm only on `purge`. Codex owns its store (.zst/indexes),
274
+ // so codex deletion is delegated to the native CLI, never a manual rollout rm.
275
+ /** Canonical UUID (both providers id sessions this way). */
276
+ const SESSION_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
277
+ /** Strict session-id gate — a canonical UUID only. Rejects `/`, `\`, `..`, and
278
+ * anything else before the id is ever joined into a filesystem path. MUST be
279
+ * the first call on the `sessions rm` / `restore` path. */
280
+ export function assertValidSessionId(id) {
281
+ if (typeof id !== "string" || !SESSION_ID_RE.test(id)) {
282
+ throw new Error(`invalid session id ${JSON.stringify(String(id))} — expected a canonical UUID`);
283
+ }
284
+ }
285
+ /** Defense-in-depth: the resolved target must live inside `rootDir` after
286
+ * symlink resolution, or we refuse to touch it. Both paths must exist. */
287
+ function assertInside(rootDir, target) {
288
+ const root = fs.realpathSync(rootDir);
289
+ const resolved = fs.realpathSync(target);
290
+ const rel = path.relative(root, resolved);
291
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
292
+ throw new Error(`refusing to operate on a path outside ${rootDir}: ${target}`);
293
+ }
294
+ }
295
+ const TRASH_DIRNAME = ".agent-switch-trash";
296
+ const TRASH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
297
+ /** Move a file/dir into `dest`, falling back to copy→verify→delete across a
298
+ * device boundary (rename fails EXDEV). */
299
+ function moveInto(src, dest, isDir) {
300
+ try {
301
+ fs.renameSync(src, dest);
302
+ return;
303
+ }
304
+ catch (e) {
305
+ if (e.code !== "EXDEV")
306
+ throw e;
307
+ }
308
+ if (isDir) {
309
+ fs.cpSync(src, dest, { recursive: true });
310
+ fs.rmSync(src, { recursive: true, force: true });
311
+ }
312
+ else {
313
+ fs.copyFileSync(src, dest);
314
+ if (fs.statSync(src).size !== fs.statSync(dest).size) {
315
+ fs.rmSync(dest, { force: true });
316
+ throw new Error(`trash move failed verification: ${dest}`);
317
+ }
318
+ fs.rmSync(src, { force: true });
319
+ }
320
+ }
321
+ /**
322
+ * Delete one Claude session. Ordering is ALWAYS checkpoint-dir first, transcript
323
+ * LAST — a crash mid-delete then leaves a locatable, still-resumable session
324
+ * (`locateSession` keys on `<id>.jsonl`) rather than an orphaned checkpoint.
325
+ * Default is a recoverable trash-move into `<config>/.agent-switch-trash/`;
326
+ * `purge` does the true irreversible `fs.rmSync`. `now` is injectable for tests.
327
+ */
328
+ export function deleteSession(loc, opts = {}) {
329
+ const projectsRoot = path.join(loc.configDir, "projects");
330
+ assertInside(projectsRoot, loc.jsonl);
331
+ if (loc.checkpointDir)
332
+ assertInside(projectsRoot, loc.checkpointDir);
333
+ const removed = [];
334
+ const residue = [];
335
+ if (opts.purge) {
336
+ if (loc.checkpointDir) {
337
+ try {
338
+ fs.rmSync(loc.checkpointDir, { recursive: true, force: true });
339
+ removed.push(loc.checkpointDir);
340
+ }
341
+ catch {
342
+ residue.push(loc.checkpointDir);
343
+ }
344
+ }
345
+ try {
346
+ fs.rmSync(loc.jsonl, { force: true });
347
+ removed.push(loc.jsonl);
348
+ }
349
+ catch {
350
+ residue.push(loc.jsonl);
351
+ }
352
+ return { mode: "purge", trashId: null, removed, residue };
353
+ }
354
+ const id = path.basename(loc.jsonl, ".jsonl");
355
+ const trashId = `${opts.now ?? Date.now()}-${id}`;
356
+ const dest = path.join(loc.configDir, TRASH_DIRNAME, trashId);
357
+ const destProj = path.join(dest, "projects", loc.projectDir);
358
+ fs.mkdirSync(destProj, { recursive: true, mode: 0o700 });
359
+ if (loc.checkpointDir) {
360
+ moveInto(loc.checkpointDir, path.join(destProj, path.basename(loc.checkpointDir)), true);
361
+ removed.push(loc.checkpointDir);
362
+ }
363
+ moveInto(loc.jsonl, path.join(destProj, path.basename(loc.jsonl)), false);
364
+ removed.push(loc.jsonl);
365
+ const manifest = {
366
+ id,
367
+ projectDir: loc.projectDir,
368
+ hadCheckpoint: loc.checkpointDir !== null,
369
+ trashedAt: new Date(opts.now ?? Date.now()).toISOString(),
370
+ };
371
+ fs.writeFileSync(path.join(dest, "manifest.json"), JSON.stringify(manifest, null, 2));
372
+ return { mode: "trash", trashId, removed, residue };
373
+ }
374
+ /** Restore a trashed Claude session back to its origin. Refuses if a live
375
+ * transcript already occupies the id (never silently overwrites). */
376
+ export function restoreSession(configDir, trashId) {
377
+ const dest = path.join(configDir, TRASH_DIRNAME, trashId);
378
+ const manifest = JSON.parse(fs.readFileSync(path.join(dest, "manifest.json"), "utf8"));
379
+ const backProj = path.join(configDir, "projects", manifest.projectDir);
380
+ const backJsonl = path.join(backProj, `${manifest.id}.jsonl`);
381
+ if (fs.existsSync(backJsonl)) {
382
+ throw new Error(`cannot restore: ${backJsonl} already exists`);
383
+ }
384
+ fs.mkdirSync(backProj, { recursive: true, mode: 0o700 });
385
+ const trashProj = path.join(dest, "projects", manifest.projectDir);
386
+ if (manifest.hadCheckpoint) {
387
+ moveInto(path.join(trashProj, manifest.id), path.join(backProj, manifest.id), true);
388
+ }
389
+ moveInto(path.join(trashProj, `${manifest.id}.jsonl`), backJsonl, false);
390
+ fs.rmSync(dest, { recursive: true, force: true });
391
+ return { restored: backJsonl };
392
+ }
393
+ /** Drop trashed sessions older than the TTL. Best-effort; `now`/`ttlMs`
394
+ * injectable for tests. Returns how many trash entries were removed. */
395
+ export function sweepTrash(configDir, now = Date.now(), ttlMs = TRASH_TTL_MS) {
396
+ const trash = path.join(configDir, TRASH_DIRNAME);
397
+ let entries;
398
+ try {
399
+ entries = fs.readdirSync(trash, { withFileTypes: true });
400
+ }
401
+ catch {
402
+ return 0;
403
+ }
404
+ let swept = 0;
405
+ for (const d of entries) {
406
+ if (!d.isDirectory())
407
+ continue;
408
+ const ts = Number(d.name.split("-")[0]);
409
+ if (Number.isFinite(ts) && now - ts > ttlMs) {
410
+ fs.rmSync(path.join(trash, d.name), { recursive: true, force: true });
411
+ swept++;
412
+ }
413
+ }
414
+ return swept;
415
+ }
416
+ /** Does a Claude trash entry with this id exist in this profile? (Lets the CLI
417
+ * find which profile owns a trash-id without re-deriving the trash path.) */
418
+ export function trashedSessionExists(configDir, trashId) {
419
+ return fs.existsSync(path.join(configDir, TRASH_DIRNAME, trashId, "manifest.json"));
420
+ }
421
+ /** Native codex session action → argv. Spawned with `CODEX_HOME` set to the
422
+ * profile config dir; codex owns its store, so we never rm rollouts by hand.
423
+ * NB: codex exits 0 even when the id is not found — the caller inspects output. */
424
+ export function codexSessionCommand(action, sessionId) {
425
+ return [action, sessionId];
426
+ }
427
+ // ---------- Codex parity (verified in scripts/spikes/g03: outcome (a)) --------
428
+ //
429
+ // Codex CLI's on-disk layout differs from Claude Code's: a session is one
430
+ // date-partitioned rollout file — `<config>/sessions/YYYY/MM/DD/rollout-<ISO8601
431
+ // timestamp>-<uuid>.jsonl` (optionally `.jsonl.zst`) — with no encoded-cwd
432
+ // project dir and no checkpoint subdir. The session id is the trailing UUID in
433
+ // the filename. g03 confirmed outcome (a): a rollout MOVED into another
434
+ // authenticated CODEX_HOME (preserving the date-partitioned relative path)
435
+ // resumes immediately by id — full takeover parity, no index rebuild.
436
+ //
437
+ // Same iron rules as Claude: transcripts are opaque (never read here), transfers
438
+ // are copy → verify → delete, resume-by-id is the supported path. Codex fork
439
+ // (`--keep-source`) has NO verified spike, so codex takeover is MOVE-ONLY — the
440
+ // caller refuses keep-source rather than risk same-id divergence.
441
+ /** The trailing UUID of a codex rollout filename, or null. Anchored on the
442
+ * `.jsonl[.zst]` tail so the ISO8601 timestamp earlier in the name (which also
443
+ * contains hyphen-separated digits) can never be mistaken for the id. */
444
+ export function codexSessionId(filename) {
445
+ const m = /-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl(?:\.zst)?$/.exec(filename);
446
+ return m ? m[1] : null;
447
+ }
448
+ /** All rollout files under a codex home's `sessions/` tree, as [absPath, dirent-relative].
449
+ * Bounded recursive scan of the date partitions; tolerant of a missing tree. */
450
+ function walkCodexRollouts(sessionsDir) {
451
+ const out = [];
452
+ const stack = [sessionsDir];
453
+ while (stack.length > 0) {
454
+ const dir = stack.pop();
455
+ let entries;
456
+ try {
457
+ entries = fs.readdirSync(dir, { withFileTypes: true });
458
+ }
459
+ catch {
460
+ continue;
461
+ }
462
+ for (const e of entries) {
463
+ const full = path.join(dir, e.name);
464
+ if (e.isDirectory()) {
465
+ stack.push(full);
466
+ }
467
+ else if (e.isFile() && codexSessionId(e.name) !== null) {
468
+ out.push(full);
469
+ }
470
+ }
471
+ }
472
+ return out;
473
+ }
474
+ /** Recent codex sessions of one home, newest first, capped at `limit`. The
475
+ * rollout blob is never read (format is opaque + version-unstable), so `cwd`
476
+ * and `summary` are null; `projectDir` carries the date-partition path for
477
+ * display parity with the Claude listing. */
478
+ export function listCodexSessions(configDir, limit) {
479
+ const sessionsDir = path.join(configDir, "sessions");
480
+ const rows = [];
481
+ for (const file of walkCodexRollouts(sessionsDir)) {
482
+ const id = codexSessionId(path.basename(file));
483
+ if (id === null)
484
+ continue;
485
+ let st;
486
+ try {
487
+ st = fs.statSync(file);
488
+ }
489
+ catch {
490
+ continue;
491
+ }
492
+ if (!st.isFile())
493
+ continue;
494
+ rows.push({
495
+ sessionId: id,
496
+ projectDir: path.relative(sessionsDir, path.dirname(file)),
497
+ cwd: null,
498
+ summary: null,
499
+ mtimeMs: st.mtimeMs,
500
+ birthtimeMs: st.birthtimeMs || st.mtimeMs,
501
+ live: false,
502
+ file,
503
+ });
504
+ }
505
+ rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
506
+ return rows.slice(0, limit);
507
+ }
508
+ /** Where a codex session id lives inside ONE home, or null. */
509
+ export function locateCodexSession(configDir, sessionId) {
510
+ const sessionsDir = path.join(configDir, "sessions");
511
+ for (const file of walkCodexRollouts(sessionsDir)) {
512
+ if (codexSessionId(path.basename(file)) === sessionId) {
513
+ return { configDir, rollout: file, rel: path.relative(configDir, file) };
514
+ }
515
+ }
516
+ return null;
517
+ }
518
+ /**
519
+ * Move one codex rollout into the target home, preserving its date-partitioned
520
+ * relative path. copy → verify (byte size) → delete-source, mirroring
521
+ * `transferSession`. Move-only: codex fork is unverified, so there is no
522
+ * keep-source path here (the caller refuses it) — a same-id copy in two homes
523
+ * would be exactly the divergence the move avoids. Throws on a target collision.
524
+ */
525
+ export function transferCodexSession(loc, tgtConfigDir) {
526
+ const tgtRollout = path.join(tgtConfigDir, loc.rel);
527
+ if (fs.existsSync(tgtRollout)) {
528
+ throw new Error(`target already has a rollout for this session id (${tgtRollout}) — refusing to overwrite`);
529
+ }
530
+ fs.mkdirSync(path.dirname(tgtRollout), { recursive: true, mode: 0o700 });
531
+ const actions = [];
532
+ fs.copyFileSync(loc.rollout, tgtRollout);
533
+ const srcSize = fs.statSync(loc.rollout).size;
534
+ const tgtSize = fs.statSync(tgtRollout).size;
535
+ if (srcSize !== tgtSize) {
536
+ fs.rmSync(tgtRollout, { force: true });
537
+ throw new Error(`rollout copy failed verification (${srcSize} != ${tgtSize} bytes)`);
538
+ }
539
+ actions.push(`copied rollout (${srcSize} bytes, verified)`);
540
+ fs.rmSync(loc.rollout, { force: true });
541
+ actions.push("removed source copy (move complete)");
542
+ return { actions, targetJsonl: tgtRollout };
543
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Seed a Claude profile's permission allowlist from the user's GLOBAL Claude
3
+ * settings (~/.claude/settings.json).
4
+ *
5
+ * agent-switch runs each profile under its own CLAUDE_CONFIG_DIR, so Claude Code
6
+ * reads THAT profile's settings.json and never the global one. A freshly-`add`ed
7
+ * profile therefore starts with none of the globally-approved command
8
+ * permissions and re-prompts for each (`import` is unaffected — it copies the
9
+ * whole ~/.claude, settings.json included). This copies (unions) the global
10
+ * `permissions` into a profile — at create time, and via a backfill command for
11
+ * existing profiles.
12
+ *
13
+ * Scope: ONLY `permissions` is inherited. Hooks are managed per-profile by
14
+ * hooks.ts; theme / model / statusLine are deliberately profile-own. The global
15
+ * file is only ever READ here — it is never modified, so a profile can never
16
+ * clobber the source allowlist (unlike a shared symlink + `share sync`).
17
+ */
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { readSettings } from "./hooks.js";
21
+ /** Union two string lists, global first, de-duplicated, order-preserving. */
22
+ function union(a = [], b = []) {
23
+ return [...new Set([...a, ...b])];
24
+ }
25
+ /**
26
+ * Pure merge: the profile's permissions with the global allow/deny/ask lists
27
+ * unioned in. A profile's own grants are never dropped; a profile scalar
28
+ * (`defaultMode`) wins over the global. Returns null when the global carries no
29
+ * permissions to inherit (caller then leaves the profile untouched).
30
+ */
31
+ export function mergePermissions(globalPerms, profilePerms) {
32
+ if (!globalPerms || typeof globalPerms !== "object")
33
+ return null;
34
+ // Nothing to inherit unless the global actually carries a grant list.
35
+ const globalHasGrants = (globalPerms.allow?.length ?? 0) + (globalPerms.deny?.length ?? 0) + (globalPerms.ask?.length ?? 0) > 0;
36
+ if (!globalHasGrants)
37
+ return null;
38
+ const p = profilePerms && typeof profilePerms === "object" ? profilePerms : {};
39
+ const merged = { ...p };
40
+ const allow = union(globalPerms.allow, p.allow);
41
+ if (allow.length)
42
+ merged.allow = allow;
43
+ const deny = union(globalPerms.deny, p.deny);
44
+ if (deny.length)
45
+ merged.deny = deny;
46
+ const ask = union(globalPerms.ask, p.ask);
47
+ if (ask.length)
48
+ merged.ask = ask;
49
+ const defaultMode = p.defaultMode ?? globalPerms.defaultMode;
50
+ if (defaultMode !== undefined)
51
+ merged.defaultMode = defaultMode;
52
+ return merged;
53
+ }
54
+ /** Pure: a NEW profile settings object with the global permissions inherited.
55
+ * Returns the input unchanged when there is nothing to inherit. */
56
+ export function withInheritedPermissions(profileSettings, globalSettings) {
57
+ const merged = mergePermissions(globalSettings?.permissions, profileSettings?.permissions);
58
+ if (!merged)
59
+ return profileSettings;
60
+ return { ...profileSettings, permissions: merged };
61
+ }
62
+ /**
63
+ * Seed `profileConfigDir/settings.json` permissions from
64
+ * `globalConfigDir/settings.json`. Reads both, writes only the profile. Degraded
65
+ * mode: a missing/garbage global settings file → no-op (readSettings returns
66
+ * {}). The global file is never written. Idempotent — a second run over an
67
+ * already-seeded profile reports `changed: false`.
68
+ */
69
+ export function inheritPermissions(profileConfigDir, globalConfigDir) {
70
+ const globalSettings = readSettings(globalConfigDir);
71
+ const before = readSettings(profileConfigDir);
72
+ const next = withInheritedPermissions(before, globalSettings);
73
+ const afterAllow = (next.permissions?.allow ?? []).length;
74
+ const beforeAllow = (before.permissions?.allow ?? []).length;
75
+ if (JSON.stringify(next) === JSON.stringify(before)) {
76
+ return { changed: false, allowCount: afterAllow, addedAllow: 0 };
77
+ }
78
+ fs.mkdirSync(profileConfigDir, { recursive: true, mode: 0o700 });
79
+ fs.writeFileSync(path.join(profileConfigDir, "settings.json"), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
80
+ return { changed: true, allowCount: afterAllow, addedAllow: afterAllow - beforeAllow };
81
+ }