@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/dist/index.js ADDED
@@ -0,0 +1,1693 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-switch — multi-account profile switcher for Claude Code, Codex, and
4
+ * Antigravity (macOS, Linux, Windows).
5
+ *
6
+ * Architecture: config-dir isolation. Each profile is its own config dir with
7
+ * its own live login, selected by the provider's env var (CLAUDE_CONFIG_DIR /
8
+ * CODEX_HOME / HOME). Switching only changes which dir new
9
+ * invocations point at — nothing is snapshotted, so nothing goes stale.
10
+ *
11
+ * On-disk layout: ~/.agent-switch/<provider>/<name>/config. v1 Claude profiles
12
+ * (~/.agent-switch/<name>) are migrated on first run (see migrateLegacyLayout).
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { spawn, spawnSync } from "node:child_process";
17
+ import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readSwitchStrategy, setSwitchStrategy, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, ROOT, } from "./profiles.js";
18
+ import { PROVIDER_IDS, provider, isProviderInstalled, resolveBinary } from "./providers.js";
19
+ import { parseArgs, parseRun, resolveProviderValue } from "./args.js";
20
+ import { extractBrief, writeBrief, sweepBriefs, seedPrompt } from "./handoff.js";
21
+ import { credentialStore } from "./credentials.js";
22
+ import { withProperLock } from "./locks.js";
23
+ import { applySharing, removeSharing, syncSharing, sharedLinkHealth } from "./share.js";
24
+ import { detectShell, shellenvScript } from "./shellenv.js";
25
+ import { runDoctor } from "./doctor.js";
26
+ import { launchGui } from "./gui-launch.js";
27
+ import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, } from "./mappings.js";
28
+ import { accessTokenOf, checkAuth, fetchProfile, fetchUsage, liveSessionPids, readProfileCredential, } from "./api.js";
29
+ import { formatSnapshot, parseUsage } from "./usage.js";
30
+ import { readCodexUsage } from "./codex-usage.js";
31
+ import { redeemResetCredit } from "./codex-reset.js";
32
+ import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, listSessions, listCodexSessions, locateSession, locateCodexSession, markLive, restoreSession, sharedHistory, sweepTrash, transferSession, transferCodexSession, trashedSessionExists, } from "./sessions.js";
33
+ import { isFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
34
+ import { cmdService, serviceUninstall } from "./service.js";
35
+ import { readContext, turnInFlight } from "./telemetry.js";
36
+ import { readPreview } from "./session-preview.js";
37
+ import { installHooks, uninstallHooks, hooksInstalled, readSettings, appendEvent, eventFile, profileFromConfigDir, } from "./hooks.js";
38
+ import { readTelemetryConfig, writeTelemetryConfig } from "./notify.js";
39
+ import { appendNotification, readNotifications, clearNotifications } from "./notifications.js";
40
+ import { APPS, buildLaunch, findApp, guiDataDir, isInstalled } from "./apps.js";
41
+ import { ensureAgyKeychain } from "./agy-keychain.js";
42
+ import { currentManagedSession, currentTmuxSessionName, newSessionArgs, readTmuxRegistry, recordManagedSession, respawnPaneArgs, sendKeysArgs, tmuxAvailable, tmuxSessionName, } from "./tmux.js";
43
+ // Per-OS credential store (keychain-then-file on darwin, file-only elsewhere).
44
+ const credentials = credentialStore();
45
+ const CLAUDE_HOME = provider("claude").defaultConfigDir(); // ~/.claude
46
+ const CLAUDE_JSON = path.join(HOME, ".claude.json");
47
+ // ---------- launcher ---------------------------------------------------------
48
+ function launch(p, name, args) {
49
+ const home = configDir(p.id, name);
50
+ const env = { ...process.env, [p.envVar]: home };
51
+ // Antigravity's agy CLI stores its token in the macOS keychain (fixed key) —
52
+ // seed a per-profile keychain under this HOME so the token is isolated per
53
+ // account (and macOS doesn't pop its "no keychain found" dialog). No-op off mac.
54
+ // CFFIXED_USER_HOME is pinned to HOME so CoreFoundation can't be redirected out
55
+ // of the profile by an ambient value (see agy-keychain.ts). Only for antigravity
56
+ // — claude/codex keep the real HOME (their isolation is a config-dir env var).
57
+ if (p.id === "antigravity") {
58
+ ensureAgyKeychain(home);
59
+ env.CFFIXED_USER_HOME = home;
60
+ }
61
+ const res = spawnSync(resolveBinary(p.binary), args, { env, stdio: "inherit" });
62
+ if (res.error && res.error.code === "ENOENT") {
63
+ die(`\`${p.binary}\` binary not found on PATH. Install ${p.id} first.`);
64
+ }
65
+ return res.status ?? 1;
66
+ }
67
+ // ---------- commands ---------------------------------------------------------
68
+ function cmdAdd(providerId, name) {
69
+ if (!name)
70
+ die(`usage: agent-switch add [--provider ${providerId}] <profile>`);
71
+ if (profileExists(providerId, name))
72
+ die(`profile "${name}" already exists for ${providerId}`);
73
+ const p = provider(providerId);
74
+ ensureRoot();
75
+ fs.mkdirSync(configDir(providerId, name), { recursive: true, mode: 0o700 });
76
+ console.log(`Created ${providerId} profile "${name}".`);
77
+ console.log(`Launching ${p.binary} for first login (log in inside if prompted)...`);
78
+ launch(p, name, []);
79
+ const id = identity(providerId, name);
80
+ console.log(id ? `Profile "${name}" is linked to ${id}.` : `Profile "${name}" created.`);
81
+ console.log(`Activate it with: agent-switch use ${name}${providerId === "claude" ? "" : ` --provider ${providerId}`}`);
82
+ }
83
+ /**
84
+ * Login-free import of the default Claude install into a profile. Takes Claude
85
+ * Code's own locks so a credential can't be captured mid-rotation, copies the
86
+ * config, seeds a plaintext .credentials.json (Claude re-migrates it into the
87
+ * profile's hashed keychain entry), and sets the onboarding keys.
88
+ */
89
+ async function importClaude(name) {
90
+ if (!fs.existsSync(CLAUDE_HOME) && !fs.existsSync(CLAUDE_JSON)) {
91
+ die("no default ~/.claude setup found to import");
92
+ }
93
+ ensureRoot();
94
+ const dst = configDir("claude", name);
95
+ const seeded = await withProperLock(CLAUDE_HOME, () => withProperLock(CLAUDE_JSON, () => {
96
+ credentials.clearStale(dst); // a stale hashed entry would shadow the seed (darwin)
97
+ fs.mkdirSync(dst, { recursive: true, mode: 0o700 });
98
+ if (fs.existsSync(CLAUDE_HOME))
99
+ fs.cpSync(CLAUDE_HOME, dst, { recursive: true });
100
+ const config = (fs.existsSync(CLAUDE_JSON) && readJson(CLAUDE_JSON)) || {};
101
+ config.hasCompletedOnboarding = true;
102
+ config.theme = config.theme || "dark";
103
+ fs.writeFileSync(path.join(dst, ".claude.json"), JSON.stringify(config, null, 2) + "\n", {
104
+ mode: 0o600,
105
+ });
106
+ const creds = credentials.readDefault(CLAUDE_HOME);
107
+ if (creds)
108
+ fs.writeFileSync(path.join(dst, ".credentials.json"), creds, { mode: 0o600 });
109
+ return creds !== null;
110
+ }));
111
+ console.log(`Imported default Claude setup into profile "${name}".`);
112
+ console.log(seeded
113
+ ? "Credentials were seeded — no re-login needed. Claude Code migrates the seed into\n" +
114
+ "this profile's own keychain entry on first use.\n" +
115
+ "IMPORTANT: the default install and this profile now share one OAuth lineage — the\n" +
116
+ "first side to refresh invalidates the other, so go through agent-switch from now on."
117
+ : "No live credential found to seed (keychain locked/empty). Run `agent-switch run " +
118
+ `${name}\` once and log in inside.`);
119
+ }
120
+ /** Import a file-based provider profile (e.g. codex): copy the credential/identity
121
+ * files from the default install's config dir into the profile's config dir. */
122
+ function importFileProvider(p, name) {
123
+ const srcDir = p.defaultConfigDir();
124
+ if (!fs.existsSync(srcDir))
125
+ die(`no default ${p.binary} install (${srcDir}) found to import`);
126
+ const dstDir = p.configDirFor(configDir(p.id, name));
127
+ fs.mkdirSync(dstDir, { recursive: true, mode: 0o700 });
128
+ let seeded = false;
129
+ for (const file of p.importFiles) {
130
+ const src = path.join(srcDir, file);
131
+ if (!fs.existsSync(src))
132
+ continue;
133
+ fs.copyFileSync(src, path.join(dstDir, file));
134
+ fs.chmodSync(path.join(dstDir, file), 0o600);
135
+ if (path.join(srcDir, file) === p.credentialPath(srcDir))
136
+ seeded = true;
137
+ }
138
+ console.log(`Imported default ${p.binary} setup into profile "${name}".`);
139
+ console.log(seeded
140
+ ? `Credential seeded — no re-login needed.\nIMPORTANT: this profile shares one OAuth ` +
141
+ `lineage with the default ${p.binary} login; go through agent-switch from now on.`
142
+ : `No credential file found to seed. Run \`agent-switch run ${name} --provider ${p.id}\` ` +
143
+ "and log in once.");
144
+ }
145
+ async function cmdImport(providerId, name) {
146
+ if (!name)
147
+ die(`usage: agent-switch import [--provider ${providerId}] <profile>`);
148
+ if (profileExists(providerId, name))
149
+ die(`profile "${name}" already exists for ${providerId}`);
150
+ if (providerId === "claude")
151
+ return importClaude(name);
152
+ return importFileProvider(provider(providerId), name);
153
+ }
154
+ function cmdUse(providerId, name) {
155
+ const n = requireProfile(providerId, name, "use");
156
+ setActive(providerId, n);
157
+ const id = identity(providerId, n);
158
+ console.log(`Active ${providerId} profile: ${n}${id ? ` (${id})` : ""}`);
159
+ console.log(`New \`${provider(providerId).binary}\` sessions use this profile. Running sessions are unaffected.`);
160
+ }
161
+ function cmdDeactivate(providerId) {
162
+ const active = activeFor(providerId);
163
+ if (!active) {
164
+ console.log(`No active ${providerId} profile.`);
165
+ return;
166
+ }
167
+ setActive(providerId, null);
168
+ console.log(`Deactivated ${providerId} profile "${active}". No ${providerId} profile is active.`);
169
+ console.log(`New \`${provider(providerId).binary}\` sessions fall back to the default install until you \`use\` one.`);
170
+ }
171
+ function cmdRun(providerId, name, args) {
172
+ const n = requireProfile(providerId, name, "run");
173
+ const p = provider(providerId);
174
+ // `--tmux` is an agent-switch flag, not a passthrough flag — strip it.
175
+ const wantTmux = args.includes("--tmux");
176
+ const passthrough = args.filter((a) => a !== "--tmux");
177
+ if (wantTmux) {
178
+ if (!tmuxAvailable()) {
179
+ console.log("(--tmux unavailable — tmux not found or Windows; running normally.)");
180
+ }
181
+ else {
182
+ // Wrap the session in an agent-switch-managed tmux session (recorded so
183
+ // `takeover --in-place` knows this pane is ours to respawn).
184
+ const sess = tmuxSessionName(providerId, n);
185
+ recordManagedSession(sess, { provider: providerId, profile: n });
186
+ const argv = newSessionArgs(sess, p.envVar, configDir(providerId, n), [p.binary, ...passthrough]);
187
+ const res = spawnSync("tmux", argv, { stdio: "inherit" });
188
+ process.exit(res.status ?? 1);
189
+ }
190
+ }
191
+ process.exit(launch(p, n, passthrough));
192
+ }
193
+ function printProfileLine(providerId, name, showLive) {
194
+ const mark = activeFor(providerId) === name ? "*" : " ";
195
+ const id = identity(providerId, name) ?? "not logged in";
196
+ let live = "";
197
+ if (showLive && providerId === "claude") {
198
+ const pids = liveSessionPids(configDir("claude", name));
199
+ if (pids.length > 0)
200
+ live = ` [${pids.length} live session${pids.length > 1 ? "s" : ""}]`;
201
+ }
202
+ console.log(`${mark} ${name.padEnd(16)} ${id}${live}`);
203
+ }
204
+ function cmdList(providerId, json = false) {
205
+ // An explicit --provider always lists that one; otherwise every provider that
206
+ // is enabled on ANY surface. Gating on the CLI surface alone would silently
207
+ // drop a UI-only provider's profiles (e.g. Antigravity, cli:false/ui:true) —
208
+ // the GUI reads `list --json` for every tab, so it must see them too.
209
+ const cfg = readProviders();
210
+ const providers = providerId ? [providerId] : PROVIDER_IDS.filter((p) => cfg[p].cli || cfg[p].ui);
211
+ if (json) {
212
+ // The GUI IPC contract: the profile list (identity/active/live) — NOT usage.
213
+ // Usage stays behind `status --json` (active profile only, anti-rotation).
214
+ const rows = providers.flatMap((pid) => listProfiles(pid).map((n) => ({
215
+ provider: pid,
216
+ name: n,
217
+ identity: identity(pid, n),
218
+ label: labelFor(pid, n),
219
+ active: activeFor(pid) === n,
220
+ liveSessions: pid === "claude" ? liveSessionPids(configDir("claude", n)).length : 0,
221
+ })));
222
+ console.log(JSON.stringify(rows, null, 2));
223
+ return;
224
+ }
225
+ let any = false;
226
+ for (const pid of providers) {
227
+ const names = listProfiles(pid);
228
+ if (names.length === 0)
229
+ continue;
230
+ any = true;
231
+ console.log(`${pid}:`);
232
+ for (const n of names) {
233
+ process.stdout.write(" ");
234
+ printProfileLine(pid, n, true);
235
+ }
236
+ }
237
+ if (!any)
238
+ console.log("No profiles yet. Create one with: agent-switch add <name> [--provider codex|antigravity]");
239
+ }
240
+ function cmdCurrent(providerId) {
241
+ const providers = providerId ? [providerId] : PROVIDER_IDS;
242
+ for (const pid of providers) {
243
+ const active = activeFor(pid);
244
+ if (active && profileExists(pid, active)) {
245
+ const id = identity(pid, active);
246
+ console.log(`${pid}: ${active}${id ? ` (${id})` : ""}`);
247
+ }
248
+ else if (providerId) {
249
+ console.log(`${pid}: (none — falling back to the default install)`);
250
+ }
251
+ }
252
+ }
253
+ function cmdWhoami(providerId, name) {
254
+ const n = name ?? activeFor(providerId) ?? undefined;
255
+ if (!n)
256
+ die(`no profile given and none active for ${providerId}`);
257
+ requireProfile(providerId, n, "whoami");
258
+ console.log(identity(providerId, n) ?? "not logged in yet");
259
+ }
260
+ /** A Claude profile's usage snapshot via the OAuth endpoint; null if the
261
+ * credential is unreadable or the API shape is unknown. Codex/Antigravity have no
262
+ * usage readout (verified), so this is Claude-only. */
263
+ async function claudeSnapshot(name) {
264
+ // Prefer the daemon's cache when it's fresh (< its own poll interval) — the
265
+ // daemon is a cache, and the CLI works with or without it.
266
+ const state = readDaemonState();
267
+ if (state && isFresh(state, state.pollIntervalMs)) {
268
+ const cached = state.profiles[`claude/${name}`];
269
+ if (cached)
270
+ return cached;
271
+ }
272
+ const creds = readProfileCredential(configDir("claude", name));
273
+ const token = creds ? accessTokenOf(creds) : null;
274
+ if (!token)
275
+ return null;
276
+ const raw = await fetchUsage(token);
277
+ return raw ? parseUsage(raw) : null;
278
+ }
279
+ /**
280
+ * Identity + (Claude only) usage. The default all-profile table is human-only.
281
+ * `--json` emits the ACTIVE profile's snapshot ONLY — never a machine-readable
282
+ * cross-account view (the anti-rotation boundary: no ranking material).
283
+ */
284
+ async function cmdStatus(providerId, name, json = false) {
285
+ if (json) {
286
+ const pid = providerId ?? "claude";
287
+ const active = name ?? activeFor(pid);
288
+ if (!active || !profileExists(pid, active))
289
+ die(`no active ${pid} profile for --json`);
290
+ const usage = pid === "claude"
291
+ ? await claudeSnapshot(active)
292
+ : pid === "codex"
293
+ ? await readCodexUsage(configDir("codex", active))
294
+ : null;
295
+ const wc = worstLiveContext(pid, active);
296
+ const context = wc
297
+ ? { sessionId: wc.sessionId, pct: wc.pct, contextTokens: wc.contextTokens, windowTokens: wc.windowTokens, model: wc.model, confidence: wc.confidence }
298
+ : null;
299
+ console.log(JSON.stringify({ provider: pid, name: active, identity: identity(pid, active), usage, context }, null, 2));
300
+ return;
301
+ }
302
+ const rows = name
303
+ ? [{ provider: providerId ?? "claude", name: requireProfile(providerId ?? "claude", name, "status") }]
304
+ : (providerId ? listProfiles(providerId).map((n) => ({ provider: providerId, name: n })) : listAllProfiles());
305
+ if (rows.length === 0)
306
+ die("no profiles");
307
+ for (const { provider: pid, name: n } of rows) {
308
+ const mark = activeFor(pid) === n ? "*" : " ";
309
+ console.log(`${mark} ${pid}/${n} — ${identity(pid, n) ?? "not logged in"}`);
310
+ const wc = worstLiveContext(pid, n);
311
+ if (wc)
312
+ console.log(` live context: ${formatContext(wc)} (session ${wc.sessionId.slice(0, 8)})`);
313
+ if (pid === "codex") {
314
+ // Last-known from the newest rollout (no live endpoint); shows nothing
315
+ // until this profile has run codex at least once.
316
+ const snap = await readCodexUsage(configDir("codex", n));
317
+ const lines = snap ? formatSnapshot(snap) : [];
318
+ if (lines.length > 0)
319
+ lines.forEach((l) => console.log(l));
320
+ else
321
+ console.log(" (usage unavailable — token expired or not logged in)");
322
+ continue;
323
+ }
324
+ if (pid !== "claude") {
325
+ console.log(" (no usage readout for this provider — shows own usage only where available)");
326
+ continue;
327
+ }
328
+ const creds = readProfileCredential(configDir("claude", n));
329
+ const token = creds ? accessTokenOf(creds) : null;
330
+ if (!token) {
331
+ console.log(" (credential not readable — profile may not have run yet)");
332
+ continue;
333
+ }
334
+ const [profileInfo, raw] = await Promise.all([fetchProfile(token), fetchUsage(token)]);
335
+ const org = profileInfo?.organization?.name ?? profileInfo?.account?.email ?? null;
336
+ if (org)
337
+ console.log(` org: ${org}`);
338
+ const lines = raw ? formatSnapshot(parseUsage(raw)) : [];
339
+ if (lines.length > 0)
340
+ lines.forEach((l) => console.log(l));
341
+ else if (profileInfo)
342
+ console.log(" (usage unavailable — API shape changed?)");
343
+ else {
344
+ // Both profile and usage came back empty — disambiguate a dead login from
345
+ // a transient/offline failure with one read-only probe (no token write).
346
+ const auth = await checkAuth(configDir("claude", n));
347
+ console.log(auth === "expired"
348
+ ? ` (login expired — run \`agent-switch run ${n}\` and /login again)`
349
+ : " (usage unavailable — offline, or the login expired)");
350
+ }
351
+ }
352
+ }
353
+ function cmdDir(providerId) {
354
+ // Precedence: directory mapping (nearest ancestor of CWD) > active-for-provider.
355
+ const mapped = resolveMapping(process.cwd(), providerId);
356
+ if (mapped && profileExists(providerId, mapped.name)) {
357
+ console.log(configDir(providerId, mapped.name));
358
+ return;
359
+ }
360
+ const active = activeFor(providerId);
361
+ if (active && profileExists(providerId, active))
362
+ console.log(configDir(providerId, active));
363
+ // Empty output -> shell wrapper falls back to the default config dir.
364
+ }
365
+ function cmdMap(providerId, name, dir) {
366
+ const n = requireProfile(providerId, name, `map [--provider ${providerId}] <profile> [dir]`);
367
+ const key = setMapping(dir ?? process.cwd(), providerId, n);
368
+ console.log(`Mapped ${key} -> ${providerId}/${n}`);
369
+ console.log(`\`${provider(providerId).binary}\` in this directory (and subdirectories) now uses this profile.`);
370
+ }
371
+ function cmdUnmap(providerId, dir) {
372
+ const target = dir ?? process.cwd();
373
+ if (removeMapping(target, providerId))
374
+ console.log(`Removed mapping for ${target}${providerId ? ` (${providerId})` : ""}`);
375
+ else
376
+ die(`no mapping for ${target}${providerId ? ` (${providerId})` : ""}`);
377
+ }
378
+ function cmdMappings() {
379
+ const rows = mappingRows();
380
+ if (rows.length === 0) {
381
+ console.log("No directory mappings. Create one with: agent-switch map <profile> [dir]");
382
+ return;
383
+ }
384
+ for (const r of rows)
385
+ console.log(`${r.path} -> ${r.provider}/${r.name}`);
386
+ }
387
+ // share + web are Claude-config concepts and stay Claude-scoped.
388
+ function cmdShare(mode, flags = []) {
389
+ const withHistory = flags.includes("--history");
390
+ const sourceFlagIdx = flags.indexOf("--source");
391
+ const sourceName = sourceFlagIdx >= 0 ? flags[sourceFlagIdx + 1] : undefined;
392
+ const source = sourceName && sourceName !== "default"
393
+ ? configDir("claude", requireProfile("claude", sourceName, "share --source"))
394
+ : CLAUDE_HOME;
395
+ const profiles = listProfiles("claude");
396
+ if (profiles.length === 0)
397
+ die("no claude profiles");
398
+ if (mode === "on") {
399
+ if (!fs.existsSync(source))
400
+ die(`share source ${source} does not exist`);
401
+ if (withHistory && process.platform === "win32") {
402
+ die("--history requires POSIX symlinks (copies would fork history, not share it)");
403
+ }
404
+ console.log(`Sharing from ${source}${withHistory ? " (incl. history)" : ""}:`);
405
+ for (const p of profiles) {
406
+ if (configDir("claude", p) === source)
407
+ continue;
408
+ const actions = applySharing(source, configDir("claude", p), withHistory);
409
+ console.log(` ${p}: ${actions.length > 0 ? actions.join(", ") : "up to date"}`);
410
+ }
411
+ console.log("Shared directories (skills/, commands/, agents/) write through the link. Shared files\n" +
412
+ "(settings.json, ...) fork on an in-profile /config edit — run `agent-switch share sync`\n" +
413
+ "to push a fork back to the source and re-link.");
414
+ }
415
+ else if (mode === "sync") {
416
+ console.log(`Reconciling forked links against ${source}:`);
417
+ for (const p of profiles) {
418
+ if (configDir("claude", p) === source)
419
+ continue;
420
+ const actions = syncSharing(source, configDir("claude", p));
421
+ console.log(` ${p}: ${actions.length > 0 ? actions.join(", ") : "nothing to reconcile"}`);
422
+ }
423
+ }
424
+ else if (mode === "off") {
425
+ for (const p of profiles) {
426
+ const actions = removeSharing(configDir("claude", p));
427
+ if (actions.length > 0)
428
+ console.log(` ${p}: ${actions.join(", ")}`);
429
+ }
430
+ console.log("Removed agent-switch-managed links (profile-own files were never touched).");
431
+ }
432
+ else if (mode === "status") {
433
+ // Report the REAL state (from each profile's link manifest), not a cached
434
+ // flag: sharing is active when any profile carries agent-switch-managed links.
435
+ const rows = profiles
436
+ .filter((p) => configDir("claude", p) !== source)
437
+ .map((p) => ({ name: p, shared: sharedLinkHealth(configDir("claude", p)).length > 0 }));
438
+ const active = rows.some((r) => r.shared);
439
+ if (flags.includes("--json")) {
440
+ console.log(JSON.stringify({ active, source: sourceName ?? "default", profiles: rows }));
441
+ return;
442
+ }
443
+ console.log(`share: ${active ? "on" : "off"} (source ${sourceName ?? "default"})`);
444
+ for (const r of rows)
445
+ console.log(` ${r.name}: ${r.shared ? "shared" : "own"}`);
446
+ }
447
+ else {
448
+ die("usage: agent-switch share on|sync|off|status [--history] [--source <profile|default>]");
449
+ }
450
+ }
451
+ // ---------- session inventory + takeover (Claude; Codex lands per G0.3) ------
452
+ /** Relative age like "3m" / "2h" / "5d" for the sessions table. */
453
+ function ageOf(mtimeMs) {
454
+ const s = Math.max(0, Math.round((Date.now() - mtimeMs) / 1000));
455
+ if (s < 60)
456
+ return `${s}s`;
457
+ if (s < 3600)
458
+ return `${Math.round(s / 60)}m`;
459
+ if (s < 86400)
460
+ return `${Math.round(s / 3600)}h`;
461
+ return `${Math.round(s / 86400)}d`;
462
+ }
463
+ /** `claude --version`, read once per process (best-effort). Null when claude is
464
+ * not on PATH → the telemetry reader degrades to low confidence, never fails. */
465
+ let _claudeVersion;
466
+ function claudeVersion() {
467
+ if (_claudeVersion !== undefined)
468
+ return _claudeVersion;
469
+ try {
470
+ _claudeVersion = spawnSync("claude", ["--version"], { encoding: "utf8" }).stdout?.trim() || null;
471
+ }
472
+ catch {
473
+ _claudeVersion = null;
474
+ }
475
+ return _claudeVersion;
476
+ }
477
+ /** Read a session row's own context state via the sanctioned telemetry reader.
478
+ * Own-session only — never a cross-account view. */
479
+ function contextForRow(providerId, row) {
480
+ if (!row.file)
481
+ return null;
482
+ if (providerId !== "claude" && providerId !== "codex")
483
+ return null;
484
+ return readContext(providerId, row.file, { claudeVersion: claudeVersion() });
485
+ }
486
+ /** The worst (highest-utilization) LIVE session's context for a profile — the
487
+ * one-liner `status` surfaces. Own-profile only; live-marking is POSIX-only
488
+ * (win32 has none, so this returns null there). Claude/codex only. */
489
+ function worstLiveContext(providerId, name) {
490
+ if (providerId !== "claude" && providerId !== "codex")
491
+ return null;
492
+ const cfg = configDir(providerId, name);
493
+ const rows = providerId === "codex" ? listCodexSessions(cfg, 20) : listSessions(cfg, 20);
494
+ if (providerId === "claude")
495
+ markLive(cfg, rows);
496
+ let worst = null;
497
+ for (const r of rows) {
498
+ if (!r.live)
499
+ continue;
500
+ const c = contextForRow(providerId, r);
501
+ if (!c)
502
+ continue;
503
+ const key = c.pct ?? c.contextTokens / 1e9; // pct when known, else a tiny token-based tiebreak
504
+ const worstKey = worst ? (worst.pct ?? worst.contextTokens / 1e9) : -1;
505
+ if (key > worstKey)
506
+ worst = { ...c, sessionId: r.sessionId };
507
+ }
508
+ return worst;
509
+ }
510
+ /** One-column context render for the sessions table: "67% · 134k/200k",
511
+ * "134k tok" (window unknown), with a "~" prefix on low confidence. Empty
512
+ * string when there is nothing to show. */
513
+ function formatContext(c) {
514
+ if (!c)
515
+ return "";
516
+ const mark = c.confidence === "low" ? "~" : "";
517
+ const k = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
518
+ if (c.pct !== null && c.windowTokens)
519
+ return `${mark}${c.pct}% · ${k(c.contextTokens)}/${k(c.windowTokens)}`;
520
+ return `${mark}${k(c.contextTokens)} tok`;
521
+ }
522
+ /**
523
+ * `agent-switch hooks install|uninstall|status [profile]` — manage the
524
+ * lifecycle push hooks in Claude profiles' settings.json. Additive,
525
+ * marker-keyed, idempotent, share-aware (settings.json may be a shared,
526
+ * fork-prone link — after an edit the user should run `share sync`). Claude
527
+ * only (the hook contract is Claude Code's).
528
+ */
529
+ function cmdHooks(sub, name) {
530
+ const action = sub ?? "status";
531
+ if (!["install", "uninstall", "status"].includes(action)) {
532
+ die("usage: agent-switch hooks install|uninstall|status [profile]");
533
+ }
534
+ const profiles = name ? [requireProfile("claude", name, "hooks")] : listProfiles("claude");
535
+ if (profiles.length === 0)
536
+ die("no claude profiles");
537
+ let sharedWarn = false;
538
+ for (const p of profiles) {
539
+ const cfg = configDir("claude", p);
540
+ if (action === "status") {
541
+ console.log(`claude/${p}: ${hooksInstalled(readSettings(cfg)) ? "installed" : "not installed"}`);
542
+ continue;
543
+ }
544
+ const res = action === "install" ? installHooks(cfg) : uninstallHooks(cfg);
545
+ console.log(`claude/${p}: ${action} ${res.changed ? "done" : "(no change)"}`);
546
+ if (res.changed)
547
+ sharedWarn = true;
548
+ }
549
+ if (sharedWarn) {
550
+ console.log("\nsettings.json changed. If you use `share on`, run `agent-switch share sync` so the edit propagates and the link is restored.");
551
+ }
552
+ }
553
+ /** Idle-guard window per provider (council #10): a Claude turn runs 15–60s, a
554
+ * Codex turn can be sub-second. In-flight = last transcript entry non-finalized
555
+ * AND younger than this. */
556
+ const IDLE_GUARD_MS = { claude: 15_000, codex: 5_000 };
557
+ /**
558
+ * `agent-switch compact <profile> [--clear] [--dry-run] [--force] [--provider P]`
559
+ * — type `/compact` (or `/clear`, gated) into the profile's agent-switch-MANAGED
560
+ * tmux pane. MANAGED panes only (registry check); never a user's own terminal.
561
+ * Refuses while a turn is in flight (idle guard) unless --force. Own-session.
562
+ */
563
+ function cmdCompact(providerId, name, flags = {}) {
564
+ if (providerId !== "claude" && providerId !== "codex")
565
+ die(`compact supports claude and codex (not ${providerId})`);
566
+ const profile = requireProfile(providerId, name, "compact");
567
+ const clear = !!flags.clear;
568
+ const dryRun = !!flags["dry-run"];
569
+ const force = !!flags.force;
570
+ const literal = clear ? "/clear" : "/compact";
571
+ // 1. resolve the managed pane (profile-keyed; a profile has one managed name).
572
+ const sess = tmuxSessionName(providerId, profile);
573
+ const registry = readTmuxRegistry();
574
+ if (!registry[sess]) {
575
+ die(`no agent-switch-managed tmux pane for ${providerId}/${profile}.\n` +
576
+ `We only ever type into panes we own. Start one with: agent-switch run ${profile} --tmux\n` +
577
+ `Or run this yourself in the session's terminal: ${literal}`);
578
+ }
579
+ // 2. /clear is destructive — gate behind --force (no interactive confirm in a one-shot CLI).
580
+ if (clear && !force) {
581
+ die("/clear discards the whole conversation. Re-run with --force if you really mean it (or use /compact to summarize instead).");
582
+ }
583
+ // 3. idle guard — never type into a running turn.
584
+ const cfg = configDir(providerId, profile);
585
+ const rows = providerId === "codex" ? listCodexSessions(cfg, 10) : listSessions(cfg, 10);
586
+ if (providerId === "claude")
587
+ markLive(cfg, rows);
588
+ const target = rows.find((r) => r.live && r.file) ?? rows.find((r) => r.file);
589
+ if (target?.file && !force && turnInFlight(target.file, Date.now(), IDLE_GUARD_MS[providerId] ?? 15_000)) {
590
+ die(`a turn looks in-flight in ${providerId}/${profile} (last entry is unfinished + recent). Wait for it, or pass --force.`);
591
+ }
592
+ // 4. act (or dry-run).
593
+ const argv = sendKeysArgs(sess, literal);
594
+ if (dryRun) {
595
+ console.log(`[dry-run] tmux ${argv.map((a) => (a.includes(" ") ? `'${a}'` : a)).join(" ")}`);
596
+ return;
597
+ }
598
+ const res = spawnSync("tmux", argv, { stdio: "ignore" });
599
+ if (res.status === 0)
600
+ console.log(`Sent ${literal} to the managed pane "${sess}".`);
601
+ else
602
+ die(`tmux send-keys failed (is the pane still open?). Run ${literal} yourself in the session.`);
603
+ }
604
+ /**
605
+ * `agent-switch alerts on|off|status [--threshold a,b]` — toggle whether the
606
+ * daemon records context/usage crossings into the shared notification log
607
+ * (off by default) and set the per-session context thresholds. Own-session
608
+ * only. (Named `alerts` to avoid the `notify` command, which records a raw
609
+ * notification event into the log.)
610
+ */
611
+ function cmdAlerts(sub, flags = {}) {
612
+ const action = sub ?? "status";
613
+ const cfg = readTelemetryConfig(ROOT);
614
+ if (typeof flags.threshold === "string") {
615
+ const parsed = flags.threshold.split(",").map((s) => Number(s.trim())).filter((n) => Number.isFinite(n) && n > 0 && n <= 100);
616
+ if (parsed.length === 0)
617
+ die("--threshold must be a comma-separated list of 1–100 numbers, e.g. --threshold 80,95");
618
+ cfg.contextThresholds = parsed.sort((a, b) => a - b);
619
+ writeTelemetryConfig(ROOT, cfg);
620
+ }
621
+ if (action === "on" || action === "off") {
622
+ cfg.notify = action === "on";
623
+ writeTelemetryConfig(ROOT, cfg);
624
+ }
625
+ else if (action !== "status") {
626
+ die("usage: agent-switch alerts on|off|status [--threshold 80,95]");
627
+ }
628
+ if (flags.json) {
629
+ console.log(JSON.stringify({ notify: cfg.notify, contextThresholds: cfg.contextThresholds }, null, 2));
630
+ return;
631
+ }
632
+ console.log(`notifications: ${cfg.notify ? "on" : "off"} · context thresholds: ${cfg.contextThresholds.join(", ")}%`);
633
+ if (!cfg.notify && action === "status") {
634
+ console.log("(enable with `agent-switch alerts on`; the daemon records one coalesced notification per cycle when a live session crosses a threshold)");
635
+ }
636
+ }
637
+ /**
638
+ * Internal: the command Claude runs for each installed hook. Reads the hook's
639
+ * stdin JSON and its own CLAUDE_CONFIG_DIR env, maps the dir back to a profile
640
+ * (works even under a shared settings.json), and appends one event to the ring.
641
+ * Silent + best-effort — a hook must never disrupt the session it fires in.
642
+ */
643
+ function cmdHookEvent() {
644
+ try {
645
+ const raw = fs.readFileSync(0, "utf8"); // stdin
646
+ const o = JSON.parse(raw);
647
+ const cfg = process.env.CLAUDE_CONFIG_DIR;
648
+ if (!cfg)
649
+ return;
650
+ const who = profileFromConfigDir(cfg, ROOT);
651
+ if (!who)
652
+ return;
653
+ const rec = {
654
+ event: typeof o.hook_event_name === "string" ? o.hook_event_name : "unknown",
655
+ source: typeof o.source === "string" ? o.source : undefined,
656
+ sessionId: typeof o.session_id === "string" ? o.session_id : undefined,
657
+ at: new Date().toISOString(),
658
+ };
659
+ appendEvent(eventFile(ROOT, who.provider, who.profile), rec);
660
+ }
661
+ catch {
662
+ // never throw from a hook
663
+ }
664
+ }
665
+ /**
666
+ * `agent-switch sessions [profile] [--recent N] [--json]` — recent (and, where
667
+ * detectable, live) Claude Code sessions per profile. The JSON branch is the
668
+ * GUI contract: a flat array, metadata only — transcript content never leaves
669
+ * the profile dir (only the guarded first-line header is read).
670
+ */
671
+ function cmdSessions(providerId, providerExplicit, name, flags = {}) {
672
+ if (providerExplicit && providerId !== "claude" && providerId !== "codex") {
673
+ die(`sessions supports claude and codex for now (${providerId} parity is gated on its Phase-0 spike — see scripts/spikes/)`);
674
+ }
675
+ const recentFlag = flags.recent;
676
+ const limit = typeof recentFlag === "string" ? Number(recentFlag) : 10;
677
+ if (!Number.isFinite(limit) || limit < 1)
678
+ die("--recent must be a positive number");
679
+ const profiles = name ? [requireProfile(providerId, name, "sessions")] : listProfiles(providerId);
680
+ if (profiles.length === 0)
681
+ die(`no ${providerId} profiles`);
682
+ const all = [];
683
+ for (const p of profiles) {
684
+ const cfg = configDir(providerId, p);
685
+ // Codex stores date-partitioned rollout files (no encoded-cwd dir); its live
686
+ // detection is not available via the Claude pid-file mechanism, so codex rows
687
+ // stay recent-only (no live marking).
688
+ const rows = providerId === "codex" ? listCodexSessions(cfg, limit) : listSessions(cfg, limit);
689
+ if (providerId === "claude")
690
+ markLive(cfg, rows); // POSIX: pid→cwd→dir match; win32: recent-only
691
+ all.push(...rows.map((r) => ({ ...r, profile: p })));
692
+ }
693
+ if (flags.json) {
694
+ console.log(JSON.stringify(all.map((r) => {
695
+ const c = contextForRow(providerId, r);
696
+ return {
697
+ provider: providerId,
698
+ ...r,
699
+ context: c
700
+ ? { pct: c.pct, contextTokens: c.contextTokens, windowTokens: c.windowTokens, model: c.model, confidence: c.confidence }
701
+ : null,
702
+ };
703
+ }), null, 2));
704
+ return;
705
+ }
706
+ const providerFlag = providerId === "claude" ? "" : ` --provider ${providerId}`;
707
+ let any = false;
708
+ for (const p of profiles) {
709
+ const rows = all.filter((r) => r.profile === p);
710
+ if (rows.length === 0)
711
+ continue;
712
+ any = true;
713
+ console.log(`${providerId}/${p}:`);
714
+ for (const r of rows) {
715
+ const mark = r.live ? "*" : " ";
716
+ const where = r.cwd ?? r.projectDir;
717
+ const summary = r.summary ? ` ${r.summary}` : "";
718
+ const ctx = formatContext(contextForRow(providerId, r));
719
+ const ctxCol = ctx ? ` ${ctx}` : "";
720
+ console.log(`${mark} ${r.sessionId} ${where} ${ageOf(r.mtimeMs)}${ctxCol}${r.live ? " [live]" : ""}${summary}`);
721
+ }
722
+ }
723
+ if (!any) {
724
+ console.log(`No sessions found. Sessions appear after a \`${provider(providerId).binary}\` run on a profile.`);
725
+ }
726
+ else {
727
+ console.log(`\nTake one over with: agent-switch takeover <session-id> --to <profile>${providerFlag}`);
728
+ }
729
+ }
730
+ /**
731
+ * `agent-switch sessions preview <id> [--provider claude|codex] [--from <profile>]`
732
+ * — the first few conversation turns of ONE session, for the GUI's collapsible
733
+ * preview. Uses the sanctioned bounded reader (src/session-preview.ts, ADR-002):
734
+ * a capped head read, fenced, degraded to an EMPTY preview on any failure or
735
+ * unknown id (never an error — the GUI degrades to "no preview"). Codex is
736
+ * deferred (opaque/often-compressed rollout) → always an empty preview. JSON.
737
+ */
738
+ function cmdSessionsPreview(providerId, providerExplicit, sessionId, flags = {}) {
739
+ if (providerExplicit && providerId !== "claude" && providerId !== "codex") {
740
+ die(`sessions preview supports claude and codex (not ${providerId})`);
741
+ }
742
+ if (!sessionId) {
743
+ die("usage: agent-switch sessions preview <session-id> [--provider claude|codex] [--from <profile>]");
744
+ }
745
+ try {
746
+ assertValidSessionId(sessionId);
747
+ }
748
+ catch (e) {
749
+ die(e.message);
750
+ }
751
+ const empty = { messages: [], truncated: false };
752
+ const emit = (preview, profile) => console.log(JSON.stringify({ provider: providerId, profile, sessionId, ...preview }, null, 2));
753
+ if (providerId === "codex") {
754
+ emit(empty, null); // deferred: opaque, often .zst-compressed rollout blob (ADR-002)
755
+ return;
756
+ }
757
+ const candidates = typeof flags.from === "string"
758
+ ? [requireProfile("claude", flags.from, "sessions preview --from")]
759
+ : listProfiles("claude");
760
+ const hits = candidates
761
+ .map((p) => ({ profile: p, loc: locateSession(configDir("claude", p), sessionId) }))
762
+ .filter((h) => h.loc !== null);
763
+ if (hits.length === 0) {
764
+ emit(empty, null); // unknown id → empty preview, never an error
765
+ return;
766
+ }
767
+ const { profile, loc } = hits[0];
768
+ emit(readPreview("claude", loc.jsonl) ?? empty, profile);
769
+ }
770
+ /** M5 fallback: open the resume in a NEW terminal (macOS Terminal.app), else
771
+ * print it. Used when --in-place is asked for but there is no managed pane. */
772
+ function spawnNewTerminal(command) {
773
+ if (process.platform === "darwin") {
774
+ const script = `tell application "Terminal"\nactivate\ndo script "${command}"\nend tell`;
775
+ spawnSync("osascript", ["-e", script], { stdio: "ignore" });
776
+ console.log("Opened the resume in a new Terminal window — close the old window when you're done.");
777
+ }
778
+ else {
779
+ console.log(`Open a new terminal and run:\n ${command}\n(then close the old window).`);
780
+ }
781
+ }
782
+ /**
783
+ * In-place handoff. Inside an agent-switch-MANAGED tmux pane, replace the
784
+ * running CLI with the resume command under the target profile's env (the pane
785
+ * persists) — only ever a pane whose tmux session name is recorded as managed,
786
+ * never a user's own terminal. Otherwise fall back to M5 (spawn a new terminal).
787
+ * Exits the process.
788
+ */
789
+ function resumeInPlace(providerId, target, resumeArgs, resumeCommand) {
790
+ const mgmt = currentManagedSession(currentTmuxSessionName(), readTmuxRegistry());
791
+ if (!mgmt) {
792
+ spawnNewTerminal(resumeCommand); // M5 — no managed pane to respawn
793
+ process.exit(0);
794
+ }
795
+ const sess = tmuxSessionName(mgmt.provider, mgmt.profile);
796
+ const p = provider(providerId);
797
+ const argv = respawnPaneArgs(sess, p.envVar, configDir(providerId, target), [p.binary, ...resumeArgs]);
798
+ const res = spawnSync("tmux", argv, { stdio: "inherit" });
799
+ recordManagedSession(sess, { provider: providerId, profile: target }); // pane now runs the target
800
+ console.log(`Handed the session over to "${target}" in the managed tmux pane "${sess}".`);
801
+ process.exit(res.status ?? 0);
802
+ }
803
+ /**
804
+ * `agent-switch handoff extract <id> --from <profile> --to <targetProvider> [--print-only] [--json]`
805
+ * — compose a metadata-only brief from the SOURCE session (`--provider`/`--from`)
806
+ * for handing off to `--to` (target provider). Writes a 0600 brief file unless
807
+ * `--print-only`. Reads no transcript body.
808
+ *
809
+ * `agent-switch handoff seed --to <profile> [--provider P] --brief <path>` —
810
+ * open the TARGET agent INTERACTIVELY (pty) with a prompt that references the
811
+ * brief BY PATH (content never enters argv). The source session is untouched.
812
+ */
813
+ function cmdHandoff(providerId, sub, positional = [], flags = {}) {
814
+ if (sub === "extract") {
815
+ const sessionId = positional[0];
816
+ if (!sessionId || typeof flags.to !== "string") {
817
+ die("usage: agent-switch handoff extract <session-id> --from <profile> --to <target-provider> [--provider claude|codex] [--print-only] [--json]");
818
+ }
819
+ try {
820
+ assertValidSessionId(sessionId);
821
+ }
822
+ catch (e) {
823
+ die(e.message);
824
+ }
825
+ const targetProvider = resolveProviderValue(flags.to);
826
+ const srcProfile = requireProfile(providerId, typeof flags.from === "string" ? flags.from : undefined, "handoff extract --from");
827
+ const cfg = configDir(providerId, srcProfile);
828
+ const brief = extractBrief({ provider: providerId, profile: srcProfile, sessionId, configDir: cfg, targetProvider });
829
+ sweepBriefs(cfg);
830
+ if (flags["print-only"]) {
831
+ if (flags.json)
832
+ console.log(JSON.stringify({ provider: providerId, profile: srcProfile, sessionId, targetProvider, brief }, null, 2));
833
+ else
834
+ console.log(brief);
835
+ return;
836
+ }
837
+ const briefPath = writeBrief(cfg, sessionId, brief);
838
+ if (flags.json)
839
+ console.log(JSON.stringify({ provider: providerId, profile: srcProfile, sessionId, targetProvider, briefPath }, null, 2));
840
+ else
841
+ console.log(`Wrote handoff brief: ${briefPath}\nSeed the target with: agent-switch handoff seed --to <profile> --provider ${targetProvider} --brief ${briefPath}`);
842
+ return;
843
+ }
844
+ if (sub === "seed") {
845
+ if (typeof flags.to !== "string" || typeof flags.brief !== "string") {
846
+ die("usage: agent-switch handoff seed --to <profile> [--provider claude|codex] --brief <path>");
847
+ }
848
+ const target = requireProfile(providerId, flags.to, "handoff seed --to");
849
+ const briefPath = path.resolve(flags.brief);
850
+ if (!fs.existsSync(briefPath))
851
+ die(`brief not found: ${briefPath}`);
852
+ const prompt = seedPrompt(briefPath);
853
+ if (flags["print-only"]) {
854
+ console.log(`agent-switch run ${target} --provider ${providerId} -- ${JSON.stringify(prompt)}`);
855
+ return;
856
+ }
857
+ // Interactive launch (auth is interactive — spike h1). Source untouched.
858
+ cmdRun(providerId, target, [prompt]);
859
+ return;
860
+ }
861
+ die("usage: agent-switch handoff extract|seed ...");
862
+ }
863
+ /**
864
+ * `agent-switch sessions rm <id> [--provider claude|codex] [--from <profile>]
865
+ * [--purge] [--yes] [--ack <id>] [--json]` — delete ONE session. Claude: a
866
+ * recoverable trash-move (undo via `sessions restore`), `--purge` for true
867
+ * deletion. Codex: the native `codex archive` (trash) / `codex delete` (purge),
868
+ * because Codex owns its store format (.zst / indexes). Guards: strict id
869
+ * validation, no-`--force`-over-live (Claude pids re-checked HERE; Codex mtime
870
+ * freshness), multi-profile ambiguity refusal, shared-history acknowledgment.
871
+ * `--yes` is mandatory (the GUI passes it after its own confirmation).
872
+ */
873
+ function cmdSessionsRm(providerId, providerExplicit, sessionId, flags = {}) {
874
+ if (providerExplicit && providerId !== "claude" && providerId !== "codex") {
875
+ die(`sessions rm supports claude and codex (not ${providerId})`);
876
+ }
877
+ if (!sessionId) {
878
+ die("usage: agent-switch sessions rm <session-id> [--provider claude|codex] [--from <profile>] [--purge] [--yes] [--json]");
879
+ }
880
+ try {
881
+ assertValidSessionId(sessionId);
882
+ }
883
+ catch (e) {
884
+ die(e.message);
885
+ }
886
+ const purge = !!flags.purge;
887
+ const json = !!flags.json;
888
+ if (!flags.yes)
889
+ die("refusing to delete without --yes (the GUI passes it after its own confirmation)");
890
+ if (providerId === "codex")
891
+ return sessionsRmCodex(sessionId, flags, purge, json);
892
+ const candidates = typeof flags.from === "string"
893
+ ? [requireProfile("claude", flags.from, "sessions rm --from")]
894
+ : listProfiles("claude");
895
+ const hits = candidates
896
+ .map((p) => ({ profile: p, loc: locateSession(configDir("claude", p), sessionId) }))
897
+ .filter((h) => h.loc !== null);
898
+ if (hits.length === 0)
899
+ die(`session ${sessionId} not found in any claude profile (see: agent-switch sessions)`);
900
+ if (hits.length > 1) {
901
+ die(`session ${sessionId} exists in MULTIPLE profiles (${hits.map((h) => h.profile).join(", ")}) — pick one with --from`);
902
+ }
903
+ const { profile, loc } = hits[0];
904
+ const cfg = configDir("claude", profile);
905
+ // Live-guard, re-checked at exec time. --force does NOT override delete.
906
+ const livePids = liveSessionPids(cfg);
907
+ if (livePids.length > 0) {
908
+ die(`profile "${profile}" has live Claude sessions (PIDs ${livePids.join(", ")}). Stop the session first — --force does not override delete.`);
909
+ }
910
+ // Shared-history: deleting removes the transcript for EVERY profile sharing the tree.
911
+ const sharers = listProfiles("claude").filter((p) => p !== profile && sharedHistory(cfg, configDir("claude", p)));
912
+ if (sharers.length > 0 && flags.ack !== sessionId) {
913
+ die(`profiles [${sharers.join(", ")}] share this history tree — deleting removes the session for all of them. Re-run with --ack ${sessionId} to confirm.`);
914
+ }
915
+ const res = deleteSession(loc, { purge });
916
+ sweepTrash(cfg);
917
+ if (json) {
918
+ console.log(JSON.stringify({ provider: "claude", profile, sessionId, ...res }, null, 2));
919
+ return;
920
+ }
921
+ if (res.mode === "trash") {
922
+ console.log(`Trashed claude session ${sessionId} from "${profile}". Restore: agent-switch sessions restore ${res.trashId} --from ${profile}`);
923
+ }
924
+ else {
925
+ console.log(`Purged claude session ${sessionId} from "${profile}" (irreversible).`);
926
+ if (res.residue.length)
927
+ console.log(` could not remove: ${res.residue.join(", ")}`);
928
+ }
929
+ }
930
+ /** Codex delete path — native `codex archive`/`delete` in the profile's
931
+ * CODEX_HOME (codex exits 0 even on not-found, so success is read from output). */
932
+ function sessionsRmCodex(sessionId, flags, purge, json) {
933
+ const candidates = typeof flags.from === "string"
934
+ ? [requireProfile("codex", flags.from, "sessions rm --from")]
935
+ : listProfiles("codex");
936
+ const hits = candidates
937
+ .map((p) => ({ profile: p, loc: locateCodexSession(configDir("codex", p), sessionId) }))
938
+ .filter((h) => h.loc !== null);
939
+ if (hits.length === 0)
940
+ die(`session ${sessionId} not found in any codex profile`);
941
+ if (hits.length > 1) {
942
+ die(`session ${sessionId} exists in MULTIPLE codex profiles (${hits.map((h) => h.profile).join(", ")}) — pick one with --from`);
943
+ }
944
+ const { profile, loc } = hits[0];
945
+ const cfg = configDir("codex", profile);
946
+ // Codex has no live-pid signal → mtime-freshness proxy; typed --ack overrides.
947
+ const ageMs = Date.now() - fs.statSync(loc.rollout).mtimeMs;
948
+ if (ageMs < 60_000 && flags.ack !== sessionId) {
949
+ die(`codex session ${sessionId} was written ${Math.round(ageMs / 1000)}s ago and may be live (codex has no liveness signal). Re-run with --ack ${sessionId} if it is idle.`);
950
+ }
951
+ const action = purge ? "delete" : "archive";
952
+ const p = provider("codex");
953
+ const env = { ...process.env, [p.envVar]: cfg };
954
+ const r = spawnSync(resolveBinary(p.binary), codexSessionCommand(action, sessionId), { env, encoding: "utf8" });
955
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
956
+ if (/no .*session.*found/i.test(out))
957
+ die(`codex could not find session ${sessionId} in "${profile}": ${out}`);
958
+ if (r.status && r.status !== 0)
959
+ die(`codex ${action} failed: ${out || `exit ${r.status}`}`);
960
+ if (json) {
961
+ console.log(JSON.stringify({ provider: "codex", profile, sessionId, mode: purge ? "purge" : "trash", action }, null, 2));
962
+ return;
963
+ }
964
+ console.log(purge
965
+ ? `Deleted codex session ${sessionId} from "${profile}" via \`codex delete\` (irreversible).`
966
+ : `Archived codex session ${sessionId} from "${profile}" via \`codex archive\`. Restore: agent-switch sessions restore ${sessionId} --provider codex --from ${profile}`);
967
+ }
968
+ /**
969
+ * `agent-switch sessions restore <handle> [--provider codex] [--from <profile>]`
970
+ * — undo a delete. Claude: `<handle>` is the trash-id; the owning profile is
971
+ * scanned (or `--from`). Codex: `<handle>` is the session id; runs native
972
+ * `codex unarchive` and needs `--from` for the CODEX_HOME.
973
+ */
974
+ function cmdSessionsRestore(providerId, providerExplicit, handle, flags = {}) {
975
+ if (providerExplicit && providerId !== "claude" && providerId !== "codex") {
976
+ die(`sessions restore supports claude and codex (not ${providerId})`);
977
+ }
978
+ if (!handle)
979
+ die("usage: agent-switch sessions restore <trash-id|session-id> [--provider codex] [--from <profile>]");
980
+ if (providerId === "codex") {
981
+ try {
982
+ assertValidSessionId(handle);
983
+ }
984
+ catch (e) {
985
+ die(e.message);
986
+ }
987
+ if (typeof flags.from !== "string")
988
+ die("codex restore needs --from <profile> (the session's home)");
989
+ const profile = requireProfile("codex", flags.from, "sessions restore --from");
990
+ const p = provider("codex");
991
+ const env = { ...process.env, [p.envVar]: configDir("codex", profile) };
992
+ const r = spawnSync(resolveBinary(p.binary), codexSessionCommand("unarchive", handle), { env, encoding: "utf8" });
993
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
994
+ if (/no .*session.*found/i.test(out))
995
+ die(`codex could not find an archived session ${handle} in "${profile}": ${out}`);
996
+ console.log(`Unarchived codex session ${handle} in "${profile}".`);
997
+ return;
998
+ }
999
+ const candidates = typeof flags.from === "string"
1000
+ ? [requireProfile("claude", flags.from, "sessions restore --from")]
1001
+ : listProfiles("claude");
1002
+ const owner = candidates.find((p) => trashedSessionExists(configDir("claude", p), handle));
1003
+ if (!owner) {
1004
+ die(`no trashed session "${handle}" found${typeof flags.from === "string" ? ` in ${flags.from}` : ""} (use the trash-id printed when you deleted it)`);
1005
+ }
1006
+ const r = restoreSession(configDir("claude", owner), handle);
1007
+ console.log(`Restored claude session to ${r.restored} in "${owner}".`);
1008
+ }
1009
+ /**
1010
+ * `agent-switch takeover <session-id> --to <profile>` — move a session between
1011
+ * two Claude profiles and resume it there. Move by default; `--keep-source`
1012
+ * copies and forces `--fork-session` on the target (fresh id there) so two
1013
+ * profiles never both own a file under one session id. See the roadmap's
1014
+ * guard order — every refusal here is a data-integrity rule, not polish.
1015
+ */
1016
+ function cmdTakeover(providerId, providerExplicit, sessionId, flags = {}) {
1017
+ if (providerExplicit && providerId !== "claude" && providerId !== "codex") {
1018
+ die(`takeover supports claude and codex for now (${providerId} parity is gated on its Phase-0 spike — see scripts/spikes/)`);
1019
+ }
1020
+ if (!sessionId || typeof flags.to !== "string") {
1021
+ die("usage: agent-switch takeover <session-id> --to <profile> [--provider claude|codex] [--from <profile>] " +
1022
+ "[--keep-source] [--print-only] [--force] [--json]\n" +
1023
+ " (list candidates with: agent-switch sessions)");
1024
+ }
1025
+ const target = requireProfile(providerId, flags.to, "takeover --to");
1026
+ const keepSource = !!flags["keep-source"];
1027
+ const printOnly = !!flags["print-only"];
1028
+ const json = !!flags.json;
1029
+ const inPlace = !!flags["in-place"];
1030
+ if (inPlace && (printOnly || json))
1031
+ die("--in-place cannot be combined with --print-only or --json.");
1032
+ if (inPlace && keepSource) {
1033
+ die("--in-place cannot be combined with --keep-source (the fork-cleanup needs a separate interactive step).");
1034
+ }
1035
+ if (providerId === "codex") {
1036
+ return takeoverCodex(sessionId, target, { keepSource, printOnly, json, inPlace, from: flags.from, force: !!flags.force });
1037
+ }
1038
+ // Source resolution: --from, or a scan across every Claude profile. More than
1039
+ // one hit means same-id divergence already exists — surface, never guess.
1040
+ const candidates = typeof flags.from === "string"
1041
+ ? [requireProfile("claude", flags.from, "takeover --from")]
1042
+ : listProfiles("claude");
1043
+ const hits = candidates
1044
+ .map((p) => ({ profile: p, loc: locateSession(configDir("claude", p), sessionId) }))
1045
+ .filter((h) => h.loc !== null);
1046
+ if (hits.length === 0) {
1047
+ die(`session ${sessionId} not found in any claude profile (see: agent-switch sessions)`);
1048
+ }
1049
+ if (hits.length > 1) {
1050
+ die(`session ${sessionId} exists in MULTIPLE profiles (${hits.map((h) => h.profile).join(", ")}) — ` +
1051
+ "the copies have diverged. Pick one explicitly with --from after inspecting them.");
1052
+ }
1053
+ const { profile: source, loc } = hits[0];
1054
+ if (source === target)
1055
+ die(`session ${sessionId} is already in profile "${target}"`);
1056
+ // keep-source needs the interactive fork+cleanup step: without it the target
1057
+ // would keep a resumable copy under the ORIGINAL id — exactly the divergence
1058
+ // this command exists to prevent. (The GUI orchestrates this via --json later.)
1059
+ if (keepSource && (printOnly || json || !process.stdin.isTTY)) {
1060
+ die("--keep-source needs an interactive terminal (the fork's transfer copy is cleaned up after the session ends) — run it without --print-only/--json in a TTY");
1061
+ }
1062
+ const srcCfg = configDir("claude", source);
1063
+ const tgtCfg = configDir("claude", target);
1064
+ // Live guard: moving (or forking) under a running session risks interleaving
1065
+ // and appends into a file we just moved. Conservative by design: ANY live
1066
+ // session on the source profile blocks, `--force` overrides.
1067
+ const livePids = liveSessionPids(srcCfg);
1068
+ if (livePids.length > 0 && !flags.force) {
1069
+ die(`profile "${source}" has live Claude sessions (PIDs ${livePids.join(", ")}). ` +
1070
+ "Close them first (or --force if you are sure this session is not one of them).");
1071
+ }
1072
+ const resumeArgs = ["--resume", sessionId, ...(keepSource ? ["--fork-session"] : [])];
1073
+ const resumeCommand = `agent-switch run ${target} -- ${resumeArgs.join(" ")}`;
1074
+ // Shared-history mode (`share on --history`): both profiles already see one
1075
+ // projects/ tree — file ops would be self-moves. Degrade to the resume step.
1076
+ if (sharedHistory(srcCfg, tgtCfg)) {
1077
+ if (json) {
1078
+ console.log(JSON.stringify({ sessionId, from: source, to: target, transferred: [], shared: true, resumeCommand }, null, 2));
1079
+ return;
1080
+ }
1081
+ console.log(`Profiles "${source}" and "${target}" share one history tree — nothing to move.`);
1082
+ console.log(`Resume on the target with:\n ${resumeCommand}`);
1083
+ if (!printOnly && process.stdin.isTTY)
1084
+ process.exit(launch(provider("claude"), target, resumeArgs));
1085
+ return;
1086
+ }
1087
+ let result;
1088
+ try {
1089
+ result = transferSession(loc, tgtCfg, keepSource);
1090
+ }
1091
+ catch (err) {
1092
+ die(String(err?.message ?? err));
1093
+ }
1094
+ if (json) {
1095
+ console.log(JSON.stringify({ sessionId, from: source, to: target, transferred: result.actions, shared: false, resumeCommand }, null, 2));
1096
+ return;
1097
+ }
1098
+ console.log(`Session ${sessionId}: ${source} -> ${target}`);
1099
+ for (const a of result.actions)
1100
+ console.log(` ${a}`);
1101
+ if (keepSource) {
1102
+ console.log("Resuming with --fork-session (fresh id on the target; note: session-scoped");
1103
+ console.log("permission approvals do not carry into a fork — Claude asks once more).");
1104
+ const rc = launch(provider("claude"), target, resumeArgs);
1105
+ // The fork got its own id; drop the vehicle copy that still carries the
1106
+ // original id, or both profiles would own that id (the g02 trap).
1107
+ cleanupForkVehicle(tgtCfg, loc.projectDir, sessionId);
1108
+ console.log(`Cleaned up the transfer copy (${sessionId}) on the target — the fork has its own id.`);
1109
+ process.exit(rc);
1110
+ }
1111
+ console.log(`Resume it with:\n ${resumeCommand}`);
1112
+ if (inPlace)
1113
+ resumeInPlace("claude", target, resumeArgs, resumeCommand); // exits: respawn managed pane, else M5
1114
+ if (!printOnly && process.stdin.isTTY)
1115
+ process.exit(launch(provider("claude"), target, resumeArgs));
1116
+ }
1117
+ /**
1118
+ * Codex takeover — move-only (g03 outcome (a): a rollout moved into another
1119
+ * authenticated CODEX_HOME resumes immediately by id). Codex has no verified
1120
+ * fork spike, so `--keep-source` is refused rather than risk same-id
1121
+ * divergence, and no reliable pid-file live detection exists, so the absence of
1122
+ * a live-guard is surfaced instead of implied.
1123
+ */
1124
+ function takeoverCodex(sessionId, target, opts) {
1125
+ const { keepSource, printOnly, json, inPlace, from } = opts;
1126
+ if (keepSource) {
1127
+ die("--keep-source is not supported for codex: fork parity is unverified (no g0.2-equivalent spike), so a kept source would risk two homes owning one session id. Codex takeover is move-only.");
1128
+ }
1129
+ const candidates = typeof from === "string"
1130
+ ? [requireProfile("codex", from, "takeover --from")]
1131
+ : listProfiles("codex");
1132
+ const hits = candidates
1133
+ .map((p) => ({ profile: p, loc: locateCodexSession(configDir("codex", p), sessionId) }))
1134
+ .filter((h) => h.loc !== null);
1135
+ if (hits.length === 0) {
1136
+ die(`session ${sessionId} not found in any codex profile (see: agent-switch sessions --provider codex)`);
1137
+ }
1138
+ if (hits.length > 1) {
1139
+ die(`session ${sessionId} exists in MULTIPLE codex profiles (${hits.map((h) => h.profile).join(", ")}) — ` +
1140
+ "the copies have diverged. Pick one explicitly with --from after inspecting them.");
1141
+ }
1142
+ const { profile: source, loc } = hits[0];
1143
+ if (source === target)
1144
+ die(`session ${sessionId} is already in profile "${target}"`);
1145
+ const resumeArgs = ["resume", sessionId];
1146
+ const resumeCommand = `agent-switch run ${target} --provider codex -- ${resumeArgs.join(" ")}`;
1147
+ let result;
1148
+ try {
1149
+ result = transferCodexSession(loc, configDir("codex", target));
1150
+ }
1151
+ catch (err) {
1152
+ die(String(err?.message ?? err));
1153
+ }
1154
+ if (json) {
1155
+ console.log(JSON.stringify({ provider: "codex", sessionId, from: source, to: target, transferred: result.actions, shared: false, resumeCommand }, null, 2));
1156
+ return;
1157
+ }
1158
+ console.log(`Session ${sessionId}: codex ${source} -> ${target}`);
1159
+ for (const a of result.actions)
1160
+ console.log(` ${a}`);
1161
+ console.log("Note: codex has no pid-file live detection — make sure no active codex session was running on the source profile.");
1162
+ console.log(`Resume it with:\n ${resumeCommand}`);
1163
+ if (inPlace)
1164
+ resumeInPlace("codex", target, resumeArgs, resumeCommand); // exits
1165
+ if (!printOnly && process.stdin.isTTY)
1166
+ process.exit(launch(provider("codex"), target, resumeArgs));
1167
+ }
1168
+ function cmdRemove(providerId, name, force = false) {
1169
+ const n = requireProfile(providerId, name, "remove");
1170
+ if (activeFor(providerId) === n && !force) {
1171
+ die(`"${n}" is the active ${providerId} profile. Switch away first or use --force.`);
1172
+ }
1173
+ if (providerId === "claude") {
1174
+ const pids = liveSessionPids(configDir("claude", n));
1175
+ if (pids.length > 0 && !force) {
1176
+ die(`profile "${n}" has live Claude Code sessions (PIDs ${pids.join(", ")}). Close them or use --force.`);
1177
+ }
1178
+ }
1179
+ // Keychain entries exist only for claude (darwin); codex/antigravity are file-based,
1180
+ // so computing a Claude hash for them would be a conceptual no-op — skip it.
1181
+ const removedEntry = providerId === "claude" ? credentials.removeEntry(configDir(providerId, n)) : false;
1182
+ fs.rmSync(profileDir(providerId, n), { recursive: true, force: true });
1183
+ if (activeFor(providerId) === n)
1184
+ setActive(providerId, null);
1185
+ clearLabel(providerId, n); // drop its label so it can't linger as an orphan
1186
+ const pruned = pruneMappings(providerId, n);
1187
+ console.log(`Removed ${providerId} profile "${n}".`);
1188
+ if (removedEntry)
1189
+ console.log("Removed its keychain credential entry.");
1190
+ if (pruned.length > 0)
1191
+ console.log(`Removed ${pruned.length} directory mapping(s).`);
1192
+ }
1193
+ /** Set or clear a profile's label (Work / Personal / Other). */
1194
+ function cmdLabel(providerId, name, label) {
1195
+ // The profile need NOT exist yet: a label is independent state keyed by
1196
+ // provider/name, and the GUI sets the tag up front while `add` runs the login
1197
+ // in a terminal. Requiring the profile here made that set fail (and the GUI
1198
+ // swallowed the error), so a new profile's tag was silently lost.
1199
+ if (!name)
1200
+ die(`usage: agent-switch label <profile> [${PROFILE_LABELS.join("|")}|none] [--provider P]`);
1201
+ if (label === undefined || label === "none" || label === "clear") {
1202
+ setLabel(providerId, name, null);
1203
+ console.log(`Cleared label for ${providerId}/${name}.`);
1204
+ return;
1205
+ }
1206
+ if (!isProfileLabel(label)) {
1207
+ die(`invalid label "${label}" (choose: ${PROFILE_LABELS.join(", ")}, or "none" to clear)`);
1208
+ }
1209
+ setLabel(providerId, name, label);
1210
+ console.log(`Labeled ${providerId}/${name} as ${label}.`);
1211
+ }
1212
+ /** Enable/disable opt-in auto-switch, or show the current setting. */
1213
+ /** Rename a profile (move its config dir + carry active/label/mappings). */
1214
+ function cmdRename(providerId, from, to) {
1215
+ const n = requireProfile(providerId, from, "rename");
1216
+ if (!to)
1217
+ die("usage: agent-switch rename <old> <new> [--provider P]");
1218
+ if (!/^[A-Za-z0-9._-]+$/.test(to)) {
1219
+ die("new name may contain only letters, numbers, dots, dashes and underscores.");
1220
+ }
1221
+ if (to === n)
1222
+ return; // no-op
1223
+ if (profileExists(providerId, to))
1224
+ die(`profile "${to}" already exists for ${providerId}.`);
1225
+ if (providerId === "claude") {
1226
+ const pids = liveSessionPids(configDir("claude", n));
1227
+ if (pids.length > 0)
1228
+ die(`profile "${n}" has live Claude sessions (PIDs ${pids.join(", ")}). Close them first.`);
1229
+ }
1230
+ try {
1231
+ renameProfile(providerId, n, to);
1232
+ }
1233
+ catch (err) {
1234
+ die(String(err?.message ?? err));
1235
+ }
1236
+ for (const m of mappingRows()) {
1237
+ if (m.provider === providerId && m.name === n)
1238
+ setMapping(m.path, providerId, to);
1239
+ }
1240
+ console.log(`Renamed ${providerId} profile "${n}" → "${to}".`);
1241
+ }
1242
+ /** Manually redeem one banked rate-limit reset for a Codex profile. Consumes a
1243
+ * real, scarce credit — the GUI gates this behind a confirmation. */
1244
+ async function cmdReset(providerId, name) {
1245
+ if (providerId !== "codex")
1246
+ die("reset is only available for codex (banked rate-limit resets).");
1247
+ const n = requireProfile("codex", name, "reset");
1248
+ const r = await redeemResetCredit(configDir("codex", n));
1249
+ if (r.ok) {
1250
+ console.log(`Redeemed a reset for codex/${n} (windows_reset=${r.windowsReset ?? "?"}).`);
1251
+ }
1252
+ else {
1253
+ die(`reset failed: ${r.reason ?? "unknown"}`);
1254
+ }
1255
+ }
1256
+ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
1257
+ // `autoswitch strategy [reset-first|rotation-first]` — global switch behaviour.
1258
+ if (mode === "strategy") {
1259
+ if (value === "reset-first" || value === "rotation-first") {
1260
+ setSwitchStrategy(value);
1261
+ }
1262
+ else if (value !== undefined) {
1263
+ die("usage: agent-switch autoswitch strategy [reset-first|rotation-first]");
1264
+ }
1265
+ if (flags.json) {
1266
+ console.log(JSON.stringify({ strategy: readSwitchStrategy() }));
1267
+ }
1268
+ else {
1269
+ console.log(`Auto-switch strategy: ${readSwitchStrategy()}.`);
1270
+ }
1271
+ return;
1272
+ }
1273
+ const thresholdFlag = flags.threshold;
1274
+ const threshold = typeof thresholdFlag === "string" ? Number(thresholdFlag) : undefined;
1275
+ if (threshold !== undefined && (!Number.isFinite(threshold) || threshold < 1 || threshold > 100)) {
1276
+ die("--threshold must be a number between 1 and 100");
1277
+ }
1278
+ // --tag <all|work|personal|other>: restrict switch targets to accounts with
1279
+ // that label (case-insensitive). "all" clears the filter.
1280
+ const tagFlag = flags.tag;
1281
+ let tag;
1282
+ if (typeof tagFlag === "string") {
1283
+ const low = tagFlag.toLowerCase();
1284
+ const matched = low === "all" ? "all" : PROFILE_LABELS.find((l) => l.toLowerCase() === low);
1285
+ if (!matched)
1286
+ die("--tag must be one of: all, work, personal, other");
1287
+ tag = matched;
1288
+ }
1289
+ if (mode === "on" || mode === "off") {
1290
+ // Auto-switch only makes sense where there is a usage readout to trigger on
1291
+ // (Claude today). Elsewhere there is nothing to act on, so enabling is refused.
1292
+ if (mode === "on" && !provider(providerId).hasUsageReadout) {
1293
+ die(`auto-switch is only available for providers with a usage readout (Claude, Codex).`);
1294
+ }
1295
+ const cfg = setAutoSwitch(providerId, {
1296
+ enabled: mode === "on",
1297
+ ...(threshold !== undefined ? { threshold } : {}),
1298
+ ...(tag !== undefined ? { tag } : {}),
1299
+ });
1300
+ const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
1301
+ console.log(`Auto-switch for ${providerId} ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
1302
+ if (cfg.enabled) {
1303
+ // Usage-policy disclosure — restored deliberately. Automated rotation on
1304
+ // quota signals pools separate subscriptions to route around per-account
1305
+ // rate limits; a prior review found this can violate the provider's usage
1306
+ // policy. Enabling is the user's decision, but the risk must be stated.
1307
+ console.warn("⚠️ Usage-policy warning: quota-triggered rotation across accounts can violate\n" +
1308
+ " the providers' usage policies — it pools separate subscriptions to route around\n" +
1309
+ " per-account rate limits. An internal review unanimously recommended removing it;\n" +
1310
+ " it ships off-by-default anyway. See the README before enabling. (Per-context\n" +
1311
+ " profile switching — private / work / client — is unaffected; this applies only\n" +
1312
+ " to automated quota-driven rotation.)");
1313
+ const strat = readSwitchStrategy();
1314
+ console.log(`Strategy: ${strat}${strat === "reset-first" ? " (redeem a banked reset before switching, Codex)" : ""}.\n` +
1315
+ `The daemon moves the active ${providerId} profile to the account with the most headroom once the\n` +
1316
+ "active one hits the threshold. Run `agent-switch service start` so the daemon is watching.");
1317
+ }
1318
+ return;
1319
+ }
1320
+ if (mode === undefined || mode === "status") {
1321
+ if (flags.json) {
1322
+ // The GUI needs every provider's state at once (per-tab dots).
1323
+ console.log(JSON.stringify(readAutoSwitchAll()));
1324
+ return;
1325
+ }
1326
+ console.log(`strategy: ${readSwitchStrategy()}`);
1327
+ for (const p of providerExplicit ? [providerId] : PROVIDER_IDS) {
1328
+ const cfg = readAutoSwitch(p);
1329
+ const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
1330
+ console.log(`${p}: auto-switch ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
1331
+ }
1332
+ return;
1333
+ }
1334
+ die("usage: agent-switch autoswitch on|off|status|strategy [reset-first|rotation-first] [--provider P] [--threshold <1-100>] [--tag all|work|personal|other] [--json]");
1335
+ }
1336
+ /**
1337
+ * Enable/disable a provider's surfaces (cli / ui), or show the current setting.
1338
+ * Disabling never deletes profiles — it only hides the provider from `list` and
1339
+ * the GUI; re-enabling restores everything. Default enabled: Claude + Codex.
1340
+ */
1341
+ function cmdProviders(providerId, providerExplicit, mode, flags = {}) {
1342
+ const surfaceFlag = typeof flags.surface === "string" ? flags.surface : undefined;
1343
+ if (surfaceFlag !== undefined && surfaceFlag !== "cli" && surfaceFlag !== "ui") {
1344
+ die("--surface must be cli or ui");
1345
+ }
1346
+ if (mode === "enable" || mode === "disable") {
1347
+ const enabled = mode === "enable";
1348
+ const surfaces = surfaceFlag ? [surfaceFlag] : ["cli", "ui"];
1349
+ const cfg = surfaces.reduce((_acc, s) => setProviderSurface(providerId, s, enabled), readProviders()[providerId]);
1350
+ console.log(`${providerId}: cli ${cfg.cli ? "on" : "off"}, ui ${cfg.ui ? "on" : "off"}.`);
1351
+ return;
1352
+ }
1353
+ if (mode === undefined || mode === "status") {
1354
+ const all = readProviders();
1355
+ if (flags.json) {
1356
+ // The GUI needs every provider's enabled surfaces AND whether its binary
1357
+ // is installed (so it can show but not enable a missing provider) at once.
1358
+ const enriched = Object.fromEntries(PROVIDER_IDS.map((p) => [p, { ...all[p], installed: isProviderInstalled(p) }]));
1359
+ console.log(JSON.stringify(enriched));
1360
+ return;
1361
+ }
1362
+ for (const p of providerExplicit ? [providerId] : PROVIDER_IDS) {
1363
+ const inst = isProviderInstalled(p) ? "installed" : "not installed";
1364
+ console.log(`${p}: cli ${all[p].cli ? "on" : "off"}, ui ${all[p].ui ? "on" : "off"} (${inst}).`);
1365
+ }
1366
+ return;
1367
+ }
1368
+ die("usage: agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] [--json]");
1369
+ }
1370
+ /**
1371
+ * Remove agent-switch's own footprint. Deletes every profile (incl. Claude
1372
+ * keychain entries), the state/mappings, and stops+uninstalls the daemon. Does
1373
+ * NOT touch the provider CLIs' own default installs, nor `npm`/shell setup —
1374
+ * those are surfaced as manual follow-ups. Destructive → requires confirmation.
1375
+ */
1376
+ function cmdUninstall(flags = {}) {
1377
+ const claudeProfiles = listProfiles("claude");
1378
+ const allProfiles = listAllProfiles();
1379
+ if (!flags.force && !flags.yes) {
1380
+ console.log("This removes ALL agent-switch data:");
1381
+ console.log(` - ${allProfiles.length} profile(s) and their configs under ${ROOT}`);
1382
+ console.log(` - ${claudeProfiles.length} Claude keychain credential entr(y/ies) (macOS)`);
1383
+ console.log(" - directory mappings, active-profile state, and the background daemon/service");
1384
+ console.log("It does NOT touch your default claude/codex/antigravity installs.");
1385
+ console.log("\nRe-run with --force to proceed: agent-switch uninstall --force");
1386
+ return;
1387
+ }
1388
+ // Stop + uninstall the background service, and kill a manually-started daemon.
1389
+ try {
1390
+ serviceUninstall();
1391
+ }
1392
+ catch {
1393
+ /* best-effort */
1394
+ }
1395
+ const pid = readPid(PIDFILE);
1396
+ if (pid && processAlive(pid)) {
1397
+ try {
1398
+ process.kill(pid, "SIGTERM");
1399
+ }
1400
+ catch {
1401
+ /* best-effort */
1402
+ }
1403
+ }
1404
+ // Drop Claude keychain entries before deleting the dirs (darwin only; no-op elsewhere).
1405
+ let removedEntries = 0;
1406
+ for (const n of claudeProfiles) {
1407
+ if (credentials.removeEntry(configDir("claude", n)))
1408
+ removedEntries++;
1409
+ }
1410
+ fs.rmSync(ROOT, { recursive: true, force: true });
1411
+ console.log(`Removed ${allProfiles.length} profile(s) and all agent-switch data under ${ROOT}.`);
1412
+ if (removedEntries > 0)
1413
+ console.log(`Removed ${removedEntries} Claude keychain entr(y/ies).`);
1414
+ console.log("Follow-ups (manual, if you set them up):");
1415
+ console.log(" - `npm uninstall -g agent-switch` (or `npm unlink -g agent-switch`)");
1416
+ console.log(" - remove the `agent-switch shellenv` line from your shell rc");
1417
+ }
1418
+ async function cmdWeb(name) {
1419
+ const n = requireProfile("claude", name, "web");
1420
+ const userDataDir = browserDir("claude", n);
1421
+ fs.mkdirSync(userDataDir, { recursive: true, mode: 0o700 });
1422
+ let chromium;
1423
+ try {
1424
+ const mod = "playwright"; // non-literal so tsc doesn't require the package
1425
+ ({ chromium } = (await import(mod)));
1426
+ }
1427
+ catch {
1428
+ die("playwright is not installed. Run:\n npm install playwright && npx playwright install chromium");
1429
+ }
1430
+ console.log(`Opening claude.ai with the persistent browser profile "${n}"...`);
1431
+ console.log("Log in once — the session is stored in the profile and reused next time.");
1432
+ let context;
1433
+ try {
1434
+ context = await chromium.launchPersistentContext(userDataDir, {
1435
+ headless: false,
1436
+ viewport: null,
1437
+ args: ["--start-maximized"],
1438
+ });
1439
+ }
1440
+ catch (err) {
1441
+ die(`failed to launch a browser: ${err?.message ?? err}\n` +
1442
+ "On a headless Linux host there is no display to open — run this from a\n" +
1443
+ "desktop session, or ensure Chromium is installed: `npx playwright install chromium`.");
1444
+ }
1445
+ const page = context.pages()[0] ?? (await context.newPage());
1446
+ await page.goto("https://claude.ai/");
1447
+ await new Promise((resolve) => context.on("close", resolve));
1448
+ }
1449
+ // ---------- GUI app launch (foundation) --------------------------------------
1450
+ function cmdApps(json = false) {
1451
+ if (json) {
1452
+ const rows = APPS.map((a) => ({
1453
+ id: a.id,
1454
+ displayName: a.displayName,
1455
+ provider: a.provider,
1456
+ strategy: a.strategy,
1457
+ installed: isInstalled(a),
1458
+ }));
1459
+ console.log(JSON.stringify(rows, null, 2));
1460
+ return;
1461
+ }
1462
+ if (APPS.length === 0) {
1463
+ console.log("No GUI apps registered yet — support for desktop clients (Claude Desktop, Codex UI)");
1464
+ console.log("lands via their roadmaps. The launch layer (`agent-switch open`) is ready.");
1465
+ return;
1466
+ }
1467
+ for (const a of APPS) {
1468
+ const mark = isInstalled(a) ? "●" : "○";
1469
+ console.log(`${mark} ${a.id.padEnd(16)} ${a.displayName} (${a.provider}, ${a.strategy})`);
1470
+ }
1471
+ }
1472
+ const NOTIFICATION_KINDS = ["success", "error", "warning", "info"];
1473
+ /** `agent-switch notify --kind <k> --title <t> --message <m> [--json]` — record
1474
+ * an event in the shared notification log. The GUI uses this for its own
1475
+ * limit-fetch failures; the daemon appends directly via the module. `--json`
1476
+ * echoes the created notification (or `null` when it was deduplicated). */
1477
+ function cmdNotify(flags) {
1478
+ const kind = typeof flags.kind === "string" ? flags.kind : "info";
1479
+ const title = typeof flags.title === "string" ? flags.title : "";
1480
+ const message = typeof flags.message === "string" ? flags.message : "";
1481
+ if (!NOTIFICATION_KINDS.includes(kind)) {
1482
+ die(`usage: agent-switch notify --kind <${NOTIFICATION_KINDS.join("|")}> --title <t> --message <m> [--json]`);
1483
+ }
1484
+ if (!title && !message)
1485
+ die("agent-switch notify needs at least --title or --message");
1486
+ const created = appendNotification({ kind: kind, title, message });
1487
+ if (flags.json)
1488
+ console.log(JSON.stringify(created));
1489
+ }
1490
+ /** `agent-switch os-notify [on|off|status] [--json]` — toggle whether the
1491
+ * background daemon fires OS desktop notifications itself (for timeliness when
1492
+ * the GUI is closed). Default off. */
1493
+ function cmdOsNotify(sub, json = false) {
1494
+ if (sub === "on" || sub === "off")
1495
+ setOsNotifications(sub === "on");
1496
+ else if (sub && sub !== "status")
1497
+ die("usage: agent-switch os-notify [on|off|status] [--json]");
1498
+ const enabled = readOsNotifications();
1499
+ if (json)
1500
+ console.log(JSON.stringify({ enabled }));
1501
+ else
1502
+ console.log(`Daemon OS notifications ${enabled ? "ON" : "OFF"}.`);
1503
+ }
1504
+ /** `agent-switch notifications [clear] [--json]` — list the recent
1505
+ * notifications (newest first) or clear the log. */
1506
+ function cmdNotifications(sub, json = false) {
1507
+ if (sub === "clear") {
1508
+ clearNotifications();
1509
+ console.log(json ? "[]" : "Notifications cleared.");
1510
+ return;
1511
+ }
1512
+ const list = [...readNotifications()].reverse(); // newest first
1513
+ if (json) {
1514
+ console.log(JSON.stringify(list));
1515
+ return;
1516
+ }
1517
+ if (list.length === 0) {
1518
+ console.log("No notifications.");
1519
+ return;
1520
+ }
1521
+ for (const n of list) {
1522
+ const when = new Date(n.ts).toLocaleString();
1523
+ console.log(`[${when}] ${n.kind.toUpperCase()} ${n.title}${n.message ? ` — ${n.message}` : ""}`);
1524
+ }
1525
+ }
1526
+ /** Launch a registered GUI app on a profile, isolated (macOS). Profile: an
1527
+ * explicit positional name, else the active profile for the app's provider. */
1528
+ function cmdGui() {
1529
+ // Launch the tray/menubar GUI (its prebuilt binary ships as a per-platform
1530
+ // optional-dependency package; see gui-launch.ts).
1531
+ try {
1532
+ launchGui();
1533
+ console.log("Launched the agent-switch GUI.");
1534
+ }
1535
+ catch (err) {
1536
+ die(String(err?.message ?? err));
1537
+ }
1538
+ }
1539
+ function cmdOpen(appId, name) {
1540
+ if (!appId)
1541
+ die("usage: agent-switch open <app> [profile] (see `agent-switch apps`)");
1542
+ const app = findApp(appId);
1543
+ if (!app)
1544
+ die(`unknown app "${appId}". See \`agent-switch apps\` for registered apps.`);
1545
+ if (process.platform !== "darwin")
1546
+ die("GUI app launch is macOS-only for now.");
1547
+ const n = name ?? activeFor(app.provider);
1548
+ if (!n)
1549
+ die(`no profile given and none active for ${app.provider} — pass a profile name.`);
1550
+ requireProfile(app.provider, n, "open");
1551
+ if (!isInstalled(app))
1552
+ die(`${app.displayName} is not installed.`);
1553
+ let firstLaunch = false;
1554
+ if (app.strategy === "user-data-dir") {
1555
+ const dir = guiDataDir(app, n);
1556
+ firstLaunch = !fs.existsSync(dir);
1557
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); // lazy per-profile data dir
1558
+ }
1559
+ const spec = buildLaunch(app, n);
1560
+ spawn(spec.program, spec.args, { detached: true, stdio: "ignore" }).unref();
1561
+ console.log(`Opening ${app.displayName} on ${app.provider}/${n}...`);
1562
+ if (firstLaunch) {
1563
+ console.log("First launch of this profile — it opens logged-out; sign in once and the session");
1564
+ console.log("is saved to this profile. Quit other windows of this app before signing in.");
1565
+ }
1566
+ }
1567
+ function cmdShellenv(shellArg) {
1568
+ console.log(shellenvScript(detectShell(shellArg)));
1569
+ }
1570
+ function usage() {
1571
+ console.log(`agent-switch — switch accounts for Claude Code, Codex, and Antigravity (macOS · Linux · Windows)
1572
+
1573
+ Provider defaults to claude; pass --provider codex|antigravity for the others.
1574
+
1575
+ agent-switch add [--provider P] <name> create a profile and log it in
1576
+ agent-switch import [--provider P] <name> migrate the default install (no re-login)
1577
+ agent-switch use [--provider P] <name> set the active profile for a provider
1578
+ agent-switch deactivate [--provider P] clear the active profile for a provider
1579
+ agent-switch run [--provider P] <name> [--tmux] [..] launch the provider's CLI (--tmux = managed tmux session, POSIX)
1580
+ agent-switch list [--provider P] [--json] list profiles, grouped by provider
1581
+ agent-switch status [--provider P] [name] [--json] identity (+ Claude usage); --json = active only
1582
+ agent-switch current [--provider P] show the active profile(s)
1583
+ agent-switch whoami [--provider P] [name] show a profile's account identity
1584
+ agent-switch dir [--provider P] resolve profile for CWD (mapping > active)
1585
+ agent-switch map [--provider P] <name> [dir] map a directory to a profile
1586
+ agent-switch unmap [--provider P] [dir] remove a directory mapping
1587
+ agent-switch mappings list directory mappings
1588
+ agent-switch share on|sync|off|status [--history] [--source <profile|default>] [--json] (Claude)
1589
+ agent-switch sessions [profile] [--recent N] [--json] recent + live Claude sessions per profile (with context %)
1590
+ agent-switch hooks install|uninstall|status [profile] manage lifecycle push hooks in Claude settings.json
1591
+ agent-switch alerts on|off|status [--threshold 80,95] record context/usage crossings to the notification log (off by default)
1592
+ agent-switch compact <profile> [--clear] [--dry-run] [--force] type /compact (or /clear) into the profile's managed tmux pane
1593
+ agent-switch takeover <id> --to <profile> [--from <profile>] [--keep-source] [--in-place] [--print-only] [--force] move a session to another profile and resume it
1594
+ agent-switch web <name> claude.ai in a persistent browser (Claude)
1595
+ agent-switch remove [--provider P] <name> [--force] delete a profile
1596
+ agent-switch label [--provider P] <name> [Work|Personal|Other|none] tag a profile
1597
+ agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] per-provider auto-switch (default OFF)
1598
+ agent-switch reset <profile> --provider codex redeem one banked Codex rate-limit reset
1599
+ agent-switch rename <old> <new> [--provider P] rename a profile (name & keep its tag)
1600
+ agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] enable/disable a provider (default: Claude + Codex)
1601
+ agent-switch gui launch the tray/menubar GUI (bundled via npm)
1602
+ agent-switch apps list launchable GUI apps (macOS)
1603
+ agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
1604
+ agent-switch shellenv [--shell zsh|bash|fish|powershell] shell integration
1605
+ agent-switch service run|start|stop|status|install|uninstall background usage daemon
1606
+ agent-switch notifications [clear] [--json] recent notifications (auto-switches, fetch failures)
1607
+ agent-switch notify --kind K --title T --message M [--json] record a notification event
1608
+ agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications (default off)
1609
+ agent-switch uninstall [--force] remove all agent-switch data + daemon
1610
+ agent-switch doctor per-OS, per-provider self-check`);
1611
+ }
1612
+ // ---------- main -------------------------------------------------------------
1613
+ async function main() {
1614
+ const argv = process.argv.slice(2);
1615
+ const { cmd, providerId, providerExplicit, positional, flags } = parseArgs(argv);
1616
+ const rest = argv.slice(1); // args after the command (share/run parse these themselves)
1617
+ // One-time layout migration (v1 Claude profiles → provider-scoped). Quiet
1618
+ // unless it actually moves something. The `.layout-v2` marker makes this a
1619
+ // single existsSync after the first run, so there is no per-launch scan tax —
1620
+ // `dir` (the shell-prompt hot path) MUST still run it, or the first post-
1621
+ // upgrade `dir` would miss an un-migrated active profile and fall back to the
1622
+ // default config dir. Only pure help / shellenv (which emit text, touch no
1623
+ // profiles) skip it.
1624
+ if (cmd === "__hook-event")
1625
+ return cmdHookEvent(); // internal, silent, no migration
1626
+ if (cmd && !["help", "--help", "-h", "shellenv"].includes(cmd)) {
1627
+ const moved = migrateLegacyLayout();
1628
+ if (moved.length > 0) {
1629
+ // stderr, not stdout: `dir` is machine-consumed by the shell wrapper
1630
+ // (`dir="$(agent-switch dir 2>/dev/null)"`), so a status line on stdout
1631
+ // would pollute the resolved config path.
1632
+ console.error(`Migrated ${moved.length} Claude profile(s) to the new layout: ${moved.join(", ")}.`);
1633
+ }
1634
+ }
1635
+ switch (cmd) {
1636
+ case "add": return cmdAdd(providerId, positional[0]);
1637
+ case "import": return cmdImport(providerId, positional[0]);
1638
+ case "use": return cmdUse(providerId, positional[0]);
1639
+ case "deactivate": return cmdDeactivate(providerId);
1640
+ case "label": return cmdLabel(providerId, positional[0], positional[1]);
1641
+ case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags, positional[1]);
1642
+ case "reset": return cmdReset(providerId, positional[0]);
1643
+ case "rename": return cmdRename(providerId, positional[0], positional[1]);
1644
+ case "providers": return cmdProviders(providerId, providerExplicit, positional[0], flags);
1645
+ case "gui": return cmdGui();
1646
+ case "open": return cmdOpen(positional[0], positional[1]);
1647
+ case "apps": return cmdApps(!!flags.json);
1648
+ case "notify": return cmdNotify(flags);
1649
+ case "notifications": return cmdNotifications(positional[0], !!flags.json);
1650
+ case "os-notify": return cmdOsNotify(positional[0], !!flags.json);
1651
+ case "uninstall": return cmdUninstall(flags);
1652
+ case "run": {
1653
+ const r = parseRun(rest);
1654
+ return cmdRun(r.providerId, r.name, r.args);
1655
+ }
1656
+ case "handoff": return cmdHandoff(providerId, positional[0], positional.slice(1), flags);
1657
+ case "list":
1658
+ case "ls": return cmdList(providerExplicit ? providerId : undefined, !!flags.json);
1659
+ case "status": return cmdStatus(providerExplicit ? providerId : undefined, positional[0], !!flags.json);
1660
+ case "current": return cmdCurrent(providerExplicit ? providerId : undefined);
1661
+ case "whoami": return cmdWhoami(providerId, positional[0]);
1662
+ case "dir": return cmdDir(providerId);
1663
+ case "map": return cmdMap(providerId, positional[0], positional[1]);
1664
+ case "unmap": return cmdUnmap(providerExplicit ? providerId : undefined, positional[0]);
1665
+ case "mappings": return cmdMappings();
1666
+ case "share": return cmdShare(positional[0], rest.slice(1));
1667
+ case "sessions":
1668
+ if (positional[0] === "rm")
1669
+ return cmdSessionsRm(providerId, providerExplicit, positional[1], flags);
1670
+ if (positional[0] === "restore")
1671
+ return cmdSessionsRestore(providerId, providerExplicit, positional[1], flags);
1672
+ if (positional[0] === "preview")
1673
+ return cmdSessionsPreview(providerId, providerExplicit, positional[1], flags);
1674
+ return cmdSessions(providerId, providerExplicit, positional[0], flags);
1675
+ case "hooks": return cmdHooks(positional[0], positional[1]);
1676
+ case "alerts": return cmdAlerts(positional[0], flags);
1677
+ case "compact": return cmdCompact(providerId, positional[0], flags);
1678
+ case "takeover": return cmdTakeover(providerId, providerExplicit, positional[0], flags);
1679
+ case "web": return cmdWeb(positional[0]);
1680
+ case "remove":
1681
+ case "rm": return cmdRemove(providerId, positional[0], !!flags.force);
1682
+ case "shellenv": return cmdShellenv(flags.shell ?? positional[0]);
1683
+ case "service": return cmdService(positional[0]);
1684
+ case "doctor": return process.exit(await runDoctor());
1685
+ case "help":
1686
+ case "--help":
1687
+ case "-h": return usage();
1688
+ default:
1689
+ usage();
1690
+ process.exit(cmd ? 1 : 0);
1691
+ }
1692
+ }
1693
+ main().catch((err) => die(String(err?.message ?? err)));