@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.
- package/LICENSE +21 -0
- package/README.md +383 -0
- package/dist/agy-keychain.js +70 -0
- package/dist/api.js +154 -0
- package/dist/apps.js +112 -0
- package/dist/args.js +85 -0
- package/dist/codex-reset.js +93 -0
- package/dist/codex-usage.js +105 -0
- package/dist/credentials.js +66 -0
- package/dist/daemon.js +393 -0
- package/dist/doctor.js +119 -0
- package/dist/gui-launch.js +91 -0
- package/dist/handoff.js +167 -0
- package/dist/history.js +35 -0
- package/dist/hooks.js +142 -0
- package/dist/index.js +1693 -0
- package/dist/keychain.js +54 -0
- package/dist/locks.js +89 -0
- package/dist/mappings.js +132 -0
- package/dist/notifications.js +70 -0
- package/dist/notify.js +84 -0
- package/dist/os-notify.js +66 -0
- package/dist/profiles.js +376 -0
- package/dist/providers.js +170 -0
- package/dist/service.js +223 -0
- package/dist/session-preview.js +163 -0
- package/dist/sessions.js +543 -0
- package/dist/settings-inherit.js +81 -0
- package/dist/share.js +257 -0
- package/dist/shellenv.js +116 -0
- package/dist/telemetry.js +269 -0
- package/dist/tmux.js +98 -0
- package/dist/tokens.js +88 -0
- package/dist/usage.js +208 -0
- package/package.json +39 -0
package/dist/sessions.js
ADDED
|
@@ -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
|
+
}
|