@event4u/agent-switch 2.1.0 → 2.2.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 CHANGED
@@ -15,45 +15,58 @@ import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import * as readline from "node:readline";
17
17
  import { spawn, spawnSync } from "node:child_process";
18
- 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, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readGuard, setGuard, readBinaryPaths, setBinaryPath, ROOT, resetRebindKillSwitch, } from "./profiles.js";
18
+ import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, colorOf, isDisabled, setDisabled, setColor, isProfileColor, PROFILE_COLORS, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readGuard, setGuard, readBinaryPaths, setBinaryPath, ROOT, resetRebindKillSwitch, } from "./profiles.js";
19
+ import { browserWrapperScript, defaultBrowserDeps, listBrowsers, locateBrowser } from "./browsers.js";
19
20
  import { PROVIDER_IDS, provider, isProviderInstalled, resolveBinary } from "./providers.js";
20
- import { rebind, restoreRebind } from "./rebind.js";
21
+ import { borrowerOf, discardBinding, readBinding, rebind, releaseBorrowedAccount, restoreRebind } from "./rebind.js";
21
22
  import { buildRebindDialog, resolveRebindChoice, selectableRows } from "./rebind-dialog.js";
22
23
  import { parseArgs, parseRun, resolveProviderValue } from "./args.js";
23
24
  import { extractBrief, writeBrief, sweepBriefs, seedPrompt } from "./handoff.js";
24
25
  import { credentialStore } from "./credentials.js";
25
26
  import { withProperLock } from "./locks.js";
26
- import { applySharing, removeSharing, syncSharing, sharedLinkHealth } from "./share.js";
27
+ import { applySharing, removeSharing, syncSharing, sharedLinkHealth, PROMPT_HISTORY } from "./share.js";
27
28
  import { assertNotFull, exportConfig, importConfig } from "./config-transfer.js";
28
- import { detectShell, shellenvScript } from "./shellenv.js";
29
- import { runDoctor } from "./doctor.js";
29
+ import { detectShell, installShellIntegration, shellenvScript } from "./shellenv.js";
30
+ import { runDoctor, shellIntegrationCheck } from "./doctor.js";
31
+ import { appendActivity, readActivity, ACTIVITY_MAX_LINES } from "./activity.js";
32
+ import { readGuiSettings, writeGuiSettings, applyGuiSetting } from "./gui-settings.js";
33
+ import { readProtection, setProtection, renameProtection, forgetProtection } from "./protection.js";
34
+ import { POOL_PREFIX, addPoolMember, createPool, deletePool, findPool, isPoolRef, parseMember, poolIdOf, readPools, removePoolMember, renamePool, renamePoolMember, suggestPoolsFromLabels, } from "./pools.js";
35
+ import { resolveForDir } from "./dir-resolve.js";
36
+ import { antigravityAuthStatus, claudeAuthStatus, claudeCredentialFile, codexAuthStatus, fileMtimeMs, readText, } from "./auth-status.js";
30
37
  import { TOOL_IDS, checkTooling, formatToolNotes, formatToolingLines, runToolAction, toolNotesRefusal, toolReleaseRepo, } from "./tooling.js";
31
38
  import { releaseNoteBlocks } from "./release-notes.js";
32
39
  import { readHistory, recordHistorySample } from "./history.js";
33
40
  import { launchGui } from "./gui-launch.js";
34
41
  import { GUARD_POLICIES, GUARD_REFUSED_EXIT, OVERRIDE_ENV, guardVerdict, inspectLoad, isGuardPolicy, overrideFromEnv, renderGuardMessage, } from "./single-load.js";
35
42
  import { checkForUpdate, fetchReleases, releasesNewerThan, selfUpdate } from "./updates.js";
36
- import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, } from "./mappings.js";
43
+ import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, setPaused, normalizePath, } from "./mappings.js";
37
44
  import { checkAuth, fetchProfile, fetchUsage, liveSessionPids, } from "./api.js";
38
45
  import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
39
46
  import { formatSnapshot, parseUsage } from "./usage.js";
40
47
  import { readCodexUsage } from "./codex-usage.js";
41
48
  import { redeemResetCredit } from "./codex-reset.js";
42
- import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, detectRunningClaudeProfile, listSessions, listCodexSessions, locateSession, locateCodexSession, markLive, restoreSession, sharedHistory, sweepTrash, transferSession, transferCodexSession, trashedSessionExists, } from "./sessions.js";
49
+ import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, detectRunningClaudeProfile, listSessions, listCodexSessions, locateSession, locateCodexSession, markLive, openQuestion, restoreSession, sharedHistory, sweepTrash, transferSession, transferCodexSession, trashedSessionExists, } from "./sessions.js";
43
50
  import { isFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
44
- import { cmdService, serviceUninstall } from "./service.js";
51
+ import { cmdService, serviceInstall, serviceTarget, serviceUninstall } from "./service.js";
52
+ import { fileURLToPath } from "node:url";
45
53
  import { readContext, turnInFlight } from "./telemetry.js";
46
54
  import { readPreview } from "./session-preview.js";
47
- import { installHooks, uninstallHooks, hooksInstalled, readSettings, appendEvent, eventFile, profileFromConfigDir, } from "./hooks.js";
55
+ import { installHooks, uninstallHooks, hooksInstalled, readSettings, appendEvent, eventFile, profileFromConfigDir, readEvents, waitingSessions, } from "./hooks.js";
48
56
  import { readTelemetryConfig, writeTelemetryConfig } from "./notify.js";
49
57
  import { appendNotification, readNotifications, clearNotifications } from "./notifications.js";
50
58
  import { APPS, buildLaunch, findApp, guiDataDir, isInstalled } from "./apps.js";
59
+ import { scanDesktop } from "./desktop-sessions.js";
51
60
  import { ensureAgyKeychain } from "./agy-keychain.js";
52
61
  import { currentManagedSession, currentTmuxSessionName, newSessionArgs, readTmuxRegistry, recordManagedSession, respawnPaneArgs, sendKeysArgs, tmuxAvailable, tmuxSessionName, } from "./tmux.js";
53
62
  // Per-OS credential store (keychain-then-file on darwin, file-only elsewhere).
54
63
  const credentials = credentialStore();
55
64
  const CLAUDE_HOME = provider("claude").defaultConfigDir(); // ~/.claude
56
65
  const CLAUDE_JSON = path.join(HOME, ".claude.json");
66
+ /** Record one of agent-switch's own actions in the activity log (never a token). */
67
+ function logActivity(kind, target, action, reason, ok = true) {
68
+ appendActivity({ kind, target, action, reason, ok });
69
+ }
57
70
  /** Same-provider profiles that carry no live session — the concrete
58
71
  * alternatives a refusal names, so the user can act on it without first
59
72
  * running `list` themselves. */
@@ -95,9 +108,9 @@ function checkSingleLoad(providerId, name, opts = {}) {
95
108
  };
96
109
  }
97
110
  // ---------- launcher ---------------------------------------------------------
