@event4u/agent-switch 2.0.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,47 +15,102 @@ 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, 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";
30
- import { TOOL_IDS, checkTooling, formatToolingLines, runToolAction } from "./tooling.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";
37
+ import { TOOL_IDS, checkTooling, formatToolNotes, formatToolingLines, runToolAction, toolNotesRefusal, toolReleaseRepo, } from "./tooling.js";
38
+ import { releaseNoteBlocks } from "./release-notes.js";
31
39
  import { readHistory, recordHistorySample } from "./history.js";
32
40
  import { launchGui } from "./gui-launch.js";
33
- import { checkForUpdate, selfUpdate } from "./updates.js";
34
- import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, } from "./mappings.js";
41
+ import { GUARD_POLICIES, GUARD_REFUSED_EXIT, OVERRIDE_ENV, guardVerdict, inspectLoad, isGuardPolicy, overrideFromEnv, renderGuardMessage, } from "./single-load.js";
42
+ import { checkForUpdate, fetchReleases, releasesNewerThan, selfUpdate } from "./updates.js";
43
+ import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, setPaused, normalizePath, } from "./mappings.js";
35
44
  import { checkAuth, fetchProfile, fetchUsage, liveSessionPids, } from "./api.js";
36
45
  import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
37
46
  import { formatSnapshot, parseUsage } from "./usage.js";
38
47
  import { readCodexUsage } from "./codex-usage.js";
39
48
  import { redeemResetCredit } from "./codex-reset.js";
40
- import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, 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";
41
50
  import { isFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
42
- import { cmdService, serviceUninstall } from "./service.js";
51
+ import { cmdService, serviceInstall, serviceTarget, serviceUninstall } from "./service.js";
52
+ import { fileURLToPath } from "node:url";
43
53
  import { readContext, turnInFlight } from "./telemetry.js";
44
54
  import { readPreview } from "./session-preview.js";
45
- 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";
46
56
  import { readTelemetryConfig, writeTelemetryConfig } from "./notify.js";
47
57
  import { appendNotification, readNotifications, clearNotifications } from "./notifications.js";
48
58
  import { APPS, buildLaunch, findApp, guiDataDir, isInstalled } from "./apps.js";
59
+ import { scanDesktop } from "./desktop-sessions.js";
49
60
  import { ensureAgyKeychain } from "./agy-keychain.js";
50
61
  import { currentManagedSession, currentTmuxSessionName, newSessionArgs, readTmuxRegistry, recordManagedSession, respawnPaneArgs, sendKeysArgs, tmuxAvailable, tmuxSessionName, } from "./tmux.js";
51
62
  // Per-OS credential store (keychain-then-file on darwin, file-only elsewhere).
52
63
  const credentials = credentialStore();
53
64
  const CLAUDE_HOME = provider("claude").defaultConfigDir(); // ~/.claude
54
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
+ }
70
+ /** Same-provider profiles that carry no live session — the concrete
71
+ * alternatives a refusal names, so the user can act on it without first
72
+ * running `list` themselves. */
73
+ function freeProfiles(providerId, exclude) {
74
+ return listProfiles(providerId).filter((n) => n !== exclude && liveSessionPids(configDir(providerId, n)).length === 0);
75
+ }
76
+ function checkSingleLoad(providerId, name, opts = {}) {
77
+ const p = provider(providerId);
78
+ // Early-out before touching the filesystem: `dir --guard` runs on every
79
+ // interactive start, so a disabled guard (or a provider with no live-session
80
+ // signal at all) must cost a state read and nothing more.
81
+ const policy = readGuard();
82
+ if (policy === "off" || !p.hasLiveSessionSignal) {
83
+ return { verdict: "allow", inspection: { sessions: [], loaded: false, nested: false }, message: null };
84
+ }
85
+ const cfg = configDir(providerId, name);
86
+ const inspection = inspectLoad(cfg, p.envVar);
87
+ const verdict = guardVerdict({
88
+ policy,
89
+ inspection,
90
+ overridden: opts.forced === true || overrideFromEnv(),
91
+ detectable: p.hasLiveSessionSignal,
92
+ });
93
+ return {
94
+ verdict,
95
+ inspection,
96
+ message: verdict === "allow"
97
+ ? null
98
+ : renderGuardMessage({
99
+ profile: name,
100
+ binary: p.binary,
101
+ verdict,
102
+ inspection,
103
+ offerForceFlag: opts.offerForceFlag === true,
104
+ context: opts.context ?? "start",
105
+ // Only worth computing on a message we are actually going to print.
106
+ alternatives: verdict === "refuse" ? freeProfiles(providerId, name) : [],
107
+ }),
108
+ };
109
+ }
55
110
  // ---------- launcher ---------------------------------------------------------
56
- function launch(p, name, args) {
111
+ function launch(p, name, args, extraEnv = {}) {
57
112
  const home = configDir(p.id, name);
58
- const env = { ...process.env, [p.envVar]: home };
113
+ const env = { ...process.env, ...extraEnv, [p.envVar]: home };
59
114
  // Antigravity's agy CLI stores its token in the macOS keychain (fixed key) —
60
115
  // seed a per-profile keychain under this HOME so the token is isolated per
61
116
  // account (and macOS doesn't pop its "no keychain found" dialog). No-op off mac.
@@ -73,20 +128,111 @@ function launch(p, name, args) {
73
128
  return res.status ?? 1;
74
129
  }
75
130
  // ---------- commands ---------------------------------------------------------
76
- function cmdAdd(providerId, name) {
131
+ function cmdAdd(providerId, name, browserId) {
77
132
  if (!name)
78
- die(`usage: agent-switch add [--provider ${providerId}] <profile>`);
133
+ die(`usage: agent-switch add [--provider ${providerId}] <profile> [--browser B]`);
79
134
  if (profileExists(providerId, name))
80
135
  die(`profile "${name}" already exists for ${providerId}`);
81
136
  const p = provider(providerId);
137
+ const env = browserId ? browserEnv(browserId, providerId) : {};
82
138
  ensureRoot();
83
- 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);
84
145
  console.log(`Created ${providerId} profile "${name}".`);
85
- console.log(`Launching ${p.binary} for first login (log in inside if prompted)...`);
86
- 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);
87
151
  const id = identity(providerId, name);
88
- console.log(id ? `Profile "${name}" is linked to ${id}.` : `Profile "${name}" created.`);
89
- 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}` : ""}.`);
90
236
  }
91
237
  /**
92
238
  * Login-free import of the default Claude install into a profile. Takes Claude
@@ -161,10 +307,21 @@ async function cmdImport(providerId, name) {
161
307
  }
162
308
  function cmdUse(providerId, name) {
163
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}`}`);
164
312
  setActive(providerId, n);
313
+ logActivity("switch", `${providerId}/${n}`, "set as active profile", "user ran `agent-switch use`");
165
314
  const id = identity(providerId, n);
166
315
  console.log(`Active ${providerId} profile: ${n}${id ? ` (${id})` : ""}`);
167
316
  console.log(`New \`${provider(providerId).binary}\` sessions use this profile. Running sessions are unaffected.`);
317
+ // `use` loads nothing — it states an intent — so it NEVER blocks. But it is
318
+ // the cheapest possible moment to tell the user that the profile they just
319
+ // pointed at is already busy, before they type the command that gets refused.
320
+ const check = checkSingleLoad(providerId, n, { context: "use" });
321
+ if (check.message) {
322
+ console.log("");
323
+ console.warn(check.message);
324
+ }
168
325
  }
169
326
  function cmdDeactivate(providerId) {
170
327
  const active = activeFor(providerId);
@@ -173,15 +330,30 @@ function cmdDeactivate(providerId) {
173
330
  return;
174
331
  }
175
332
  setActive(providerId, null);
333
+ logActivity("switch", `${providerId}/${active}`, "deactivated", "user ran `agent-switch deactivate`");
176
334
  console.log(`Deactivated ${providerId} profile "${active}". No ${providerId} profile is active.`);
177
335
  console.log(`New \`${provider(providerId).binary}\` sessions fall back to the default install until you \`use\` one.`);
178
336
  }
179
- function cmdRun(providerId, name, args) {
337
+ function cmdRun(providerId, name, args, cwd) {
180
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
+ }
181
344
  const p = provider(providerId);
182
- // `--tmux` is an agent-switch flag, not a passthrough flag — strip it.
345
+ // `--tmux` and `--force` are agent-switch flags, not passthrough flags — strip
346
+ // them before the rest reaches the provider binary.
183
347
  const wantTmux = args.includes("--tmux");
184
- const passthrough = args.filter((a) => a !== "--tmux");
348
+ const forced = args.includes("--force");
349
+ const passthrough = args.filter((a) => a !== "--tmux" && a !== "--force");
350
+ // agent-switch spawns the binary itself here, so this is the strongest
351
+ // enforcement point: refuse before anything is launched.
352
+ const guard = checkSingleLoad(providerId, n, { forced, offerForceFlag: true });
353
+ if (guard.message)
354
+ console.error(guard.verdict === "refuse" ? `error: ${guard.message}` : guard.message);
355
+ if (guard.verdict === "refuse")
356
+ process.exit(GUARD_REFUSED_EXIT);
185
357
  if (wantTmux) {
186
358
  if (!tmuxAvailable()) {
187
359
  console.log("(--tmux unavailable — tmux not found or Windows; running normally.)");
@@ -204,8 +376,12 @@ function printProfileLine(providerId, name, showLive) {
204
376
  let live = "";
205
377
  if (showLive && providerId === "claude") {
206
378
  const pids = liveSessionPids(configDir("claude", name));
207
- if (pids.length > 0)
208
- live = ` [${pids.length} live session${pids.length > 1 ? "s" : ""}]`;
379
+ // More than one session on one account is the state the single-load guard
380
+ // exists to prevent, so it is marked rather than blending into the count.
381
+ if (pids.length > 1)
382
+ live = ` ⚠️ [loaded ${pids.length}× — ${pids.length} live sessions]`;
383
+ else if (pids.length === 1)
384
+ live = " [1 live session]";
209
385
  }
210
386
  console.log(`${mark} ${name.padEnd(16)} ${id}${live}`);
211
387
  }
@@ -224,6 +400,8 @@ function cmdList(providerId, json = false) {
224
400
  name: n,
225
401
  identity: identity(pid, n),
226
402
  label: labelFor(pid, n),
403
+ color: colorOf(pid, n),
404
+ disabled: isDisabled(pid, n),
227
405
  active: activeFor(pid) === n,
228
406
  liveSessions: pid === "claude" ? liveSessionPids(configDir("claude", n)).length : 0,
229
407
  })));
@@ -340,7 +518,10 @@ async function cmdStatus(providerId, name, json = false) {
340
518
  }
341
519
  const token = freshAccessToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")));
342
520
  if (!token) {
343
- 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)");
344
525
  continue;
345
526
  }
346
527
  const [profileInfo, raw] = await Promise.all([fetchProfile(token), fetchUsage(token)]);
@@ -362,20 +543,107 @@ async function cmdStatus(providerId, name, json = false) {
362
543
  }
363
544
  }
364
545
  }
365
- function cmdDir(providerId) {
366
- // Precedence: directory mapping (nearest ancestor of CWD) > active-for-provider.
367
- const mapped = resolveMapping(process.cwd(), providerId);
368
- if (mapped && profileExists(providerId, mapped.name)) {
369
- console.log(configDir(providerId, mapped.name));
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
+ }
594
+ /**
595
+ * Resolve which profile a shell wrapper should load: directory mapping (nearest
596
+ * ancestor of CWD) > active-for-provider. Empty output → the wrapper falls back
597
+ * to the default config dir.
598
+ *
599
+ * With `--guard`, stdout is unchanged (the wrapper still reads the path from
600
+ * it) and the guard speaks on STDERR, exiting {@link GUARD_REFUSED_EXIT} on a
601
+ * refusal. Splitting the streams is what lets one command both feed the wrapper
602
+ * and talk to the human; a distinct exit code is what lets the wrapper block on
603
+ * a refusal without ever blocking on a missing or older `agent-switch`.
604
+ */
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));
370
609
  return;