98
- function launch(p, name, args) {
111
+ function launch(p, name, args, extraEnv = {}) {
99
112
  const home = configDir(p.id, name);
100
- const env = { ...process.env, [p.envVar]: home };
113
+ const env = { ...process.env, ...extraEnv, [p.envVar]: home };
101
114
  // Antigravity's agy CLI stores its token in the macOS keychain (fixed key) —
102
115
  // seed a per-profile keychain under this HOME so the token is isolated per
103
116
  // account (and macOS doesn't pop its "no keychain found" dialog). No-op off mac.
@@ -115,20 +128,111 @@ function launch(p, name, args) {
115
128
  return res.status ?? 1;
116
129
  }
117
130
  // ---------- commands ---------------------------------------------------------
118
- function cmdAdd(providerId, name) {
131
+ function cmdAdd(providerId, name, browserId) {
119
132
  if (!name)
120
- die(`usage: agent-switch add [--provider ${providerId}] <profile>`);
133
+ die(`usage: agent-switch add [--provider ${providerId}] <profile> [--browser B]`);
121
134
  if (profileExists(providerId, name))
122
135
  die(`profile "${name}" already exists for ${providerId}`);
123
136
  const p = provider(providerId);
137
+ const env = browserId ? browserEnv(browserId, providerId) : {};
124
138
  ensureRoot();
125
- fs.mkdirSync(configDir(providerId, name), { recursive: true, mode: 0o700 });
139
+ const dir = configDir(providerId, name);
140
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
141
+ // A bare `claude` in a fresh config dir opens Claude Code's setup screens, not
142
+ // the sign-in; skip them the way `import` does and run the sign-in itself.
143
+ if (providerId === "claude")
144
+ seedClaudeOnboarding(dir);
126
145
  console.log(`Created ${providerId} profile "${name}".`);
127
- console.log(`Launching ${p.binary} for first login (log in inside if prompted)...`);
128
- launch(p, name, []);
146
+ const loginArgs = LOGIN_ARGS[providerId];
147
+ console.log(loginArgs.length
148
+ ? `Signing it in with \`${[p.binary, ...loginArgs].join(" ")}\` (finish it in the browser if one opens)...`
149
+ : `Launching ${p.binary} for first login (log in inside if prompted)...`);
150
+ const code = launch(p, name, loginArgs, env);
129
151
  const id = identity(providerId, name);
130
- console.log(id ? `Profile "${name}" is linked to ${id}.` : `Profile "${name}" created.`);
131
- console.log(`Activate it with: agent-switch use ${name}${providerId === "claude" ? "" : ` --provider ${providerId}`}`);
152
+ const after = authStatusFor(providerId, name);
153
+ const ok = !!id || after.state === "connected" || after.state === "expiring";
154
+ logActivity("login", `${providerId}/${name}`, ok ? "signed in (new profile)" : "created, sign-in not confirmed", "user ran `agent-switch add`", ok);
155
+ const flag = providerId === "claude" ? "" : ` --provider ${providerId}`;
156
+ if (!ok && loginArgs.length) {
157
+ console.error(`Profile "${name}" was created but is not signed in yet${code !== 0 ? ` (${p.binary} exited with ${code})` : ""}. Finish it with: agent-switch login ${name}${flag}`);
158
+ process.exit(1);
159
+ }
160
+ console.log(id ? `Profile "${name}" is signed in as ${id}.` : ok ? `Profile "${name}" is signed in.` : `Profile "${name}" created.`);
161
+ console.log(`Activate it with: agent-switch use ${name}${flag}`);
162
+ }
163
+ /** Mark Claude Code's first-run setup as done in a new profile (theme taken
164
+ * from the default install), so its first start goes straight to work. */
165
+ function seedClaudeOnboarding(dir) {
166
+ const file = path.join(dir, ".claude.json");
167
+ const config = (fs.existsSync(file) && readJson(file)) || {};
168
+ const theme = (fs.existsSync(CLAUDE_JSON) && readJson(CLAUDE_JSON)?.theme) || "dark";
169
+ config.hasCompletedOnboarding = true;
170
+ config.theme = config.theme || theme;
171
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
172
+ }
173
+ /**
174
+ * `$BROWSER` for a sign-in in one specific browser. Only Claude Code is verified
175
+ * to follow it; codex on macOS opens the system default regardless, so it is
176
+ * told rather than silently ignored.
177
+ */
178
+ function browserEnv(browserId, providerId) {
179
+ if (process.platform === "win32")
180
+ die("--browser is not supported on Windows yet.");
181
+ const deps = defaultBrowserDeps(HOME);
182
+ const located = locateBrowser(browserId, deps);
183
+ if (!located) {
184
+ const installed = listBrowsers(deps).map((b) => b.id).join(", ") || "(none found)";
185
+ die(`browser "${browserId}" is not installed. Installed: ${installed}`);
186
+ }
187
+ if (providerId !== "claude") {
188
+ console.log(`Note: ${provider(providerId).binary} opens your default browser itself. If that is the wrong one, open the link it prints in ${browserId} — the sign-in works from any browser.`);
189
+ }
190
+ if (process.platform !== "darwin")
191
+ return { BROWSER: located };
192
+ const wrapper = path.join(ROOT, "browsers", browserId);
193
+ fs.mkdirSync(path.dirname(wrapper), { recursive: true, mode: 0o700 });
194
+ fs.writeFileSync(wrapper, browserWrapperScript(located), { mode: 0o700 });
195
+ fs.chmodSync(wrapper, 0o700);
196
+ return { BROWSER: wrapper };
197
+ }
198
+ function cmdBrowsers(json) {
199
+ const list = listBrowsers(defaultBrowserDeps(HOME));
200
+ if (json)
201
+ return void console.log(JSON.stringify(list, null, 2));
202
+ if (list.length === 0)
203
+ return void console.log("No supported browsers found.");
204
+ for (const b of list)
205
+ console.log(`${b.id.padEnd(16)} ${b.name}`);
206
+ }
207
+ /** The provider's own sign-in command for an existing profile. Antigravity's
208
+ * agy has no separate login command; it prompts when it starts signed out. */
209
+ const LOGIN_ARGS = {
210
+ claude: ["auth", "login"],
211
+ codex: ["login"],
212
+ antigravity: [],
213
+ };
214
+ /**
215
+ * Sign an existing profile in again — `add` refuses a name that exists, and a
216
+ * login the provider cleared (signed out, refresh window ended) needs exactly
217
+ * this. Runs the provider's own sign-in under the profile's config dir, then
218
+ * reads the stored login back: the identity in the config outlives a sign-out,
219
+ * so only the credential proves the sign-in happened.
220
+ */
221
+ function cmdLogin(providerId, name, browserId) {
222
+ const n = requireProfile(providerId, name, "login");
223
+ const p = provider(providerId);
224
+ const env = browserId ? browserEnv(browserId, providerId) : {};
225
+ console.log(`Signing ${providerId}/${n} in with \`${[p.binary, ...LOGIN_ARGS[providerId]].join(" ")}\` (finish it in the browser if one opens)...`);
226
+ const code = launch(p, n, LOGIN_ARGS[providerId], env);
227
+ const after = authStatusFor(providerId, n);
228
+ const ok = after.state === "connected" || after.state === "expiring";
229
+ logActivity("login", `${providerId}/${n}`, ok ? "signed in again" : "sign-in not confirmed", "user ran `agent-switch login`", ok);
230
+ if (!ok) {
231
+ console.error(`${providerId}/${n} is still not signed in (${after.detail})${code !== 0 ? ` — ${p.binary} exited with ${code}` : ""}.`);
232
+ process.exit(1);
233
+ }
234
+ const id = identity(providerId, n);
235
+ console.log(`${providerId}/${n} is signed in${id ? ` as ${id}` : ""}.`);
132
236
  }
133
237
  /**
134
238
  * Login-free import of the default Claude install into a profile. Takes Claude
@@ -203,7 +307,10 @@ async function cmdImport(providerId, name) {
203
307
  }
204
308
  function cmdUse(providerId, name) {
205
309
  const n = requireProfile(providerId, name, "use");
310
+ if (isDisabled(providerId, n))
311
+ die(`${n} is disabled — turn it back on first: agent-switch enable ${n}${providerId === "claude" ? "" : ` --provider ${providerId}`}`);
206
312
  setActive(providerId, n);
313
+ logActivity("switch", `${providerId}/${n}`, "set as active profile", "user ran `agent-switch use`");
207
314
  const id = identity(providerId, n);
208
315
  console.log(`Active ${providerId} profile: ${n}${id ? ` (${id})` : ""}`);
209
316
  console.log(`New \`${provider(providerId).binary}\` sessions use this profile. Running sessions are unaffected.`);
@@ -223,11 +330,17 @@ function cmdDeactivate(providerId) {
223
330
  return;
224
331
  }
225
332
  setActive(providerId, null);
333
+ logActivity("switch", `${providerId}/${active}`, "deactivated", "user ran `agent-switch deactivate`");
226
334
  console.log(`Deactivated ${providerId} profile "${active}". No ${providerId} profile is active.`);
227
335
  console.log(`New \`${provider(providerId).binary}\` sessions fall back to the default install until you \`use\` one.`);
228
336
  }
229
- function cmdRun(providerId, name, args) {
337
+ function cmdRun(providerId, name, args, cwd) {
230
338
  const n = requireProfile(providerId, name, "run");
339
+ if (cwd !== undefined) {
340
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory())
341
+ die(`no such folder: ${cwd}`);
342
+ process.chdir(cwd);
343
+ }
231
344
  const p = provider(providerId);
232
345
  // `--tmux` and `--force` are agent-switch flags, not passthrough flags — strip
233
346
  // them before the rest reaches the provider binary.
@@ -287,6 +400,8 @@ function cmdList(providerId, json = false) {
287
400
  name: n,
288
401
  identity: identity(pid, n),
289
402
  label: labelFor(pid, n),
403
+ color: colorOf(pid, n),
404
+ disabled: isDisabled(pid, n),
290
405
  active: activeFor(pid) === n,
291
406
  liveSessions: pid === "claude" ? liveSessionPids(configDir("claude", n)).length : 0,
292
407
  })));
@@ -403,7 +518,10 @@ async function cmdStatus(providerId, name, json = false) {
403
518
  }
404
519
  const token = freshAccessToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")));
405
520
  if (!token) {
406
- console.log(" (credential not readable — profile may not have run yet)");
521
+ const auth = authStatusFor("claude", n);
522
+ console.log(auth.state === "sign-in-needed"
523
+ ? ` (signed out — sign in again: agent-switch login ${n})`
524
+ : " (credential not readable — profile may not have run yet)");
407
525
  continue;
408
526
  }
409
527
  const [profileInfo, raw] = await Promise.all([fetchProfile(token), fetchUsage(token)]);
@@ -425,6 +543,54 @@ async function cmdStatus(providerId, name, json = false) {
425
543
  }
426
544
  }
427
545
  }
546
+ /** Cached usage for one profile: the daemon's snapshot or the newest history
547
+ * sample, whichever is newer. Local reads only — `dir` runs on every start. */
548
+ function cachedUsage(providerId, name) {
549
+ const daemonSnap = readDaemonState()?.profiles?.[`${providerId}/${name}`] ?? null;
550
+ const daemonMs = daemonSnap ? Date.parse(daemonSnap.capturedAt) : NaN;
551
+ const hist = readHistory(path.join(profileDir(providerId, name), "usage-history.json"));
552
+ const last = hist.length ? hist[hist.length - 1] : null;
553
+ const histMs = last ? Date.parse(last.at) : NaN;
554
+ if (last && !isNaN(histMs) && (isNaN(daemonMs) || histMs > daemonMs)) {
555
+ // History samples keep no reset time; the daemon's last one still holds
556
+ // while it lies ahead (a window's reset only moves once it has passed).
557
+ const resetOf = (key) => {
558
+ const r = daemonSnap?.windows.find((d) => d.key === key)?.resetsAt ?? null;
559
+ return r && Date.parse(r) > histMs ? r : null;
560
+ };
561
+ const windows = last.windows.map((w) => ({ key: w.key, label: w.key, utilization: w.utilization, resetsAt: resetOf(w.key) }));
562
+ return { snapshot: { windows, routines: null, capturedAt: last.at }, capturedMs: histMs };
563
+ }
564
+ return { snapshot: daemonSnap, capturedMs: isNaN(daemonMs) ? null : daemonMs };
565
+ }
566
+ function tildePath(p) {
567
+ return p === HOME || p.startsWith(HOME + path.sep) ? "~" + p.slice(HOME.length) : p;
568
+ }
569
+ /** Profile names are validated by `profileDir`, which exits on a bad name —
570
+ * a pool ref or a stale mapping must never kill the shell wrapper's hot path. */
571
+ function profileExistsSafe(providerId, name) {
572
+ return /^[A-Za-z0-9_-]+$/.test(name) && profileExists(providerId, name);
573
+ }
574
+ function resolveDir(providerId, plain = false) {
575
+ return resolveForDir({
576
+ providerId,
577
+ mapping: plain ? null : resolveMapping(process.cwd(), providerId),
578
+ names: listProfiles(providerId),
579
+ threshold: (n) => readProtection(providerId, n).thresholdPct,
580
+ active: activeFor(providerId),
581
+ exists: (n) => profileExistsSafe(providerId, n),
582
+ signedIn: (n) => identity(providerId, n) !== null,
583
+ disabled: (n) => isDisabled(providerId, n),
584
+ pool: (id) => findPool(id),
585
+ usage: (n) => cachedUsage(providerId, n),
586
+ // Only providers that write a live-session signal can report one; the
587
+ // check runs for pool members only.
588
+ live: (n) => provider(providerId).hasLiveSessionSignal && liveSessionPids(configDir(providerId, n)).length > 0,
589
+ guard: readGuard(),
590
+ now: Date.now(),
591
+ displayPath: tildePath,
592
+ });
593
+ }
428
594
  /**
429
595
  * Resolve which profile a shell wrapper should load: directory mapping (nearest
430
596
  * ancestor of CWD) > active-for-provider. Empty output → the wrapper falls back
@@ -436,14 +602,13 @@ async function cmdStatus(providerId, name, json = false) {
436
602
  * and talk to the human; a distinct exit code is what lets the wrapper block on
437
603
  * a refusal without ever blocking on a missing or older `agent-switch`.
438
604
  */
439
- function cmdDir(providerId, guarded = false) {
440
- const mapped = resolveMapping(process.cwd(), providerId);
441
- const name = mapped && profileExists(providerId, mapped.name)
442
- ? mapped.name
443
- : (() => {
444
- const active = activeFor(providerId);
445
- return active && profileExists(providerId, active) ? active : null;
446
- })();
605
+ function cmdDir(providerId, guarded = false, json = false, plain = false) {
606
+ const r = resolveDir(providerId, plain);
607
+ if (json) {
608
+ console.log(JSON.stringify({ profile: r.name ? `${providerId}/${r.name}` : null, configDir: r.name ? configDir(providerId, r.name) : null, why: r.why }, null, 2));
609
+ return;
610
+ }
611
+ const name = r.name;
447
612
  if (!name)
448
613
  return;
449
614
  console.log(configDir(providerId, name));
@@ -457,8 +622,28 @@ function cmdDir(providerId, guarded = false) {
457
622
  if (guard.verdict === "refuse")
458
623
  process.exit(GUARD_REFUSED_EXIT);
459
624
  }
460
- function cmdMap(providerId, name, dir) {
461
- const n = requireProfile(providerId, name, `map [--provider ${providerId}] <profile> [dir]`);
625
+ function cmdMap(providerId, name, dir, flags = {}) {
626
+ if (flags.pause || flags.resume) {
627
+ // `map --pause [dir]`: the positional is the folder, there is no profile.
628
+ const target = name ?? process.cwd();
629
+ const on = !!flags.pause;
630
+ if (!setPaused(target, providerId, on))
631
+ die(`no mapping for ${target} (${providerId})`);
632
+ console.log(on
633
+ ? `Paused the ${providerId} rule for ${normalizePath(target)} — it stays listed but no longer picks the account. Resume: agent-switch map --resume ${target}`
634
+ : `Resumed the ${providerId} rule for ${normalizePath(target)}.`);
635
+ return;
636
+ }
637
+ if (name && isPoolRef(name)) {
638
+ const pool = findPool(poolIdOf(name));
639
+ if (!pool)
640
+ die(`no pool "${poolIdOf(name)}" — create it with: agent-switch pool create <name>`);
641
+ const key = setMapping(dir ?? process.cwd(), providerId, `${POOL_PREFIX}${pool.id}`);
642
+ console.log(`Mapped ${key} -> pool ${pool.id} (${providerId})`);
643
+ console.log(`\`${provider(providerId).binary}\` here starts on the ${providerId} member of "${pool.name}" with the most fresh headroom. See why: agent-switch dir --json`);
644
+ return;
645
+ }
646
+ const n = requireProfile(providerId, name, `map [--provider ${providerId}] <profile|pool:<id>> [dir]`);
462
647
  const key = setMapping(dir ?? process.cwd(), providerId, n);
463
648
  console.log(`Mapped ${key} -> ${providerId}/${n}`);
464
649
  console.log(`\`${provider(providerId).binary}\` in this directory (and subdirectories) now uses this profile.`);
@@ -470,14 +655,20 @@ function cmdUnmap(providerId, dir) {
470
655
  else
471
656
  die(`no mapping for ${target}${providerId ? ` (${providerId})` : ""}`);
472
657
  }
473
- function cmdMappings() {
658
+ function cmdMappings(json = false) {
474
659
  const rows = mappingRows();
660
+ if (json) {
661
+ console.log(JSON.stringify(rows.map((r) => ({ path: r.path, provider: r.provider, target: r.name, paused: r.paused }))));
662
+ return;
663
+ }
475
664
  if (rows.length === 0) {
476
665
  console.log("No directory mappings. Create one with: agent-switch map <profile> [dir]");
477
666
  return;
478
667
  }
479
- for (const r of rows)
480
- console.log(`${r.path} -> ${r.provider}/${r.name}`);
668
+ for (const r of rows) {
669
+ const line = isPoolRef(r.name) ? `${r.path} -> ${r.name} (${r.provider})` : `${r.path} -> ${r.provider}/${r.name}`;
670
+ console.log(r.paused ? `${line} [paused]` : line);
671
+ }
481
672
  }
482
673
  // share + web are Claude-config concepts and stay Claude-scoped.
483
674
  function cmdShare(mode, flags = []) {
@@ -503,7 +694,9 @@ function cmdShare(mode, flags = []) {
503
694
  const actions = applySharing(source, configDir("claude", p), withHistory);
504
695
  console.log(` ${p}: ${actions.length > 0 ? actions.join(", ") : "up to date"}`);
505
696
  }
506
- console.log("Shared directories (skills/, commands/, agents/) write through the link. Shared files\n" +
697
+ console.log("Prompt history (history.jsonl — what ↑ and Ctrl+R search) is shared too; a profile's own\n" +
698
+ "file was merged in and kept as history.jsonl.pre-share.\n" +
699
+ "Shared directories (skills/, commands/, agents/) write through the link. Shared files\n" +
507
700
  "(settings.json, ...) fork on an in-profile /config edit — run `agent-switch share sync`\n" +
508
701
  "to push a fork back to the source and re-link.");
509
702
  }
@@ -529,7 +722,11 @@ function cmdShare(mode, flags = []) {
529
722
  // flag: sharing is active when any profile carries agent-switch-managed links.
530
723
  const rows = profiles
531
724
  .filter((p) => configDir("claude", p) !== source)
532
- .map((p) => ({ name: p, shared: sharedLinkHealth(configDir("claude", p)).length > 0 }));
725
+ .map((p) => {
726
+ const links = sharedLinkHealth(configDir("claude", p));
727
+ const prompts = links.some((l) => l.name === PROMPT_HISTORY && l.state === "linked");
728
+ return { name: p, shared: links.length > 0, prompts };
729
+ });
533
730
  const active = rows.some((r) => r.shared);
534
731
  if (flags.includes("--json")) {
535
732
  console.log(JSON.stringify({ active, source: sourceName ?? "default", profiles: rows }));
@@ -537,7 +734,7 @@ function cmdShare(mode, flags = []) {
537
734
  }
538
735
  console.log(`share: ${active ? "on" : "off"} (source ${sourceName ?? "default"})`);
539
736
  for (const r of rows)
540
- console.log(` ${r.name}: ${r.shared ? "shared" : "own"}`);
737
+ console.log(` ${r.name}: ${r.shared ? "shared" : "own"}${r.shared && !r.prompts ? " (prompt history not linked — run share on)" : ""}`);
541
738
  }
542
739
  else {
543
740
  die("usage: agent-switch share on|sync|off|status [--history] [--source <profile|default>]");
@@ -822,6 +1019,40 @@ function cmdHookEvent() {
822
1019
  * GUI contract: a flat array, metadata only — transcript content never leaves
823
1020
  * the profile dir (only the guarded first-line header is read).
824
1021
  */
1022
+ /** The regular Desktop app's data dir, then every profile's own instance. */
1023
+ function desktopInstances() {
1024
+ const app = findApp("claude-desktop");
1025
+ const regular = process.platform === "darwin"
1026
+ ? path.join(HOME, "Library", "Application Support", "Claude")
1027
+ : process.platform === "win32"
1028
+ ? path.join(process.env.APPDATA ?? path.join(HOME, "AppData", "Roaming"), "Claude")
1029
+ : path.join(HOME, ".config", "Claude");
1030
+ return [{ profile: null, dir: regular }, ...(app ? listProfiles("claude").map((p) => ({ profile: p, dir: guiDataDir(app, p) })) : [])];
1031
+ }
1032
+ function desktopScan() {
1033
+ const byAccount = new Map();
1034
+ for (const p of listProfiles("claude")) {
1035
+ const uuid = readJson(path.join(configDir("claude", p), ".claude.json"))?.oauthAccount?.accountUuid;
1036
+ if (typeof uuid === "string")
1037
+ byAccount.set(uuid, p);
1038
+ }
1039
+ return scanDesktop({ instances: desktopInstances(), claudeHome: CLAUDE_HOME, profileOf: (u) => byAccount.get(u) ?? null, now: Date.now() });
1040
+ }
1041
+ /** Which accounts ran sessions in each Claude Desktop instance; `mismatch` =
1042
+ * a profile's own instance signed in with another account. */
1043
+ function cmdDesktop(json) {
1044
+ const { instances } = desktopScan();
1045
+ const shown = instances.filter((i) => i.accounts.length > 0);
1046
+ if (json)
1047
+ return void console.log(JSON.stringify(shown, null, 2));
1048
+ if (!shown.length)
1049
+ return void console.log("No Claude Desktop sessions found.");
1050
+ for (const i of shown) {
1051
+ console.log(`${i.profile ? `${i.profile}'s instance` : "Claude Desktop"} (${tildePath(i.dir)})`);
1052
+ for (const a of i.accounts)
1053
+ console.log(` ${a.profile ?? "an account without a profile"} (${a.accountUuid.slice(0, 8)})${i.profile && a.profile !== i.profile ? " <- not this profile's account" : ""}`);
1054
+ }
1055
+ }
825
1056
  function cmdSessions(providerId, providerExplicit, name, flags = {}) {
826
1057
  if (providerExplicit && providerId !== "claude" && providerId !== "codex") {
827
1058
  die(`sessions supports claude and codex for now (${providerId} parity is gated on its Phase-0 spike — see scripts/spikes/)`);
@@ -834,6 +1065,8 @@ function cmdSessions(providerId, providerExplicit, name, flags = {}) {
834
1065
  if (profiles.length === 0)
835
1066
  die(`no ${providerId} profiles`);
836
1067
  const all = [];
1068
+ // Claude Desktop's own sessions, filed by account (see desktop-sessions.ts).
1069
+ const desktop = providerId === "claude" ? desktopScan().rows : [];
837
1070
  for (const p of profiles) {
838
1071
  const cfg = configDir(providerId, p);
839
1072
  // Codex stores date-partitioned rollout files (no encoded-cwd dir); its live
@@ -842,7 +1075,24 @@ function cmdSessions(providerId, providerExplicit, name, flags = {}) {
842
1075
  const rows = providerId === "codex" ? listCodexSessions(cfg, limit) : listSessions(cfg, limit);
843
1076
  if (providerId === "claude")
844
1077
  markLive(cfg, rows); // POSIX: pid→cwd→dir match; win32: recent-only
845
- all.push(...rows.map((r) => ({ ...r, profile: p })));
1078
+ if (providerId === "claude") {
1079
+ const mtime = new Map(rows.map((r) => [r.sessionId, r.mtimeMs]));
1080
+ const waiting = waitingSessions(readEvents(eventFile(ROOT, providerId, p)), (id) => mtime.get(id) ?? null);
1081
+ // Hooks report a prompt; an open question also shows in the transcript itself.
1082
+ for (const r of rows) {
1083
+ const why = (r.file ? openQuestion(r.file) : null) ?? waiting.get(r.sessionId) ?? null;
1084
+ if (why)
1085
+ Object.assign(r, { waiting: true, waitingFor: why });
1086
+ }
1087
+ }
1088
+ const own = new Set(rows.map((r) => r.sessionId));
1089
+ for (const d of desktop)
1090
+ if (d.profile === p && !own.has(d.sessionId)) {
1091
+ const q = d.file ? openQuestion(d.file) : null;
1092
+ rows.push(q ? { ...d, waiting: true, waitingFor: q } : d);
1093
+ }
1094
+ rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
1095
+ all.push(...rows.slice(0, limit).map((r) => ({ ...r, profile: p })));
846
1096
  }
847
1097
  if (flags.json) {
848
1098
  console.log(JSON.stringify(all.map((r) => {
@@ -1319,7 +1569,25 @@ function takeoverCodex(sessionId, target, opts) {
1319
1569
  if (!printOnly && process.stdin.isTTY)
1320
1570
  process.exit(launch(provider("codex"), target, resumeArgs));
1321
1571
  }
1322
- function cmdRemove(providerId, name, force = false) {
1572
+ /**
1573
+ * Ask before a destructive or scarce-credit action. `--yes` skips the question.
1574
+ * Off a terminal there is nobody to ask, so the action proceeds as it always
1575
+ * did (scripts and the GUI, which confirms in its own UI, keep working) and a
1576
+ * note on stderr says how to make that explicit.
1577
+ */
1578
+ async function confirmAction(question, yes) {
1579
+ if (yes)
1580
+ return true;
1581
+ if (!process.stdin.isTTY) {
1582
+ console.error("agent-switch: not a terminal — proceeding without confirmation (pass --yes to make this explicit).");
1583
+ return true;
1584
+ }
1585
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1586
+ const answer = await new Promise((resolve) => rl.question(`${question} [y/N] `, resolve));
1587
+ rl.close();
1588
+ return /^y(es)?$/i.test(answer.trim());
1589
+ }
1590
+ async function cmdRemove(providerId, name, force = false, yes = false) {
1323
1591
  const n = requireProfile(providerId, name, "remove");
1324
1592
  if (activeFor(providerId) === n && !force) {
1325
1593
  die(`"${n}" is the active ${providerId} profile. Switch away first or use --force.`);
@@ -1330,15 +1598,53 @@ function cmdRemove(providerId, name, force = false) {
1330
1598
  die(`profile "${n}" has live Claude Code sessions (PIDs ${pids.join(", ")}). Close them or use --force.`);
1331
1599
  }
1332
1600
  }
1601
+ // An active rebind keeps both original credentials in two Keychain stash
1602
+ // entries. Deleting the profile would orphan them — restore first.
1603
+ const binding = providerId === "claude" ? readBinding(configDir("claude", n)) : null;
1604
+ if (binding && !force) {
1605
+ die(`profile "${n}" is rebound to "${binding.boundToProfile}"'s account. Restore it first: ` +
1606
+ `agent-switch rebind --restore --profile ${n} — or use --force to drop the rebind (its stashed credentials are deleted).`);
1607
+ }
1608
+ // The reverse case: another profile is serving THIS profile's account. Its
1609
+ // restore would write into the store of a profile that no longer exists.
1610
+ const borrower = providerId === "claude" ? borrowerOf(n) : null;
1611
+ if (borrower && !force) {
1612
+ die(`profile "${n}"'s account is currently borrowed by "${borrower}" (rebind). Restore it first: ` +
1613
+ `agent-switch rebind --restore --profile ${borrower} — or use --force to restore "${borrower}" and then remove "${n}".`);
1614
+ }
1615
+ const who = identity(providerId, n);
1616
+ if (!(await confirmAction(`Remove ${providerId} profile "${n}"${who ? ` (${who})` : ""} and its login? This cannot be undone.`, yes))) {
1617
+ console.log("Cancelled — nothing removed.");
1618
+ return;
1619
+ }
1333
1620
  // Keychain entries exist only for claude (darwin); codex/antigravity are file-based,
1334
1621
  // so computing a Claude hash for them would be a conceptual no-op — skip it.
1622
+ let restoredBorrower = null;
1623
+ if (borrower) {
1624
+ try {
1625
+ restoredBorrower = await releaseBorrowedAccount(n);
1626
+ }
1627
+ catch (e) {
1628
+ die(`could not restore "${borrower}" first, so "${n}" was not removed: ${e.message}`);
1629
+ }
1630
+ if (restoredBorrower)
1631
+ logActivity("rebind", `claude/${restoredBorrower}`, `restored own account (was on ${n})`, "user ran `agent-switch remove --force`");
1632
+ }
1633
+ const droppedRebind = binding ? discardBinding(n) : false;
1335
1634
  const removedEntry = providerId === "claude" ? credentials.removeEntry(configDir(providerId, n)) : false;
1336
1635
  fs.rmSync(profileDir(providerId, n), { recursive: true, force: true });
1337
1636
  if (activeFor(providerId) === n)
1338
1637
  setActive(providerId, null);
1339
1638
  clearLabel(providerId, n); // drop its label so it can't linger as an orphan
1340
1639
  const pruned = pruneMappings(providerId, n);
1640
+ forgetProtection(providerId, n);
1641
+ renamePoolMember(`${providerId}/${n}`, null);
1642
+ logActivity("remove", `${providerId}/${n}`, "profile removed", yes ? "user ran `agent-switch remove --yes`" : "user ran `agent-switch remove`");
1341
1643
  console.log(`Removed ${providerId} profile "${n}".`);
1644
+ if (restoredBorrower)
1645
+ console.log(`Restored "${restoredBorrower}" to its own account first (it was borrowing "${n}").`);
1646
+ if (droppedRebind)
1647
+ console.log(`Dropped its active rebind (stashed credentials deleted; "${binding.boundToProfile}" keeps its own login).`);
1342
1648
  if (removedEntry)
1343
1649
  console.log("Removed its keychain credential entry.");
1344
1650
  if (pruned.length > 0)
@@ -1363,6 +1669,55 @@ function cmdLabel(providerId, name, label) {
1363
1669
  setLabel(providerId, name, label);
1364
1670
  console.log(`Labeled ${providerId}/${name} as ${label}.`);
1365
1671
  }
1672
+ /** Disable keeps a profile (login, history, settings) but takes it out of every
1673
+ * pick: no active slot, no pool or folder rule, no background polling. */
1674
+ function cmdDisable(providerId, name, disabled) {
1675
+ const n = requireProfile(providerId, name, disabled ? "disable" : "enable");
1676
+ const wasActive = activeFor(providerId) === n;
1677
+ setDisabled(providerId, n, disabled);
1678
+ if (!disabled)
1679
+ return void console.log(`Turned ${providerId}/${n} back on.`);
1680
+ console.log(`Disabled ${providerId}/${n}. Nothing was deleted; \`agent-switch enable ${n}\` turns it back on.`);
1681
+ if (wasActive)
1682
+ console.log(`It was the active ${providerId} profile — new sessions now use the default login until you pick another.`);
1683
+ }
1684
+ /**
1685
+ * Make this CLI the one terminals and the background service use. Each app runs
1686
+ * it on start, so a dev build and an installed app each bring their own CLI:
1687
+ * the shell wrapper reads <home>/active-cli, and a service installed from the
1688
+ * other CLI is re-installed onto this one. An uninstalled service stays so.
1689
+ */
1690
+ function cmdActivate(json) {
1691
+ const cli = fs.realpathSync(fileURLToPath(import.meta.url));
1692
+ ensureRoot();
1693
+ fs.writeFileSync(path.join(ROOT, "active-cli"), `${cli}\n`, { mode: 0o600 });
1694
+ const before = serviceTarget();
1695
+ if (before === "other-cli")
1696
+ serviceInstall(() => { });
1697
+ const service = before === "other-cli" ? "repointed" : before === "this-cli" ? "unchanged" : "not-installed";
1698
+ if (json)
1699
+ return void console.log(JSON.stringify({ cli, service }));
1700
+ console.log(`Terminals now use ${cli}.`);
1701
+ if (service === "repointed")
1702
+ console.log("The background service was moved onto it.");
1703
+ if (service === "not-installed")
1704
+ console.log("The background service isn't installed (agent-switch service install).");
1705
+ }
1706
+ function cmdColor(providerId, name, color) {
1707
+ // Like a label, independent state keyed by provider/name: the GUI picks the
1708
+ // colour before `add` has created the profile.
1709
+ if (!name)
1710
+ die(`usage: agent-switch color <profile> [${PROFILE_COLORS.join("|")}|none] [--provider P]`);
1711
+ if (color === undefined || color === "none" || color === "clear") {
1712
+ setColor(providerId, name, null);
1713
+ console.log(`Cleared the colour of ${providerId}/${name}.`);
1714
+ return;
1715
+ }
1716
+ if (!isProfileColor(color))
1717
+ die(`invalid colour "${color}" (choose: ${PROFILE_COLORS.join(", ")}, or "none" to clear)`);
1718
+ setColor(providerId, name, color);
1719
+ console.log(`Coloured ${providerId}/${name} ${color}.`);
1720
+ }
1366
1721
  /** Enable/disable opt-in near-limit notifications (notify + suggest, never an
1367
1722
  * automatic switch), or show the current setting. */
1368
1723
  /** Rename a profile (move its config dir + carry active/label/mappings). */
@@ -1392,15 +1747,22 @@ function cmdRename(providerId, from, to) {
1392
1747
  if (m.provider === providerId && m.name === n)
1393
1748
  setMapping(m.path, providerId, to);
1394
1749
  }
1750
+ renameProtection(providerId, n, to);
1751
+ renamePoolMember(`${providerId}/${n}`, `${providerId}/${to}`);
1395
1752
  console.log(`Renamed ${providerId} profile "${n}" → "${to}".`);
1396
1753
  }
1397
1754
  /** Manually redeem one banked rate-limit reset for a Codex profile. Consumes a
1398
1755
  * real, scarce credit — the GUI gates this behind a confirmation. */
1399
- async function cmdReset(providerId, name) {
1756
+ async function cmdReset(providerId, name, yes = false) {
1400
1757
  if (providerId !== "codex")
1401
1758
  die("reset is only available for codex (banked rate-limit resets).");
1402
1759
  const n = requireProfile("codex", name, "reset");
1760
+ if (!(await confirmAction(`Redeem one banked rate-limit reset for codex/${n}? Credits are scarce and cannot be returned.`, yes))) {
1761
+ console.log("Cancelled — nothing redeemed.");
1762
+ return;
1763
+ }
1403
1764
  const r = await redeemResetCredit(configDir("codex", n));
1765
+ logActivity("redeem", `codex/${n}`, r.ok ? "redeemed one banked reset" : `redeem failed: ${r.reason ?? "unknown"}`, "user ran `agent-switch reset`", r.ok);
1404
1766
  if (r.ok) {
1405
1767
  console.log(`Redeemed a reset for codex/${n} (windows_reset=${r.windowsReset ?? "?"}).`);
1406
1768
  }
@@ -1441,7 +1803,7 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}) {
1441
1803
  if (cfg.enabled) {
1442
1804
  console.log(`Once the active ${providerId} profile hits the threshold, the daemon NOTIFIES you and SUGGESTS the\n` +
1443
1805
  "account with the most headroom — it never switches automatically; switching stays a manual action.\n" +
1444
- "On Codex it redeems a banked reset first (and stays on the account) before suggesting a switch.\n" +
1806
+ "On Codex it also tells you when a banked reset is available — redeeming one stays your call (`agent-switch reset`).\n" +
1445
1807
  "Run `agent-switch service start` so the daemon is watching.");
1446
1808
  }
1447
1809
  return;
@@ -1630,7 +1992,9 @@ function cmdNotify(flags) {
1630
1992
  }
1631
1993
  if (!title && !message)
1632
1994
  die("agent-switch notify needs at least --title or --message");
1633
- const created = appendNotification({ kind: kind, title, message });
1995
+ // --repeat: each occurrence is its own moment (a session waiting again), so
1996
+ // an identical notice is not collapsed into the last one.
1997
+ const created = appendNotification({ kind: kind, title, message }, undefined, undefined, !flags.repeat);
1634
1998
  if (flags.json)
1635
1999
  console.log(JSON.stringify(created));
1636
2000
  }
@@ -1717,7 +2081,9 @@ async function cmdUpdate() {
1717
2081
  console.log(`Updating ${c.current} → ${c.release.tag} via npm…`);
1718
2082
  else
1719
2083
  console.log(`Update check failed (${c.message}); attempting \`npm install\` anyway…`);
1720
- process.exit(selfUpdate());
2084
+ const code = selfUpdate();
2085
+ logActivity("update", null, code === 0 ? "self-update installed" : `self-update failed (exit ${code})`, "user ran `agent-switch update`", code === 0);
2086
+ process.exit(code);
1721
2087
  }
1722
2088
  function cmdOpen(appId, name) {
1723
2089
  if (!appId)
@@ -1758,6 +2124,7 @@ function cmdGuard(value, json = false) {
1758
2124
  die(`unknown guard policy "${value}" (choose: ${GUARD_POLICIES.join(", ")})`);
1759
2125
  }
1760
2126
  setGuard(value);
2127
+ logActivity("guard", null, `set ${value}`, "user ran `agent-switch guard`");
1761
2128
  }
1762
2129
  const policy = readGuard();
1763
2130
  // Every profile of every provider that can report a live session.
@@ -1804,8 +2171,17 @@ function cmdGuard(value, json = false) {
1804
2171
  console.log(`Override once: ${OVERRIDE_ENV}=1 <binary> · Change: agent-switch guard <${GUARD_POLICIES.join("|")}>`);
1805
2172
  }
1806
2173
  }
1807
- function cmdShellenv(shellArg) {
1808
- console.log(shellenvScript(detectShell(shellArg)));
2174
+ function cmdShellenv(shellArg, sub, json = false) {
2175
+ if (sub !== "install")
2176
+ return void console.log(shellenvScript(detectShell(shellArg)));
2177
+ const shell = detectShell(shellArg);
2178
+ const present = shellIntegrationCheck(HOME).status === "ok";
2179
+ const r = installShellIntegration(shell, HOME, present);
2180
+ if (json)
2181
+ return void console.log(JSON.stringify(r));
2182
+ console.log(r.alreadyInstalled
2183
+ ? "The shell integration is already installed."
2184
+ : `Added the shell integration to ${r.file}. Open a new terminal (or run: source ${r.file}) and \`${shell === "powershell" ? "claude" : "type claude"}\` uses agent-switch.`);
1809
2185
  }
1810
2186
  /** `agent-switch tooling` — ecosystem tool readout (agent-config, rtk, provider
1811
2187
  * CLIs). `--json` is the GUI IPC contract: the GUI renders this readout and
@@ -1922,7 +2298,9 @@ function usage() {
1922
2298
  Provider defaults to claude; pass --provider codex|antigravity for the others.
1923
2299
  Run with no command to launch the tray/menubar GUI (single-instance).
1924
2300
 
1925
- agent-switch add [--provider P] <name> create a profile and log it in
2301
+ agent-switch add [--provider P] <name> [--browser B] create a profile and sign it in
2302
+ agent-switch login [--provider P] <name> [--browser <id>] sign an existing profile in again (after a sign-out / expired login); --browser opens the Claude sign-in in that browser
2303
+ agent-switch browsers [--json] browsers --browser can open (macOS / Linux)
1926
2304
  agent-switch import [--provider P] <name> migrate the default install (no re-login)
1927
2305
  agent-switch use [--provider P] <name> set the active profile for a provider
1928
2306
  agent-switch deactivate [--provider P] clear the active profile for a provider
@@ -1931,9 +2309,10 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1931
2309
  agent-switch status [--provider P] [name] [--json] identity (+ Claude usage); --json = active only
1932
2310
  agent-switch current [--provider P] show the active profile(s)
1933
2311
  agent-switch whoami [--provider P] [name] show a profile's account identity
1934
- agent-switch dir [--provider P] [--guard] resolve profile for CWD (mapping > active)
1935
- agent-switch guard [block|warn|off] one profile, one live session (default block)
1936
- agent-switch map [--provider P] <name> [dir] map a directory to a profile
2312
+ agent-switch dir [--provider P] [--guard] [--json] [--plain] resolve profile for CWD (rule > balanced pick > active); --json adds why
2313
+ agent-switch guard [block|warn|off] one profile, one live session (default warn on new installs)
2314
+ agent-switch map [--provider P] <name|pool:<id>> [dir] map a directory to a profile or a pool
2315
+ agent-switch map --pause|--resume [--provider P] [dir] pause a folder rule (kept, but skipped) or turn it back on
1937
2316
  agent-switch unmap [--provider P] [dir] remove a directory mapping
1938
2317
  agent-switch mappings list directory mappings
1939
2318
  agent-switch share on|sync|off|status [--history] [--source <profile|default>] [--json] (Claude)
@@ -1945,10 +2324,18 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1945
2324
  agent-switch takeover <id> --to <profile> [--from <profile>] [--keep-source] [--in-place] [--print-only] [--force] move a session to another profile and resume it
1946
2325
  agent-switch rebind [<account>] [--profile <p>] | rebind --restore [--profile <p>] | rebind --reset switch a RUNNING profile's account (macOS; no <account> = interactive picker; --restore reverses; --reset re-enables after a circuit-breaker trip)
1947
2326
  agent-switch web <name> claude.ai in a persistent browser (Claude)
1948
- agent-switch remove [--provider P] <name> [--force] delete a profile
2327
+ agent-switch remove [--provider P] <name> [--force] [--yes] delete a profile (asks on a terminal; --yes skips)
1949
2328
  agent-switch label [--provider P] <name> [Work|Personal|Other|none] tag a profile
2329
+ agent-switch desktop [--json] which accounts ran sessions in each Claude Desktop instance
2330
+ agent-switch activate [--json] make this CLI the one terminals and the background service use
2331
+ agent-switch disable|enable [--provider P] <name> keep a profile but stop using it / turn it back on
2332
+ agent-switch color [--provider P] <name> [orange|red|yellow|green|teal|blue|purple|pink|none] pick a profile's colour
1950
2333
  agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] notify + suggest near a usage limit; never switches automatically (default OFF)
1951
- agent-switch reset <profile> --provider codex redeem one banked Codex rate-limit reset
2334
+ agent-switch reset <profile> --provider codex [--yes] redeem one banked Codex rate-limit reset (asks on a terminal)
2335
+ agent-switch protection [--json] | protection set <provider/name> [--on|--off] [--threshold N] per-profile limit warning (default on, 98%)
2336
+ agent-switch pool [list|create|rename|delete|add|remove|suggest] [--json] optional groups a folder rule can point at
2337
+ agent-switch auth-status [--provider P] [--json] per-profile login state from the provider's store (no tokens)
2338
+ agent-switch activity [--limit N] [--json] what agent-switch itself did, and why
1952
2339
  agent-switch rename <old> <new> [--provider P] rename a profile (name & keep its tag)
1953
2340
  agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] enable/disable a provider (default: Claude + Codex)
1954
2341
  agent-switch [gui] launch the tray/menubar GUI (the default with no command; won't start a second if one is running)
@@ -1957,6 +2344,7 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1957
2344
  agent-switch apps list launchable GUI apps (macOS)
1958
2345
  agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
1959
2346
  agent-switch shellenv [--shell zsh|bash|fish|powershell] shell integration
2347
+ agent-switch shellenv install [--shell S] [--json] add it to your shell's startup file
1960
2348
  agent-switch service run|start|stop|status|install|uninstall background usage daemon
1961
2349
  agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
1962
2350
  agent-switch notify --kind K --title T --message M [--json] record a notification event
@@ -1966,7 +2354,7 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1966
2354
  agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
1967
2355
  agent-switch tooling install|upgrade <tool> install/upgrade an ecosystem tool (agent-config, rtk, claude, codex)
1968
2356
  agent-switch tooling notes [<tool>] [--latest] [--json] read an ecosystem tool's release notes (default agent-config; fetches from GitHub)
1969
- agent-switch doctor per-OS, per-provider self-check`);
2357
+ agent-switch doctor [--json] per-OS, per-provider self-check`);
1970
2358
  }
1971
2359
  /**
1972
2360
  * The interactive limit dialog (Phase 4) — `agent-switch rebind` with no account
@@ -2039,6 +2427,7 @@ async function rebindDialog(running) {
2039
2427
  }
2040
2428
  try {
2041
2429
  const r = await rebind({ account: choice.profile, profile: running });
2430
+ logActivity("rebind", `claude/${running}`, `now serves ${choice.profile}'s account`, "user picked it in the rebind dialog");
2042
2431
  console.log(r.uxNote);
2043
2432
  }
2044
2433
  catch (e) {
@@ -2088,6 +2477,7 @@ async function cmdRebind(providerId, providerExplicit, account, flags) {
2088
2477
  requireProfile("claude", profile, "rebind --restore --profile");
2089
2478
  try {
2090
2479
  const r = await restoreRebind({ profile });
2480
+ logActivity("rebind", `claude/${profile}`, `restored own account (was on ${r.wasBoundTo})`, "user ran `agent-switch rebind --restore`");
2091
2481
  console.log(`Restored "${r.restoredProfile}" to its own account (was rebound to "${r.wasBoundTo}").`);
2092
2482
  }
2093
2483
  catch (e) {
@@ -2110,12 +2500,228 @@ async function cmdRebind(providerId, providerExplicit, account, flags) {
2110
2500
  requireProfile("claude", profile, "rebind --profile");
2111
2501
  try {
2112
2502
  const r = await rebind({ account, profile });
2503
+ logActivity("rebind", `claude/${profile}`, `now serves ${account}'s account`, "user ran `agent-switch rebind`");
2113
2504
  console.log(r.uxNote);
2114
2505
  }
2115
2506
  catch (e) {
2116
2507
  die(e.message);
2117
2508
  }
2118
2509
  }
2510
+ // ---------- gui-settings / activity / auth-status / protection / pool ----------
2511
+ /** `gui-settings [--json]` · `gui-settings set pet-welcomed` ·
2512
+ * `gui-settings set autostart enabled|disabled --by first-run|user` ·
2513
+ * `gui-settings set autostart-degraded --reason <text>|none`. The GUI's
2514
+ * file-backed preferences (see gui-settings.ts); prints the result as JSON. */
2515
+ function cmdGuiSettings(sub, args, flags) {
2516
+ const usageLine = "usage: agent-switch gui-settings [--json] | gui-settings set pet-welcomed | set autostart enabled|disabled --by first-run|user | set autostart-degraded --reason <text>|none";
2517
+ if (sub === undefined || sub === "get" || sub === "status") {
2518
+ console.log(JSON.stringify(readGuiSettings(), null, 2));
2519
+ return;
2520
+ }
2521
+ if (sub !== "set")
2522
+ die(usageLine);
2523
+ const [key, value] = args;
2524
+ let u;
2525
+ if (key === "pet-welcomed")
2526
+ u = { key };
2527
+ else if (key === "autostart" && (value === "enabled" || value === "disabled")) {
2528
+ const by = flags.by === "first-run" || flags.by === "user" ? flags.by : die(usageLine);
2529
+ u = { key, state: value, by };
2530
+ }
2531
+ else if (key === "autostart-degraded") {
2532
+ const reason = typeof flags.reason === "string" ? flags.reason : die(usageLine);
2533
+ u = { key, reason: reason === "none" ? null : reason };
2534
+ }
2535
+ else
2536
+ die(usageLine);
2537
+ const before = readGuiSettings();
2538
+ let after;
2539
+ try {
2540
+ after = applyGuiSetting(before, u);
2541
+ }
2542
+ catch (e) {
2543
+ die(e.message);
2544
+ }
2545
+ writeGuiSettings(after);
2546
+ if (u.key === "autostart" && JSON.stringify(after.autostart) !== JSON.stringify(before.autostart)) {
2547
+ logActivity("autostart", null, `start at login ${u.state}`, u.by === "user" ? "user changed it in the desktop app" : "first-run default, confirmed with the OS");
2548
+ }
2549
+ if (u.key === "autostart-degraded" && u.reason) {
2550
+ logActivity("autostart", null, "start at login could not be confirmed", "first-run default; retried next launch", false);
2551
+ }
2552
+ console.log(JSON.stringify(after, null, 2));
2553
+ }
2554
+ function cmdActivity(flags) {
2555
+ const limit = typeof flags.limit === "string" ? Number(flags.limit) : undefined;
2556
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1))
2557
+ die("--limit must be a whole number of at least 1");
2558
+ const entries = readActivity(limit ?? (flags.json ? undefined : 50));
2559
+ if (flags.json) {
2560
+ console.log(JSON.stringify({ entries, capacity: ACTIVITY_MAX_LINES }, null, 2));
2561
+ return;
2562
+ }
2563
+ if (entries.length === 0) {
2564
+ console.log("No activity recorded yet.");
2565
+ return;
2566
+ }
2567
+ for (const e of entries) {
2568
+ console.log(`${e.at} ${e.ok ? " " : "!"} ${e.kind.padEnd(10)} ${e.target ?? "-"} ${e.action} — ${e.reason}`);
2569
+ }
2570
+ }
2571
+ function authStatusFor(providerId, name) {
2572
+ const now = Date.now();
2573
+ const cfg = provider(providerId).configDirFor(configDir(providerId, name));
2574
+ if (providerId === "claude") {
2575
+ const file = claudeCredentialFile(cfg);
2576
+ const fromFile = readText(file);
2577
+ return claudeAuthStatus(name, credentials.read(cfg), fromFile !== null ? fileMtimeMs(file) : null, now);
2578
+ }
2579
+ if (providerId === "codex")
2580
+ return codexAuthStatus(name, readText(provider("codex").credentialPath(cfg)), now);
2581
+ return antigravityAuthStatus(name, fs.existsSync(provider("antigravity").credentialPath(cfg)));
2582
+ }
2583
+ function cmdAuthStatus(providerId, json) {
2584
+ const rows = (providerId ? listProfiles(providerId).map((n) => ({ provider: providerId, name: n })) : listAllProfiles()).map((r) => authStatusFor(r.provider, r.name));
2585
+ if (json) {
2586
+ console.log(JSON.stringify(rows, null, 2));
2587
+ return;
2588
+ }
2589
+ if (rows.length === 0)
2590
+ return void console.log("No profiles.");
2591
+ for (const r of rows) {
2592
+ const exp = r.expiresAt ? `, ends ${r.expiresAt}` : "";
2593
+ console.log(`${`${r.provider}/${r.name}`.padEnd(24)} ${r.state}${exp} — ${r.detail}`);
2594
+ }
2595
+ }
2596
+ function parseMemberArg(v, usageLine) {
2597
+ const m = v ? parseMember(v) : null;
2598
+ if (!m)
2599
+ die(usageLine);
2600
+ return m;
2601
+ }
2602
+ function cmdProtection(sub, args, flags) {
2603
+ const usageLine = "usage: agent-switch protection [--json] | protection set <provider/name> [--on|--off] [--threshold <1-100>]";
2604
+ if (sub === undefined || sub === "status" || sub === "list") {
2605
+ const rows = listAllProfiles().map((p) => ({ profile: `${p.provider}/${p.name}`, ...readProtection(p.provider, p.name) }));
2606
+ if (flags.json)
2607
+ return void console.log(JSON.stringify(rows, null, 2));
2608
+ if (rows.length === 0)
2609
+ return void console.log("No profiles.");
2610
+ for (const r of rows)
2611
+ console.log(`${r.profile.padEnd(24)} ${r.enabled ? "on " : "off"} at ${r.thresholdPct}%`);
2612
+ console.log("\nA warning when a profile reaches its threshold — nothing is paused or switched.");
2613
+ return;
2614
+ }
2615
+ if (sub !== "set")
2616
+ die(usageLine);
2617
+ const m = parseMemberArg(args[0], usageLine);
2618
+ requireProfile(m.provider, m.name, "protection set");
2619
+ if (flags.on && flags.off)
2620
+ die("pass either --on or --off, not both");
2621
+ const threshold = typeof flags.threshold === "string" ? Number(flags.threshold) : undefined;
2622
+ if (threshold !== undefined && !(Number.isInteger(threshold) && threshold >= 1 && threshold <= 100))
2623
+ die("--threshold must be a whole number between 1 and 100");
2624
+ if (!flags.on && !flags.off && threshold === undefined)
2625
+ die(usageLine);
2626
+ const next = setProtection(m.provider, m.name, {
2627
+ ...(flags.on ? { enabled: true } : {}),
2628
+ ...(flags.off ? { enabled: false } : {}),
2629
+ ...(threshold !== undefined ? { thresholdPct: threshold } : {}),
2630
+ });
2631
+ logActivity("protection", `${m.provider}/${m.name}`, `${next.enabled ? "on" : "off"} at ${next.thresholdPct}%`, "user ran `agent-switch protection set`");
2632
+ if (flags.json)
2633
+ return void console.log(JSON.stringify({ profile: `${m.provider}/${m.name}`, ...next }, null, 2));
2634
+ console.log(`Session protection for ${m.provider}/${m.name}: ${next.enabled ? "on" : "off"} at ${next.thresholdPct}%.`);
2635
+ }
2636
+ function poolMappings(id) {
2637
+ return mappingRows().filter((r) => r.name === `${POOL_PREFIX}${id}`).map((r) => `${r.path} (${r.provider})`);
2638
+ }
2639
+ function cmdPool(sub, args, flags) {
2640
+ const usageLine = "usage: agent-switch pool [list] [--json] | pool create <name> | pool rename <id> <name> | pool delete <id> | pool add <id> <provider/name> | pool remove <id> <provider/name> | pool suggest [--confirm] [--json]";
2641
+ const json = !!flags.json;
2642
+ const out = (v, text) => console.log(json ? JSON.stringify(v, null, 2) : text);
2643
+ try {
2644
+ switch (sub) {
2645
+ case undefined:
2646
+ case "list": {
2647
+ const pools = readPools().map((p) => ({ ...p, rules: poolMappings(p.id) }));
2648
+ if (json)
2649
+ return void console.log(JSON.stringify(pools, null, 2));
2650
+ if (pools.length === 0)
2651
+ return void console.log("No pools. Pools are optional — create one with: agent-switch pool create <name>");
2652
+ for (const p of pools) {
2653
+ console.log(`${p.id} "${p.name}" ${p.members.length ? p.members.join(", ") : "(no members)"}${p.rules.length ? ` · folders: ${p.rules.join(", ")}` : ""}`);
2654
+ }
2655
+ return;
2656
+ }
2657
+ case "create": {
2658
+ if (!args[0])
2659
+ die(usageLine);
2660
+ const p = createPool(args.join(" "));
2661
+ logActivity("pool", p.id, "created", "user ran `agent-switch pool create`");
2662
+ return out(p, `Created pool ${p.id} ("${p.name}"). Add members: agent-switch pool add ${p.id} <provider/name>`);
2663
+ }
2664
+ case "rename": {
2665
+ if (!args[0] || !args[1])
2666
+ die(usageLine);
2667
+ const p = renamePool(args[0], args.slice(1).join(" "));
2668
+ logActivity("pool", p.id, `renamed to "${p.name}"`, "user ran `agent-switch pool rename`");
2669
+ return out(p, `Pool ${p.id} is now called "${p.name}" (its id and folder rules are unchanged).`);
2670
+ }
2671
+ case "delete": {
2672
+ if (!args[0])
2673
+ die(usageLine);
2674
+ const rules = poolMappings(args[0]);
2675
+ if (rules.length > 0)
2676
+ die(`pool "${args[0]}" is used by folder rules: ${rules.join(", ")}. Remove them first (agent-switch unmap <dir>).`);
2677
+ deletePool(args[0]);
2678
+ logActivity("pool", args[0], "deleted", "user ran `agent-switch pool delete`");
2679
+ return out({ deleted: args[0] }, `Deleted pool ${args[0]}. Its profiles are untouched.`);
2680
+ }
2681
+ case "add":
2682
+ case "remove": {
2683
+ if (!args[0])
2684
+ die(usageLine);
2685
+ const m = parseMemberArg(args[1], usageLine);
2686
+ if (sub === "add")
2687
+ requireProfile(m.provider, m.name, "pool add");
2688
+ const key = `${m.provider}/${m.name}`;
2689
+ const p = sub === "add" ? addPoolMember(args[0], key) : removePoolMember(args[0], key);
2690
+ logActivity("pool", p.id, `${sub === "add" ? "added" : "removed"} ${key}`, `user ran \`agent-switch pool ${sub}\``);
2691
+ return out(p, `${sub === "add" ? "Added" : "Removed"} ${key} ${sub === "add" ? "to" : "from"} pool ${p.id}.`);
2692
+ }
2693
+ case "suggest": {
2694
+ const proposals = suggestPoolsFromLabels();
2695
+ if (!flags.confirm) {
2696
+ if (json)
2697
+ return void console.log(JSON.stringify({ preview: true, proposals }, null, 2));
2698
+ if (proposals.length === 0)
2699
+ return void console.log("No labelled profiles — nothing to suggest.");
2700
+ console.log("Preview — nothing is created until you run `agent-switch pool suggest --confirm`:");
2701
+ for (const p of proposals)
2702
+ console.log(` ${p.id} ${p.members.join(", ")}${p.exists ? " (exists — skipped)" : ""}`);
2703
+ return;
2704
+ }
2705
+ const created = [];
2706
+ for (const prop of proposals) {
2707
+ if (prop.exists)
2708
+ continue;
2709
+ const pool = createPool(prop.name);
2710
+ for (const m of prop.members)
2711
+ addPoolMember(pool.id, m);
2712
+ created.push(pool.id);
2713
+ logActivity("pool", pool.id, `created from label ${prop.name}`, "user confirmed `agent-switch pool suggest`");
2714
+ }
2715
+ return out({ created }, created.length ? `Created pools: ${created.join(", ")}.` : "Nothing to create.");
2716
+ }
2717
+ default:
2718
+ die(usageLine);
2719
+ }
2720
+ }
2721
+ catch (e) {
2722
+ die(e.message);
2723
+ }
2724
+ }
2119
2725
  // ---------- main -------------------------------------------------------------
2120
2726
  async function main() {
2121
2727
  const argv = process.argv.slice(2);
@@ -2144,13 +2750,21 @@ async function main() {
2144
2750
  }
2145
2751
  }
2146
2752
  switch (cmd) {
2147
- case "add": return cmdAdd(providerId, positional[0]);
2753
+ case "add": return cmdAdd(providerId, positional[0], typeof flags.browser === "string" ? flags.browser : undefined);
2754
+ case "login": return cmdLogin(providerId, positional[0], typeof flags.browser === "string" ? flags.browser : undefined);
2755
+ case "browsers": return cmdBrowsers(!!flags.json);
2148
2756
  case "import": return cmdImport(providerId, positional[0]);
2149
2757
  case "use": return cmdUse(providerId, positional[0]);
2150
2758
  case "deactivate": return cmdDeactivate(providerId);
2151
2759
  case "label": return cmdLabel(providerId, positional[0], positional[1]);
2760
+ case "color":
2761
+ case "colour": return cmdColor(providerId, positional[0], positional[1]);
2762
+ case "activate": return cmdActivate(!!flags.json);
2763
+ case "desktop": return cmdDesktop(!!flags.json);
2764
+ case "disable": return cmdDisable(providerId, positional[0], true);
2765
+ case "enable": return cmdDisable(providerId, positional[0], false);
2152
2766
  case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags);
2153
- case "reset": return cmdReset(providerId, positional[0]);
2767
+ case "reset": return cmdReset(providerId, positional[0], !!flags.yes);
2154
2768
  case "rename": return cmdRename(providerId, positional[0], positional[1]);
2155
2769
  case "providers": return cmdProviders(providerId, providerExplicit, positional[0], flags);
2156
2770
  case "gui": return cmdGui();
@@ -2165,8 +2779,14 @@ async function main() {
2165
2779
  case "os-notify": return cmdOsNotify(positional[0], !!flags.json);
2166
2780
  case "uninstall": return cmdUninstall(flags);
2167
2781
  case "run": {
2168
- const r = parseRun(rest);
2169
- return cmdRun(r.providerId, r.name, r.args);
2782
+ let r;
2783
+ try {
2784
+ r = parseRun(rest);
2785
+ }
2786
+ catch (e) {
2787
+ die(e.message);
2788
+ }
2789
+ return cmdRun(r.providerId, r.name, r.args, r.cwd);
2170
2790
  }
2171
2791
  case "handoff": return cmdHandoff(providerId, positional[0], positional.slice(1), flags);
2172
2792
  case "list":
@@ -2174,11 +2794,11 @@ async function main() {
2174
2794
  case "status": return cmdStatus(providerExplicit ? providerId : undefined, positional[0], !!flags.json);
2175
2795
  case "current": return cmdCurrent(providerExplicit ? providerId : undefined);
2176
2796
  case "whoami": return cmdWhoami(providerId, positional[0]);
2177
- case "dir": return cmdDir(providerId, !!flags.guard);
2797
+ case "dir": return cmdDir(providerId, !!flags.guard, !!flags.json, !!flags.plain);
2178
2798
  case "guard": return cmdGuard(positional[0], !!flags.json);
2179
- case "map": return cmdMap(providerId, positional[0], positional[1]);
2799
+ case "map": return cmdMap(providerId, positional[0], positional[1], flags);
2180
2800
  case "unmap": return cmdUnmap(providerExplicit ? providerId : undefined, positional[0]);
2181
- case "mappings": return cmdMappings();
2801
+ case "mappings": return cmdMappings(rest.includes("--json"));
2182
2802
  case "share": return cmdShare(positional[0], rest.slice(1));
2183
2803
  case "config": return cmdConfig(positional[0], positional[1], flags);
2184
2804
  case "sessions":
@@ -2196,8 +2816,11 @@ async function main() {
2196
2816
  case "rebind": return cmdRebind(providerId, providerExplicit, positional[0], flags);
2197
2817
  case "web": return cmdWeb(positional[0]);
2198
2818
  case "remove":
2199
- case "rm": return cmdRemove(providerId, positional[0], !!flags.force);
2200
- case "shellenv": return cmdShellenv(flags.shell ?? positional[0]);
2819
+ case "rm": return cmdRemove(providerId, positional[0], !!flags.force, !!flags.yes);
2820
+ case "shellenv":
2821
+ return positional[0] === "install"
2822
+ ? cmdShellenv(flags.shell, "install", !!flags.json)
2823
+ : cmdShellenv(flags.shell ?? positional[0]);
2201
2824
  case "service": return cmdService(positional[0]);
2202
2825
  case "usage": return cmdUsageHistory(providerId, positional[0], flags);
2203
2826
  case "tooling":
@@ -2210,7 +2833,13 @@ async function main() {
2210
2833
  die(`usage: agent-switch tooling [install|upgrade <${TOOL_IDS.join("|")}>] [notes [<tool>]] [--json]`);
2211
2834
  }
2212
2835
  return cmdTooling(!!flags.json);
2213
- case "doctor": return process.exit(await runDoctor());
2836
+ case "doctor": return process.exit(await runDoctor(!!flags.json));
2837
+ case "auth-status": return cmdAuthStatus(providerExplicit ? providerId : undefined, !!flags.json);
2838
+ case "activity": return cmdActivity(flags);
2839
+ case "protection": return cmdProtection(positional[0], positional.slice(1), flags);
2840
+ case "pool":
2841
+ case "pools": return cmdPool(positional[0], positional.slice(1), flags);
2842
+ case "gui-settings": return cmdGuiSettings(positional[0], positional.slice(1), flags);
2214
2843
  case "help":
2215
2844
  case "--help":
2216
2845
  case "-h": return usage();