371
610
  }
372
- const active = activeFor(providerId);
373
- if (active && profileExists(providerId, active))
374
- console.log(configDir(providerId, active));
375
- // Empty output -> shell wrapper falls back to the default config dir.
611
+ const name = r.name;
612
+ if (!name)
613
+ return;
614
+ console.log(configDir(providerId, name));
615
+ if (!guarded)
616
+ return;
617
+ // A wrapper cannot pass `--force` (it would reach the provider binary), so
618
+ // only the env override is offered here.
619
+ const guard = checkSingleLoad(providerId, name, { offerForceFlag: false });
620
+ if (guard.message)
621
+ console.error(`agent-switch: ${guard.message}`);
622
+ if (guard.verdict === "refuse")
623
+ process.exit(GUARD_REFUSED_EXIT);
376
624
  }
377
- function cmdMap(providerId, name, dir) {
378
- 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]`);
379
647
  const key = setMapping(dir ?? process.cwd(), providerId, n);
380
648
  console.log(`Mapped ${key} -> ${providerId}/${n}`);
381
649
  console.log(`\`${provider(providerId).binary}\` in this directory (and subdirectories) now uses this profile.`);
@@ -387,14 +655,20 @@ function cmdUnmap(providerId, dir) {
387
655
  else
388
656
  die(`no mapping for ${target}${providerId ? ` (${providerId})` : ""}`);
389
657
  }
390
- function cmdMappings() {
658
+ function cmdMappings(json = false) {
391
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
+ }
392
664
  if (rows.length === 0) {
393
665
  console.log("No directory mappings. Create one with: agent-switch map <profile> [dir]");
394
666
  return;
395
667
  }
396
- for (const r of rows)
397
- 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
+ }
398
672
  }
399
673
  // share + web are Claude-config concepts and stay Claude-scoped.
400
674
  function cmdShare(mode, flags = []) {
@@ -420,7 +694,9 @@ function cmdShare(mode, flags = []) {
420
694
  const actions = applySharing(source, configDir("claude", p), withHistory);
421
695
  console.log(` ${p}: ${actions.length > 0 ? actions.join(", ") : "up to date"}`);
422
696
  }
423
- 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" +
424
700
  "(settings.json, ...) fork on an in-profile /config edit — run `agent-switch share sync`\n" +
425
701
  "to push a fork back to the source and re-link.");
426
702
  }
@@ -446,7 +722,11 @@ function cmdShare(mode, flags = []) {
446
722
  // flag: sharing is active when any profile carries agent-switch-managed links.
447
723
  const rows = profiles
448
724
  .filter((p) => configDir("claude", p) !== source)
449
- .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
+ });
450
730
  const active = rows.some((r) => r.shared);
451
731
  if (flags.includes("--json")) {
452
732
  console.log(JSON.stringify({ active, source: sourceName ?? "default", profiles: rows }));
@@ -454,7 +734,7 @@ function cmdShare(mode, flags = []) {
454
734
  }
455
735
  console.log(`share: ${active ? "on" : "off"} (source ${sourceName ?? "default"})`);
456
736
  for (const r of rows)
457
- 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)" : ""}`);
458
738
  }
459
739
  else {
460
740
  die("usage: agent-switch share on|sync|off|status [--history] [--source <profile|default>]");
@@ -739,6 +1019,40 @@ function cmdHookEvent() {
739
1019
  * GUI contract: a flat array, metadata only — transcript content never leaves
740
1020
  * the profile dir (only the guarded first-line header is read).
741
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
+ }
742
1056
  function cmdSessions(providerId, providerExplicit, name, flags = {}) {
743
1057
  if (providerExplicit && providerId !== "claude" && providerId !== "codex") {
744
1058
  die(`sessions supports claude and codex for now (${providerId} parity is gated on its Phase-0 spike — see scripts/spikes/)`);
@@ -751,6 +1065,8 @@ function cmdSessions(providerId, providerExplicit, name, flags = {}) {
751
1065
  if (profiles.length === 0)
752
1066
  die(`no ${providerId} profiles`);
753
1067
  const all = [];
1068
+ // Claude Desktop's own sessions, filed by account (see desktop-sessions.ts).
1069
+ const desktop = providerId === "claude" ? desktopScan().rows : [];
754
1070
  for (const p of profiles) {
755
1071
  const cfg = configDir(providerId, p);
756
1072
  // Codex stores date-partitioned rollout files (no encoded-cwd dir); its live
@@ -759,7 +1075,24 @@ function cmdSessions(providerId, providerExplicit, name, flags = {}) {
759
1075
  const rows = providerId === "codex" ? listCodexSessions(cfg, limit) : listSessions(cfg, limit);
760
1076
  if (providerId === "claude")
761
1077
  markLive(cfg, rows); // POSIX: pid→cwd→dir match; win32: recent-only
762
- 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 })));
763
1096
  }
764
1097
  if (flags.json) {
765
1098
  console.log(JSON.stringify(all.map((r) => {
@@ -1236,7 +1569,25 @@ function takeoverCodex(sessionId, target, opts) {
1236
1569
  if (!printOnly && process.stdin.isTTY)
1237
1570
  process.exit(launch(provider("codex"), target, resumeArgs));
1238
1571
  }
1239
- 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) {
1240
1591
  const n = requireProfile(providerId, name, "remove");
1241
1592
  if (activeFor(providerId) === n && !force) {
1242
1593
  die(`"${n}" is the active ${providerId} profile. Switch away first or use --force.`);
@@ -1247,15 +1598,53 @@ function cmdRemove(providerId, name, force = false) {
1247
1598
  die(`profile "${n}" has live Claude Code sessions (PIDs ${pids.join(", ")}). Close them or use --force.`);
1248
1599
  }
1249
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
+ }
1250
1620
  // Keychain entries exist only for claude (darwin); codex/antigravity are file-based,
1251
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;
1252
1634
  const removedEntry = providerId === "claude" ? credentials.removeEntry(configDir(providerId, n)) : false;
1253
1635
  fs.rmSync(profileDir(providerId, n), { recursive: true, force: true });
1254
1636
  if (activeFor(providerId) === n)
1255
1637
  setActive(providerId, null);
1256
1638
  clearLabel(providerId, n); // drop its label so it can't linger as an orphan
1257
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`");
1258
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).`);
1259
1648
  if (removedEntry)
1260
1649
  console.log("Removed its keychain credential entry.");
1261
1650
  if (pruned.length > 0)
@@ -1280,6 +1669,55 @@ function cmdLabel(providerId, name, label) {
1280
1669
  setLabel(providerId, name, label);
1281
1670
  console.log(`Labeled ${providerId}/${name} as ${label}.`);
1282
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
+ }
1283
1721
  /** Enable/disable opt-in near-limit notifications (notify + suggest, never an
1284
1722
  * automatic switch), or show the current setting. */
1285
1723
  /** Rename a profile (move its config dir + carry active/label/mappings). */
@@ -1309,15 +1747,22 @@ function cmdRename(providerId, from, to) {
1309
1747
  if (m.provider === providerId && m.name === n)
1310
1748
  setMapping(m.path, providerId, to);
1311
1749
  }
1750
+ renameProtection(providerId, n, to);
1751
+ renamePoolMember(`${providerId}/${n}`, `${providerId}/${to}`);
1312
1752
  console.log(`Renamed ${providerId} profile "${n}" → "${to}".`);
1313
1753
  }
1314
1754
  /** Manually redeem one banked rate-limit reset for a Codex profile. Consumes a
1315
1755
  * real, scarce credit — the GUI gates this behind a confirmation. */
1316
- async function cmdReset(providerId, name) {
1756
+ async function cmdReset(providerId, name, yes = false) {
1317
1757
  if (providerId !== "codex")
1318
1758
  die("reset is only available for codex (banked rate-limit resets).");
1319
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
+ }
1320
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);
1321
1766
  if (r.ok) {
1322
1767
  console.log(`Redeemed a reset for codex/${n} (windows_reset=${r.windowsReset ?? "?"}).`);
1323
1768
  }
@@ -1358,7 +1803,7 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}) {
1358
1803
  if (cfg.enabled) {
1359
1804
  console.log(`Once the active ${providerId} profile hits the threshold, the daemon NOTIFIES you and SUGGESTS the\n` +
1360
1805
  "account with the most headroom — it never switches automatically; switching stays a manual action.\n" +
1361
- "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" +
1362
1807
  "Run `agent-switch service start` so the daemon is watching.");
1363
1808
  }
1364
1809
  return;
@@ -1547,7 +1992,9 @@ function cmdNotify(flags) {
1547
1992
  }
1548
1993
  if (!title && !message)
1549
1994
  die("agent-switch notify needs at least --title or --message");
1550
- 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);
1551
1998
  if (flags.json)
1552
1999
  console.log(JSON.stringify(created));
1553
2000
  }
@@ -1634,7 +2081,9 @@ async function cmdUpdate() {
1634
2081
  console.log(`Updating ${c.current} → ${c.release.tag} via npm…`);
1635
2082
  else
1636
2083
  console.log(`Update check failed (${c.message}); attempting \`npm install\` anyway…`);
1637
- 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);
1638
2087
  }
1639
2088
  function cmdOpen(appId, name) {
1640
2089
  if (!appId)
@@ -1664,8 +2113,75 @@ function cmdOpen(appId, name) {
1664
2113
  console.log("is saved to this profile. Quit other windows of this app before signing in.");
1665
2114
  }
1666
2115
  }
1667
- function cmdShellenv(shellArg) {
1668
- console.log(shellenvScript(detectShell(shellArg)));
2116
+ /**
2117
+ * `agent-switch guard [block|warn|off]` — read or set the single-load policy.
2118
+ * The bare form also reports what is loaded RIGHT NOW, because "what is my
2119
+ * policy" and "is anything currently doubled" are the same question in practice.
2120
+ */
2121
+ function cmdGuard(value, json = false) {
2122
+ if (value !== undefined) {
2123
+ if (!isGuardPolicy(value)) {
2124
+ die(`unknown guard policy "${value}" (choose: ${GUARD_POLICIES.join(", ")})`);
2125
+ }
2126
+ setGuard(value);
2127
+ logActivity("guard", null, `set ${value}`, "user ran `agent-switch guard`");
2128
+ }
2129
+ const policy = readGuard();
2130
+ // Every profile of every provider that can report a live session.
2131
+ const rows = [];
2132
+ for (const pid of PROVIDER_IDS) {
2133
+ if (!provider(pid).hasLiveSessionSignal)
2134
+ continue;
2135
+ for (const n of listProfiles(pid)) {
2136
+ const count = liveSessionPids(configDir(pid, n)).length;
2137
+ if (count > 0)
2138
+ rows.push({ provider: pid, name: n, sessions: count });
2139
+ }
2140
+ }
2141
+ if (json) {
2142
+ console.log(JSON.stringify({ policy, loaded: rows }, null, 2));
2143
+ return;
2144
+ }
2145
+ const explain = {
2146
+ block: "a second session on a profile that is already loaded is refused",
2147
+ warn: "a second session is reported, then allowed",
2148
+ off: "no check at all",
2149
+ };
2150
+ console.log(`Single-load guard: ${policy} — ${explain[policy]}.`);
2151
+ if (rows.length === 0) {
2152
+ console.log("No profile is currently loaded by a live session.");
2153
+ }
2154
+ else {
2155
+ console.log("");
2156
+ for (const r of rows) {
2157
+ const doubled = r.sessions > 1;
2158
+ const mark = doubled ? "⚠️ " : " ";
2159
+ const detail = doubled ? `loaded ${r.sessions}× — this is what the guard exists to prevent` : "1 live session";
2160
+ console.log(`${mark} ${`${r.provider}/${r.name}`.padEnd(24)} ${detail}`);
2161
+ }
2162
+ }
2163
+ const inert = PROVIDER_IDS.filter((pid) => !provider(pid).hasLiveSessionSignal && listProfiles(pid).length > 0);
2164
+ if (inert.length > 0) {
2165
+ console.log("");
2166
+ console.log(`Not covered: ${inert.join(", ")} — these CLIs do not register a running session on disk,`);
2167
+ console.log("so agent-switch has no way to tell a second load from a first.");
2168
+ }
2169
+ if (policy !== "off") {
2170
+ console.log("");
2171
+ console.log(`Override once: ${OVERRIDE_ENV}=1 <binary> · Change: agent-switch guard <${GUARD_POLICIES.join("|")}>`);
2172
+ }
2173
+ }
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.`);
1669
2185
  }
1670
2186
  /** `agent-switch tooling` — ecosystem tool readout (agent-config, rtk, provider
1671
2187
  * CLIs). `--json` is the GUI IPC contract: the GUI renders this readout and
@@ -1679,6 +2195,61 @@ function cmdTooling(json = false) {
1679
2195
  console.log(`agent-switch tooling — platform ${process.platform}\n`);
1680
2196
  for (const l of formatToolingLines(tools))
1681
2197
  console.log(l);
2198
+ // This readout stays offline by contract; the notes command is where the
2199
+ // network lives, so point at it rather than fetching here.
2200
+ console.log("\nWhat changed in a release: `agent-switch tooling notes [<tool>]`");
2201
+ }
2202
+ /**
2203
+ * `agent-switch tooling notes [<tool>] [--latest] [--json]` — read what an
2204
+ * ecosystem update actually contains, in the terminal.
2205
+ *
2206
+ * Deliberately a SEPARATE subcommand: `tooling` (no args) is a local, offline
2207
+ * readout the GUI consumes over `--json`, and that contract stays. This one
2208
+ * fetches, and says so in the help.
2209
+ *
2210
+ * Default tool is `agent-config` (the one agent-switch announces updates for).
2211
+ * Shown: every release newer than the installed version, newest first — so
2212
+ * someone several versions behind reads all of them, not just the last. Up to
2213
+ * date or not installed → the newest release, so the command always answers.
2214
+ * A tool with no verified release source gets a named refusal, never an
2215
+ * invented source.
2216
+ */
2217
+ async function cmdToolingNotes(id, flags) {
2218
+ const toolId = (id ?? "agent-config");
2219
+ if (!TOOL_IDS.includes(toolId)) {
2220
+ die(`usage: agent-switch tooling notes [<${TOOL_IDS.join("|")}>] [--latest] [--json]`);
2221
+ }
2222
+ const repo = toolReleaseRepo(toolId);
2223
+ if (!repo)
2224
+ die(toolNotesRefusal(toolId));
2225
+ const installed = checkTooling({ ids: [toolId] })[0]?.version ?? null;
2226
+ let releases = [];
2227
+ let failure = null;
2228
+ try {
2229
+ releases = await fetchReleases(repo);
2230
+ }
2231
+ catch (e) {
2232
+ failure = e instanceof Error ? e.message : String(e);
2233
+ }
2234
+ const latest = releases[0]?.tag ?? null;
2235
+ // Unread first; when there is nothing unread (up to date, not installed, or
2236
+ // the list is empty) fall back to the newest release so the command answers.
2237
+ const unread = installed ? releasesNewerThan(releases, installed) : [];
2238
+ const selected = unread.length > 0 ? unread : releases.slice(0, 1);
2239
+ const view = { id: toolId, installed, latest, releases: flags.latest ? selected.slice(0, 1) : selected };
2240
+ if (flags.json) {
2241
+ console.log(JSON.stringify({
2242
+ ...view,
2243
+ repo,
2244
+ error: failure,
2245
+ releases: view.releases.map((r) => ({ ...r, blocks: releaseNoteBlocks(r.notes) })),
2246
+ }, null, 2));
2247
+ return;
2248
+ }
2249
+ for (const line of formatToolNotes(view))
2250
+ console.log(line);
2251
+ if (failure)
2252
+ console.log(`\nCould not reach the release source (${failure}) — showing what was available.`);
1682
2253
  }
1683
2254
  /** `agent-switch tooling install|upgrade <id>` — run the tool's per-platform
1684
2255
  * install/upgrade command as a visible child process (the GUI runs this inside
@@ -1727,7 +2298,9 @@ function usage() {
1727
2298
  Provider defaults to claude; pass --provider codex|antigravity for the others.
1728
2299
  Run with no command to launch the tray/menubar GUI (single-instance).
1729
2300
 
1730
- 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)
1731
2304
  agent-switch import [--provider P] <name> migrate the default install (no re-login)
1732
2305
  agent-switch use [--provider P] <name> set the active profile for a provider
1733
2306
  agent-switch deactivate [--provider P] clear the active profile for a provider
@@ -1736,8 +2309,10 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1736
2309
  agent-switch status [--provider P] [name] [--json] identity (+ Claude usage); --json = active only
1737
2310
  agent-switch current [--provider P] show the active profile(s)
1738
2311
  agent-switch whoami [--provider P] [name] show a profile's account identity
1739
- agent-switch dir [--provider P] resolve profile for CWD (mapping > active)
1740
- 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
1741
2316
  agent-switch unmap [--provider P] [dir] remove a directory mapping
1742
2317
  agent-switch mappings list directory mappings
1743
2318
  agent-switch share on|sync|off|status [--history] [--source <profile|default>] [--json] (Claude)
@@ -1749,10 +2324,18 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1749
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
1750
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)
1751
2326
  agent-switch web <name> claude.ai in a persistent browser (Claude)
1752
- 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)
1753
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
1754
2333
  agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] notify + suggest near a usage limit; never switches automatically (default OFF)
1755
- 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
1756
2339
  agent-switch rename <old> <new> [--provider P] rename a profile (name & keep its tag)
1757
2340
  agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] enable/disable a provider (default: Claude + Codex)
1758
2341
  agent-switch [gui] launch the tray/menubar GUI (the default with no command; won't start a second if one is running)
@@ -1761,6 +2344,7 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1761
2344
  agent-switch apps list launchable GUI apps (macOS)
1762
2345
  agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
1763
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
1764
2348
  agent-switch service run|start|stop|status|install|uninstall background usage daemon
1765
2349
  agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
1766
2350
  agent-switch notify --kind K --title T --message M [--json] record a notification event
@@ -1769,7 +2353,8 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1769
2353
  agent-switch usage history [--provider P] [--profile <name>] [--json] daemon-recorded usage samples per profile
1770
2354
  agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
1771
2355
  agent-switch tooling install|upgrade <tool> install/upgrade an ecosystem tool (agent-config, rtk, claude, codex)
1772
- agent-switch doctor per-OS, per-provider self-check`);
2356
+ agent-switch tooling notes [<tool>] [--latest] [--json] read an ecosystem tool's release notes (default agent-config; fetches from GitHub)
2357
+ agent-switch doctor [--json] per-OS, per-provider self-check`);
1773
2358
  }
1774
2359
  /**
1775
2360
  * The interactive limit dialog (Phase 4) — `agent-switch rebind` with no account
@@ -1842,6 +2427,7 @@ async function rebindDialog(running) {
1842
2427
  }
1843
2428
  try {
1844
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");
1845
2431
  console.log(r.uxNote);
1846
2432
  }
1847
2433
  catch (e) {
@@ -1858,7 +2444,26 @@ async function rebindDialog(running) {
1858
2444
  async function cmdRebind(providerId, providerExplicit, account, flags) {
1859
2445
  if (providerExplicit && providerId !== "claude")
1860
2446
  die(`rebind supports claude only (not ${providerId})`);
1861
- const profile = typeof flags.profile === "string" ? flags.profile : activeFor("claude") ?? undefined;
2447
+ // Resolve which running profile rebind targets: explicit --profile wins; else
2448
+ // prefer the profile that actually has a LIVE session (rebind's whole point is
2449
+ // the running session, which may not be the "active" default-for-new-processes
2450
+ // profile). Only auto-target when exactly one profile is running — a credential
2451
+ // write must never guess across ambiguity — otherwise keep the active fallback.
2452
+ const explicitProfile = typeof flags.profile === "string" ? flags.profile : undefined;
2453
+ let profile = explicitProfile ?? activeFor("claude") ?? undefined;
2454
+ if (!explicitProfile) {
2455
+ const running = detectRunningClaudeProfile(listProfiles("claude"), (p) => configDir("claude", p));
2456
+ if (running.kind === "single") {
2457
+ if (running.profile !== profile) {
2458
+ console.log(`Auto-detected the running claude profile "${running.profile}" (its session is live) — targeting it. Pass --profile to override.`);
2459
+ }
2460
+ profile = running.profile;
2461
+ }
2462
+ else if (running.kind === "ambiguous") {
2463
+ console.log(`Multiple running claude profiles (${running.profiles.join(", ")}) — ` +
2464
+ `${profile ? `using the active profile "${profile}"` : "no active default"}; pass --profile <name> to target a specific one.`);
2465
+ }
2466
+ }
1862
2467
  if (flags.reset) {
1863
2468
  // Kill-switch reset (ADR-003): clear `disabled` + the failure counter so a
1864
2469
  // circuit-breaker trip (or a manual disable) no longer refuses rebind.
@@ -1872,6 +2477,7 @@ async function cmdRebind(providerId, providerExplicit, account, flags) {
1872
2477
  requireProfile("claude", profile, "rebind --restore --profile");
1873
2478
  try {
1874
2479
  const r = await restoreRebind({ profile });
2480
+ logActivity("rebind", `claude/${profile}`, `restored own account (was on ${r.wasBoundTo})`, "user ran `agent-switch rebind --restore`");
1875
2481
  console.log(`Restored "${r.restoredProfile}" to its own account (was rebound to "${r.wasBoundTo}").`);
1876
2482
  }
1877
2483
  catch (e) {
@@ -1894,12 +2500,228 @@ async function cmdRebind(providerId, providerExplicit, account, flags) {
1894
2500
  requireProfile("claude", profile, "rebind --profile");
1895
2501
  try {
1896
2502
  const r = await rebind({ account, profile });
2503
+ logActivity("rebind", `claude/${profile}`, `now serves ${account}'s account`, "user ran `agent-switch rebind`");
1897
2504
  console.log(r.uxNote);
1898
2505
  }
1899
2506
  catch (e) {
1900
2507
  die(e.message);
1901
2508
  }
1902
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
+ }
1903
2725
  // ---------- main -------------------------------------------------------------
1904
2726
  async function main() {
1905
2727
  const argv = process.argv.slice(2);
@@ -1920,19 +2742,29 @@ async function main() {
1920
2742
  const moved = migrateLegacyLayout();
1921
2743
  if (moved.length > 0) {
1922
2744
  // stderr, not stdout: `dir` is machine-consumed by the shell wrapper
1923
- // (`dir="$(agent-switch dir 2>/dev/null)"`), so a status line on stdout
1924
- // would pollute the resolved config path.
2745
+ // (`dir="$(agent-switch dir --provider … --guard)"`), so a status line on
2746
+ // stdout would pollute the resolved config path. Since the single-load
2747
+ // guard needs to speak, the wrapper no longer discards stderr — this
2748
+ // one-time migration note is now visible to the user, which is right.
1925
2749
  console.error(`Migrated ${moved.length} Claude profile(s) to the new layout: ${moved.join(", ")}.`);
1926
2750
  }
1927
2751
  }
1928
2752
  switch (cmd) {
1929
- 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);
1930
2756
  case "import": return cmdImport(providerId, positional[0]);
1931
2757
  case "use": return cmdUse(providerId, positional[0]);
1932
2758
  case "deactivate": return cmdDeactivate(providerId);
1933
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);
1934
2766
  case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags);
1935
- case "reset": return cmdReset(providerId, positional[0]);
2767
+ case "reset": return cmdReset(providerId, positional[0], !!flags.yes);
1936
2768
  case "rename": return cmdRename(providerId, positional[0], positional[1]);
1937
2769
  case "providers": return cmdProviders(providerId, providerExplicit, positional[0], flags);
1938
2770
  case "gui": return cmdGui();
@@ -1947,8 +2779,14 @@ async function main() {
1947
2779
  case "os-notify": return cmdOsNotify(positional[0], !!flags.json);
1948
2780
  case "uninstall": return cmdUninstall(flags);
1949
2781
  case "run": {
1950
- const r = parseRun(rest);
1951
- 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);
1952
2790
  }
1953
2791
  case "handoff": return cmdHandoff(providerId, positional[0], positional.slice(1), flags);
1954
2792
  case "list":
@@ -1956,10 +2794,11 @@ async function main() {
1956
2794
  case "status": return cmdStatus(providerExplicit ? providerId : undefined, positional[0], !!flags.json);
1957
2795
  case "current": return cmdCurrent(providerExplicit ? providerId : undefined);
1958
2796
  case "whoami": return cmdWhoami(providerId, positional[0]);
1959
- case "dir": return cmdDir(providerId);
1960
- case "map": return cmdMap(providerId, positional[0], positional[1]);
2797
+ case "dir": return cmdDir(providerId, !!flags.guard, !!flags.json, !!flags.plain);
2798
+ case "guard": return cmdGuard(positional[0], !!flags.json);
2799
+ case "map": return cmdMap(providerId, positional[0], positional[1], flags);
1961
2800
  case "unmap": return cmdUnmap(providerExplicit ? providerId : undefined, positional[0]);
1962
- case "mappings": return cmdMappings();
2801
+ case "mappings": return cmdMappings(rest.includes("--json"));
1963
2802
  case "share": return cmdShare(positional[0], rest.slice(1));
1964
2803
  case "config": return cmdConfig(positional[0], positional[1], flags);
1965
2804
  case "sessions":
@@ -1977,19 +2816,30 @@ async function main() {
1977
2816
  case "rebind": return cmdRebind(providerId, providerExplicit, positional[0], flags);
1978
2817
  case "web": return cmdWeb(positional[0]);
1979
2818
  case "remove":
1980
- case "rm": return cmdRemove(providerId, positional[0], !!flags.force);
1981
- 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]);
1982
2824
  case "service": return cmdService(positional[0]);
1983
2825
  case "usage": return cmdUsageHistory(providerId, positional[0], flags);
1984
2826
  case "tooling":
1985
2827
  if (positional[0] === "install" || positional[0] === "upgrade") {
1986
2828
  return cmdToolingAction(positional[0], positional[1]);
1987
2829
  }
2830
+ if (positional[0] === "notes")
2831
+ return cmdToolingNotes(positional[1], flags);
1988
2832
  if (positional[0] !== undefined) {
1989
- die(`usage: agent-switch tooling [install|upgrade <${TOOL_IDS.join("|")}>] [--json]`);
2833
+ die(`usage: agent-switch tooling [install|upgrade <${TOOL_IDS.join("|")}>] [notes [<tool>]] [--json]`);
1990
2834
  }
1991
2835
  return cmdTooling(!!flags.json);
1992
- 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);
1993
2843
  case "help":
1994
2844
  case "--help":
1995
2845
  case "-h": return usage();