@event4u/agent-switch 2.1.0 → 2.3.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,60 @@ 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, setSharesDefaultLogin, sharesDefaultLogin, 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, keptFreshByFor, } 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
- import { readHistory, recordHistorySample } from "./history.js";
39
+ import { LATEST_FILE, readHistory, readLatestSnapshot, recordHistorySample, writeLatestSnapshot } 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
- import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
45
+ import { defaultFreshenDeps, usageToken } 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, serviceAfterUpdate, serviceInstall, serviceInstallFromDisk, 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 { copyDesktopSettings, DEFAULT_DATA_DIR, hasSettingsLayout } from "./desktop-settings.js";
60
+ import { createLauncher, findRunning, focusRunning, readLaunchers, removeAllLaunchers, removeLauncher, removeLaunchersFor, renameLaunchersFor, repairLaunchers, } from "./launchers.js";
61
+ import { scanDesktop } from "./desktop-sessions.js";
51
62
  import { ensureAgyKeychain } from "./agy-keychain.js";
52
63
  import { currentManagedSession, currentTmuxSessionName, newSessionArgs, readTmuxRegistry, recordManagedSession, respawnPaneArgs, sendKeysArgs, tmuxAvailable, tmuxSessionName, } from "./tmux.js";
53
64
  // Per-OS credential store (keychain-then-file on darwin, file-only elsewhere).
54
65
  const credentials = credentialStore();
55
66
  const CLAUDE_HOME = provider("claude").defaultConfigDir(); // ~/.claude
56
67
  const CLAUDE_JSON = path.join(HOME, ".claude.json");
68
+ /** Record one of agent-switch's own actions in the activity log (never a token). */
69
+ function logActivity(kind, target, action, reason, ok = true) {
70
+ appendActivity({ kind, target, action, reason, ok });
71
+ }
57
72
  /** Same-provider profiles that carry no live session — the concrete
58
73
  * alternatives a refusal names, so the user can act on it without first
59
74
  * running `list` themselves. */
@@ -95,9 +110,9 @@ function checkSingleLoad(providerId, name, opts = {}) {
95
110
  };
96
111
  }
97
112
  // ---------- launcher ---------------------------------------------------------
98
- function launch(p, name, args) {
113
+ function launch(p, name, args, extraEnv = {}) {
99
114
  const home = configDir(p.id, name);
100
- const env = { ...process.env, [p.envVar]: home };
115
+ const env = { ...process.env, ...extraEnv, [p.envVar]: home };
101
116
  // Antigravity's agy CLI stores its token in the macOS keychain (fixed key) —
102
117
  // seed a per-profile keychain under this HOME so the token is isolated per
103
118
  // account (and macOS doesn't pop its "no keychain found" dialog). No-op off mac.
@@ -115,20 +130,114 @@ function launch(p, name, args) {
115
130
  return res.status ?? 1;
116
131
  }
117
132
  // ---------- commands ---------------------------------------------------------
118
- function cmdAdd(providerId, name) {
133
+ function cmdAdd(providerId, name, browserId) {
119
134
  if (!name)
120
- die(`usage: agent-switch add [--provider ${providerId}] <profile>`);
135
+ die(`usage: agent-switch add [--provider ${providerId}] <profile> [--browser B]`);
121
136
  if (profileExists(providerId, name))
122
137
  die(`profile "${name}" already exists for ${providerId}`);
123
138
  const p = provider(providerId);
139
+ const env = browserId ? browserEnv(browserId, providerId) : {};
124
140
  ensureRoot();
125
- fs.mkdirSync(configDir(providerId, name), { recursive: true, mode: 0o700 });
141
+ const dir = configDir(providerId, name);
142
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
143
+ // A bare `claude` in a fresh config dir opens Claude Code's setup screens, not
144
+ // the sign-in; skip them the way `import` does and run the sign-in itself.
145
+ if (providerId === "claude")
146
+ seedClaudeOnboarding(dir);
126
147
  console.log(`Created ${providerId} profile "${name}".`);
127
- console.log(`Launching ${p.binary} for first login (log in inside if prompted)...`);
128
- launch(p, name, []);
148
+ const loginArgs = LOGIN_ARGS[providerId];
149
+ console.log(loginArgs.length
150
+ ? `Signing it in with \`${[p.binary, ...loginArgs].join(" ")}\` (finish it in the browser if one opens)...`
151
+ : `Launching ${p.binary} for first login (log in inside if prompted)...`);
152
+ const code = launch(p, name, loginArgs, env);
129
153
  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}`}`);
154
+ const after = authStatusFor(providerId, name);
155
+ const ok = !!id || after.state === "connected" || after.state === "expiring";
156
+ logActivity("login", `${providerId}/${name}`, ok ? "signed in (new profile)" : "created, sign-in not confirmed", "user ran `agent-switch add`", ok);
157
+ const flag = providerId === "claude" ? "" : ` --provider ${providerId}`;
158
+ if (!ok && loginArgs.length) {
159
+ 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}`);
160
+ process.exit(1);
161
+ }
162
+ console.log(id ? `Profile "${name}" is signed in as ${id}.` : ok ? `Profile "${name}" is signed in.` : `Profile "${name}" created.`);
163
+ console.log(`Activate it with: agent-switch use ${name}${flag}`);
164
+ }
165
+ /** Mark Claude Code's first-run setup as done in a new profile (theme taken
166
+ * from the default install), so its first start goes straight to work. */
167
+ function seedClaudeOnboarding(dir) {
168
+ const file = path.join(dir, ".claude.json");
169
+ const config = (fs.existsSync(file) && readJson(file)) || {};
170
+ const theme = (fs.existsSync(CLAUDE_JSON) && readJson(CLAUDE_JSON)?.theme) || "dark";
171
+ config.hasCompletedOnboarding = true;
172
+ config.theme = config.theme || theme;
173
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
174
+ }
175
+ /**
176
+ * `$BROWSER` for a sign-in in one specific browser. Only Claude Code is verified
177
+ * to follow it; codex on macOS opens the system default regardless, so it is
178
+ * told rather than silently ignored.
179
+ */
180
+ function browserEnv(browserId, providerId) {
181
+ if (process.platform === "win32")
182
+ die("--browser is not supported on Windows yet.");
183
+ const deps = defaultBrowserDeps(HOME);
184
+ const located = locateBrowser(browserId, deps);
185
+ if (!located) {
186
+ const installed = listBrowsers(deps).map((b) => b.id).join(", ") || "(none found)";
187
+ die(`browser "${browserId}" is not installed. Installed: ${installed}`);
188
+ }
189
+ if (providerId !== "claude") {
190
+ 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.`);
191
+ }
192
+ if (process.platform !== "darwin")
193
+ return { BROWSER: located };
194
+ const wrapper = path.join(ROOT, "browsers", browserId);
195
+ fs.mkdirSync(path.dirname(wrapper), { recursive: true, mode: 0o700 });
196
+ fs.writeFileSync(wrapper, browserWrapperScript(located), { mode: 0o700 });
197
+ fs.chmodSync(wrapper, 0o700);
198
+ return { BROWSER: wrapper };
199
+ }
200
+ function cmdBrowsers(json) {
201
+ const list = listBrowsers(defaultBrowserDeps(HOME));
202
+ if (json)
203
+ return void console.log(JSON.stringify(list, null, 2));
204
+ if (list.length === 0)
205
+ return void console.log("No supported browsers found.");
206
+ for (const b of list)
207
+ console.log(`${b.id.padEnd(16)} ${b.name}`);
208
+ }
209
+ /** The provider's own sign-in command for an existing profile. Antigravity's
210
+ * agy has no separate login command; it prompts when it starts signed out. */
211
+ const LOGIN_ARGS = {
212
+ claude: ["auth", "login"],
213
+ codex: ["login"],
214
+ antigravity: [],
215
+ };
216
+ /**
217
+ * Sign an existing profile in again — `add` refuses a name that exists, and a
218
+ * login the provider cleared (signed out, refresh window ended) needs exactly
219
+ * this. Runs the provider's own sign-in under the profile's config dir, then
220
+ * reads the stored login back: the identity in the config outlives a sign-out,
221
+ * so only the credential proves the sign-in happened.
222
+ */
223
+ function cmdLogin(providerId, name, browserId) {
224
+ const n = requireProfile(providerId, name, "login");
225
+ const p = provider(providerId);
226
+ const env = browserId ? browserEnv(browserId, providerId) : {};
227
+ console.log(`Signing ${providerId}/${n} in with \`${[p.binary, ...LOGIN_ARGS[providerId]].join(" ")}\` (finish it in the browser if one opens)...`);
228
+ const code = launch(p, n, LOGIN_ARGS[providerId], env);
229
+ const after = authStatusFor(providerId, n);
230
+ const ok = after.state === "connected" || after.state === "expiring";
231
+ logActivity("login", `${providerId}/${n}`, ok ? "signed in again" : "sign-in not confirmed", "user ran `agent-switch login`", ok);
232
+ if (!ok) {
233
+ console.error(`${providerId}/${n} is still not signed in (${after.detail})${code !== 0 ? ` — ${p.binary} exited with ${code}` : ""}.`);
234
+ process.exit(1);
235
+ }
236
+ // Its own sign-in ends the lineage it shared with the default install.
237
+ if (sharesDefaultLogin(providerId, n))
238
+ setSharesDefaultLogin(providerId, n, false);
239
+ const id = identity(providerId, n);
240
+ console.log(`${providerId}/${n} is signed in${id ? ` as ${id}` : ""}.`);
132
241
  }
133
242
  /**
134
243
  * Login-free import of the default Claude install into a profile. Takes Claude
@@ -158,6 +267,8 @@ async function importClaude(name) {
158
267
  fs.writeFileSync(path.join(dst, ".credentials.json"), creds, { mode: 0o600 });
159
268
  return creds !== null;
160
269
  }));
270
+ if (seeded)
271
+ setSharesDefaultLogin("claude", name, true);
161
272
  console.log(`Imported default Claude setup into profile "${name}".`);
162
273
  console.log(seeded
163
274
  ? "Credentials were seeded — no re-login needed. Claude Code migrates the seed into\n" +
@@ -185,6 +296,8 @@ function importFileProvider(p, name) {
185
296
  if (path.join(srcDir, file) === p.credentialPath(srcDir))
186
297
  seeded = true;
187
298
  }
299
+ if (seeded)
300
+ setSharesDefaultLogin(p.id, name, true);
188
301
  console.log(`Imported default ${p.binary} setup into profile "${name}".`);
189
302
  console.log(seeded
190
303
  ? `Credential seeded — no re-login needed.\nIMPORTANT: this profile shares one OAuth ` +
@@ -203,7 +316,10 @@ async function cmdImport(providerId, name) {
203
316
  }
204
317
  function cmdUse(providerId, name) {
205
318
  const n = requireProfile(providerId, name, "use");
319
+ if (isDisabled(providerId, n))
320
+ die(`${n} is disabled — turn it back on first: agent-switch enable ${n}${providerId === "claude" ? "" : ` --provider ${providerId}`}`);
206
321
  setActive(providerId, n);
322
+ logActivity("switch", `${providerId}/${n}`, "set as active profile", "user ran `agent-switch use`");
207
323
  const id = identity(providerId, n);
208
324
  console.log(`Active ${providerId} profile: ${n}${id ? ` (${id})` : ""}`);
209
325
  console.log(`New \`${provider(providerId).binary}\` sessions use this profile. Running sessions are unaffected.`);
@@ -223,11 +339,17 @@ function cmdDeactivate(providerId) {
223
339
  return;
224
340
  }
225
341
  setActive(providerId, null);
342
+ logActivity("switch", `${providerId}/${active}`, "deactivated", "user ran `agent-switch deactivate`");
226
343
  console.log(`Deactivated ${providerId} profile "${active}". No ${providerId} profile is active.`);
227
344
  console.log(`New \`${provider(providerId).binary}\` sessions fall back to the default install until you \`use\` one.`);
228
345
  }
229
- function cmdRun(providerId, name, args) {
346
+ function cmdRun(providerId, name, args, cwd) {
230
347
  const n = requireProfile(providerId, name, "run");
348
+ if (cwd !== undefined) {
349
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory())
350
+ die(`no such folder: ${cwd}`);
351
+ process.chdir(cwd);
352
+ }
231
353
  const p = provider(providerId);
232
354
  // `--tmux` and `--force` are agent-switch flags, not passthrough flags — strip
233
355
  // them before the rest reaches the provider binary.
@@ -287,6 +409,8 @@ function cmdList(providerId, json = false) {
287
409
  name: n,
288
410
  identity: identity(pid, n),
289
411
  label: labelFor(pid, n),
412
+ color: colorOf(pid, n),
413
+ disabled: isDisabled(pid, n),
290
414
  active: activeFor(pid) === n,
291
415
  liveSessions: pid === "claude" ? liveSessionPids(configDir("claude", n)).length : 0,
292
416
  })));
@@ -340,7 +464,7 @@ async function claudeSnapshot(name) {
340
464
  if (cached)
341
465
  return cached;
342
466
  }
343
- const token = freshAccessToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")));
467
+ const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name));
344
468
  if (!token)
345
469
  return null;
346
470
  const raw = await fetchUsage(token);
@@ -366,8 +490,11 @@ async function cmdStatus(providerId, name, json = false) {
366
490
  // every displayed profile on each refresh, so recording the fresh snapshot
367
491
  // here (throttled to ~hourly) fills the chart for all profiles without
368
492
  // depending on the background daemon or an "active" profile being set.
369
- if (usage)
493
+ if (usage) {
370
494
  recordHistorySample(path.join(profileDir(pid, active), "usage-history.json"), usage);
495
+ // Unthrottled: `dir` balances on usage only while it is minutes old.
496
+ writeLatestSnapshot(path.join(profileDir(pid, active), LATEST_FILE), usage);
497
+ }
371
498
  const wc = worstLiveContext(pid, active);
372
499
  const context = wc
373
500
  ? { sessionId: wc.sessionId, pct: wc.pct, contextTokens: wc.contextTokens, windowTokens: wc.windowTokens, model: wc.model, confidence: wc.confidence }
@@ -401,9 +528,12 @@ async function cmdStatus(providerId, name, json = false) {
401
528
  console.log(" (no usage readout for this provider — shows own usage only where available)");
402
529
  continue;
403
530
  }
404
- const token = freshAccessToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")));
531
+ const token = usageToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", n));
405
532
  if (!token) {
406
- console.log(" (credential not readable — profile may not have run yet)");
533
+ const auth = authStatusFor("claude", n);
534
+ console.log(auth.state === "sign-in-needed"
535
+ ? ` (signed out — sign in again: agent-switch login ${n})`
536
+ : " (credential not readable — profile may not have run yet)");
407
537
  continue;
408
538
  }
409
539
  const [profileInfo, raw] = await Promise.all([fetchProfile(token), fetchUsage(token)]);
@@ -425,6 +555,61 @@ async function cmdStatus(providerId, name, json = false) {
425
555
  }
426
556
  }
427
557
  }
558
+ /** Cached usage for one profile: the daemon's snapshot or the newest history
559
+ * sample, whichever is newer. Local reads only — `dir` runs on every start. */
560
+ function cachedUsage(providerId, name) {
561
+ const fromDaemonOrHistory = cachedUsageFromDaemonOrHistory(providerId, name);
562
+ // The app's own reads land here unthrottled; take whichever source is newest.
563
+ const latest = readLatestSnapshot(path.join(profileDir(providerId, name), LATEST_FILE));
564
+ return latest && (fromDaemonOrHistory.capturedMs === null || latest.capturedMs > fromDaemonOrHistory.capturedMs) ? latest : fromDaemonOrHistory;
565
+ }
566
+ function cachedUsageFromDaemonOrHistory(providerId, name) {
567
+ const daemonSnap = readDaemonState()?.profiles?.[`${providerId}/${name}`] ?? null;
568
+ const daemonMs = daemonSnap ? Date.parse(daemonSnap.capturedAt) : NaN;
569
+ const hist = readHistory(path.join(profileDir(providerId, name), "usage-history.json"));
570
+ const last = hist.length ? hist[hist.length - 1] : null;
571
+ const histMs = last ? Date.parse(last.at) : NaN;
572
+ if (last && !isNaN(histMs) && (isNaN(daemonMs) || histMs > daemonMs)) {
573
+ // History samples keep no reset time; the daemon's last one still holds
574
+ // while it lies ahead (a window's reset only moves once it has passed).
575
+ const resetOf = (key) => {
576
+ const r = daemonSnap?.windows.find((d) => d.key === key)?.resetsAt ?? null;
577
+ return r && Date.parse(r) > histMs ? r : null;
578
+ };
579
+ const windows = last.windows.map((w) => ({ key: w.key, label: w.key, utilization: w.utilization, resetsAt: resetOf(w.key) }));
580
+ return { snapshot: { windows, routines: null, capturedAt: last.at }, capturedMs: histMs };
581
+ }
582
+ return { snapshot: daemonSnap, capturedMs: isNaN(daemonMs) ? null : daemonMs };
583
+ }
584
+ function tildePath(p) {
585
+ return p === HOME || p.startsWith(HOME + path.sep) ? "~" + p.slice(HOME.length) : p;
586
+ }
587
+ /** Profile names are validated by `profileDir`, which exits on a bad name —
588
+ * a pool ref or a stale mapping must never kill the shell wrapper's hot path. */
589
+ function profileExistsSafe(providerId, name) {
590
+ return /^[A-Za-z0-9_-]+$/.test(name) && profileExists(providerId, name);
591
+ }
592
+ function resolveDir(providerId, plain = false) {
593
+ return resolveForDir({
594
+ providerId,
595
+ mapping: plain ? null : resolveMapping(process.cwd(), providerId),
596
+ names: listProfiles(providerId),
597
+ threshold: (n) => readProtection(providerId, n).thresholdPct,
598
+ active: activeFor(providerId),
599
+ exists: (n) => profileExistsSafe(providerId, n),
600
+ signedIn: (n) => identity(providerId, n) !== null,
601
+ disabled: (n) => isDisabled(providerId, n),
602
+ sharesDefaultLogin: (n) => profileExistsSafe(providerId, n) && sharesDefaultLogin(providerId, n),
603
+ pool: (id) => findPool(id),
604
+ usage: (n) => cachedUsage(providerId, n),
605
+ // Only providers that write a live-session signal can report one; the
606
+ // check runs for pool members only.
607
+ live: (n) => provider(providerId).hasLiveSessionSignal && liveSessionPids(configDir(providerId, n)).length > 0,
608
+ guard: readGuard(),
609
+ now: Date.now(),
610
+ displayPath: tildePath,
611
+ });
612
+ }
428
613
  /**
429
614
  * Resolve which profile a shell wrapper should load: directory mapping (nearest
430
615
  * ancestor of CWD) > active-for-provider. Empty output → the wrapper falls back
@@ -436,14 +621,26 @@ async function cmdStatus(providerId, name, json = false) {
436
621
  * and talk to the human; a distinct exit code is what lets the wrapper block on
437
622
  * a refusal without ever blocking on a missing or older `agent-switch`.
438
623
  */
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
- })();
624
+ function cmdDir(providerId, guarded = false, json = false, plain = false) {
625
+ const r = resolveDir(providerId, plain);
626
+ if (json) {
627
+ console.log(JSON.stringify({
628
+ profile: r.name ? `${providerId}/${r.name}` : null,
629
+ configDir: r.name ? configDir(providerId, r.name) : null,
630
+ why: r.why,
631
+ ...(r.blocked ? { blocked: r.blocked } : {}),
632
+ }, null, 2));
633
+ if (r.blocked)
634
+ process.exit(GUARD_REFUSED_EXIT);
635
+ return;
636
+ }
637
+ // A folder rule that cannot be served refuses — starting on an account
638
+ // outside the rule is the one outcome the rule exists to prevent.
639
+ if (r.blocked) {
640
+ console.error(`agent-switch: not starting here — ${r.blocked}.`);
641
+ process.exit(GUARD_REFUSED_EXIT);
642
+ }
643
+ const name = r.name;
447
644
  if (!name)
448
645
  return;
449
646
  console.log(configDir(providerId, name));
@@ -457,8 +654,28 @@ function cmdDir(providerId, guarded = false) {
457
654
  if (guard.verdict === "refuse")
458
655
  process.exit(GUARD_REFUSED_EXIT);
459
656
  }
460
- function cmdMap(providerId, name, dir) {
461
- const n = requireProfile(providerId, name, `map [--provider ${providerId}] <profile> [dir]`);
657
+ function cmdMap(providerId, name, dir, flags = {}) {
658
+ if (flags.pause || flags.resume) {
659
+ // `map --pause [dir]`: the positional is the folder, there is no profile.
660
+ const target = name ?? process.cwd();
661
+ const on = !!flags.pause;
662
+ if (!setPaused(target, providerId, on))
663
+ die(`no mapping for ${target} (${providerId})`);
664
+ console.log(on
665
+ ? `Paused the ${providerId} rule for ${normalizePath(target)} — it stays listed but no longer picks the account. Resume: agent-switch map --resume ${target}`
666
+ : `Resumed the ${providerId} rule for ${normalizePath(target)}.`);
667
+ return;
668
+ }
669
+ if (name && isPoolRef(name)) {
670
+ const pool = findPool(poolIdOf(name));
671
+ if (!pool)
672
+ die(`no pool "${poolIdOf(name)}" — create it with: agent-switch pool create <name>`);
673
+ const key = setMapping(dir ?? process.cwd(), providerId, `${POOL_PREFIX}${pool.id}`);
674
+ console.log(`Mapped ${key} -> pool ${pool.id} (${providerId})`);
675
+ 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`);
676
+ return;
677
+ }
678
+ const n = requireProfile(providerId, name, `map [--provider ${providerId}] <profile|pool:<id>> [dir]`);
462
679
  const key = setMapping(dir ?? process.cwd(), providerId, n);
463
680
  console.log(`Mapped ${key} -> ${providerId}/${n}`);
464
681
  console.log(`\`${provider(providerId).binary}\` in this directory (and subdirectories) now uses this profile.`);
@@ -470,14 +687,20 @@ function cmdUnmap(providerId, dir) {
470
687
  else
471
688
  die(`no mapping for ${target}${providerId ? ` (${providerId})` : ""}`);
472
689
  }
473
- function cmdMappings() {
690
+ function cmdMappings(json = false) {
474
691
  const rows = mappingRows();
692
+ if (json) {
693
+ console.log(JSON.stringify(rows.map((r) => ({ path: r.path, provider: r.provider, target: r.name, paused: r.paused }))));
694
+ return;
695
+ }
475
696
  if (rows.length === 0) {
476
697
  console.log("No directory mappings. Create one with: agent-switch map <profile> [dir]");
477
698
  return;
478
699
  }
479
- for (const r of rows)
480
- console.log(`${r.path} -> ${r.provider}/${r.name}`);
700
+ for (const r of rows) {
701
+ const line = isPoolRef(r.name) ? `${r.path} -> ${r.name} (${r.provider})` : `${r.path} -> ${r.provider}/${r.name}`;
702
+ console.log(r.paused ? `${line} [paused]` : line);
703
+ }
481
704
  }
482
705
  // share + web are Claude-config concepts and stay Claude-scoped.
483
706
  function cmdShare(mode, flags = []) {
@@ -503,7 +726,9 @@ function cmdShare(mode, flags = []) {
503
726
  const actions = applySharing(source, configDir("claude", p), withHistory);
504
727
  console.log(` ${p}: ${actions.length > 0 ? actions.join(", ") : "up to date"}`);
505
728
  }
506
- console.log("Shared directories (skills/, commands/, agents/) write through the link. Shared files\n" +
729
+ console.log("Prompt history (history.jsonl — what ↑ and Ctrl+R search) is shared too; a profile's own\n" +
730
+ "file was merged in and kept as history.jsonl.pre-share.\n" +
731
+ "Shared directories (skills/, commands/, agents/) write through the link. Shared files\n" +
507
732
  "(settings.json, ...) fork on an in-profile /config edit — run `agent-switch share sync`\n" +
508
733
  "to push a fork back to the source and re-link.");
509
734
  }
@@ -529,7 +754,11 @@ function cmdShare(mode, flags = []) {
529
754
  // flag: sharing is active when any profile carries agent-switch-managed links.
530
755
  const rows = profiles
531
756
  .filter((p) => configDir("claude", p) !== source)
532
- .map((p) => ({ name: p, shared: sharedLinkHealth(configDir("claude", p)).length > 0 }));
757
+ .map((p) => {
758
+ const links = sharedLinkHealth(configDir("claude", p));
759
+ const prompts = links.some((l) => l.name === PROMPT_HISTORY && l.state === "linked");
760
+ return { name: p, shared: links.length > 0, prompts };
761
+ });
533
762
  const active = rows.some((r) => r.shared);
534
763
  if (flags.includes("--json")) {
535
764
  console.log(JSON.stringify({ active, source: sourceName ?? "default", profiles: rows }));
@@ -537,7 +766,7 @@ function cmdShare(mode, flags = []) {
537
766
  }
538
767
  console.log(`share: ${active ? "on" : "off"} (source ${sourceName ?? "default"})`);
539
768
  for (const r of rows)
540
- console.log(` ${r.name}: ${r.shared ? "shared" : "own"}`);
769
+ console.log(` ${r.name}: ${r.shared ? "shared" : "own"}${r.shared && !r.prompts ? " (prompt history not linked — run share on)" : ""}`);
541
770
  }
542
771
  else {
543
772
  die("usage: agent-switch share on|sync|off|status [--history] [--source <profile|default>]");
@@ -822,6 +1051,40 @@ function cmdHookEvent() {
822
1051
  * GUI contract: a flat array, metadata only — transcript content never leaves
823
1052
  * the profile dir (only the guarded first-line header is read).
824
1053
  */
1054
+ /** The regular Desktop app's data dir, then every profile's own instance. */
1055
+ function desktopInstances() {
1056
+ const app = findApp("claude-desktop");
1057
+ const regular = process.platform === "darwin"
1058
+ ? path.join(HOME, "Library", "Application Support", "Claude")
1059
+ : process.platform === "win32"
1060
+ ? path.join(process.env.APPDATA ?? path.join(HOME, "AppData", "Roaming"), "Claude")
1061
+ : path.join(HOME, ".config", "Claude");
1062
+ return [{ profile: null, dir: regular }, ...(app ? listProfiles("claude").map((p) => ({ profile: p, dir: guiDataDir(app, p) })) : [])];
1063
+ }
1064
+ function desktopScan() {
1065
+ const byAccount = new Map();
1066
+ for (const p of listProfiles("claude")) {
1067
+ const uuid = readJson(path.join(configDir("claude", p), ".claude.json"))?.oauthAccount?.accountUuid;
1068
+ if (typeof uuid === "string")
1069
+ byAccount.set(uuid, p);
1070
+ }
1071
+ return scanDesktop({ instances: desktopInstances(), claudeHome: CLAUDE_HOME, profileOf: (u) => byAccount.get(u) ?? null, now: Date.now() });
1072
+ }
1073
+ /** Which accounts ran sessions in each Claude Desktop instance; `mismatch` =
1074
+ * a profile's own instance signed in with another account. */
1075
+ function cmdDesktop(json) {
1076
+ const { instances } = desktopScan();
1077
+ const shown = instances.filter((i) => i.accounts.length > 0);
1078
+ if (json)
1079
+ return void console.log(JSON.stringify(shown, null, 2));
1080
+ if (!shown.length)
1081
+ return void console.log("No Claude Desktop sessions found.");
1082
+ for (const i of shown) {
1083
+ console.log(`${i.profile ? `${i.profile}'s instance` : "Claude Desktop"} (${tildePath(i.dir)})`);
1084
+ for (const a of i.accounts)
1085
+ 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" : ""}`);
1086
+ }
1087
+ }
825
1088
  function cmdSessions(providerId, providerExplicit, name, flags = {}) {
826
1089
  if (providerExplicit && providerId !== "claude" && providerId !== "codex") {
827
1090
  die(`sessions supports claude and codex for now (${providerId} parity is gated on its Phase-0 spike — see scripts/spikes/)`);
@@ -834,6 +1097,8 @@ function cmdSessions(providerId, providerExplicit, name, flags = {}) {
834
1097
  if (profiles.length === 0)
835
1098
  die(`no ${providerId} profiles`);
836
1099
  const all = [];
1100
+ // Claude Desktop's own sessions, filed by account (see desktop-sessions.ts).
1101
+ const desktop = providerId === "claude" ? desktopScan().rows : [];
837
1102
  for (const p of profiles) {
838
1103
  const cfg = configDir(providerId, p);
839
1104
  // Codex stores date-partitioned rollout files (no encoded-cwd dir); its live
@@ -842,7 +1107,24 @@ function cmdSessions(providerId, providerExplicit, name, flags = {}) {
842
1107
  const rows = providerId === "codex" ? listCodexSessions(cfg, limit) : listSessions(cfg, limit);
843
1108
  if (providerId === "claude")
844
1109
  markLive(cfg, rows); // POSIX: pid→cwd→dir match; win32: recent-only
845
- all.push(...rows.map((r) => ({ ...r, profile: p })));
1110
+ if (providerId === "claude") {
1111
+ const mtime = new Map(rows.map((r) => [r.sessionId, r.mtimeMs]));
1112
+ const waiting = waitingSessions(readEvents(eventFile(ROOT, providerId, p)), (id) => mtime.get(id) ?? null);
1113
+ // Hooks report a prompt; an open question also shows in the transcript itself.
1114
+ for (const r of rows) {
1115
+ const why = (r.file ? openQuestion(r.file) : null) ?? waiting.get(r.sessionId) ?? null;
1116
+ if (why)
1117
+ Object.assign(r, { waiting: true, waitingFor: why });
1118
+ }
1119
+ }
1120
+ const own = new Set(rows.map((r) => r.sessionId));
1121
+ for (const d of desktop)
1122
+ if (d.profile === p && !own.has(d.sessionId)) {
1123
+ const q = d.file ? openQuestion(d.file) : null;
1124
+ rows.push(q ? { ...d, waiting: true, waitingFor: q } : d);
1125
+ }
1126
+ rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
1127
+ all.push(...rows.slice(0, limit).map((r) => ({ ...r, profile: p })));
846
1128
  }
847
1129
  if (flags.json) {
848
1130
  console.log(JSON.stringify(all.map((r) => {
@@ -1319,7 +1601,25 @@ function takeoverCodex(sessionId, target, opts) {
1319
1601
  if (!printOnly && process.stdin.isTTY)
1320
1602
  process.exit(launch(provider("codex"), target, resumeArgs));
1321
1603
  }
1322
- function cmdRemove(providerId, name, force = false) {
1604
+ /**
1605
+ * Ask before a destructive or scarce-credit action. `--yes` skips the question.
1606
+ * Off a terminal there is nobody to ask, so the action proceeds as it always
1607
+ * did (scripts and the GUI, which confirms in its own UI, keep working) and a
1608
+ * note on stderr says how to make that explicit.
1609
+ */
1610
+ async function confirmAction(question, yes) {
1611
+ if (yes)
1612
+ return true;
1613
+ if (!process.stdin.isTTY) {
1614
+ console.error("agent-switch: not a terminal — proceeding without confirmation (pass --yes to make this explicit).");
1615
+ return true;
1616
+ }
1617
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1618
+ const answer = await new Promise((resolve) => rl.question(`${question} [y/N] `, resolve));
1619
+ rl.close();
1620
+ return /^y(es)?$/i.test(answer.trim());
1621
+ }
1622
+ async function cmdRemove(providerId, name, force = false, yes = false) {
1323
1623
  const n = requireProfile(providerId, name, "remove");
1324
1624
  if (activeFor(providerId) === n && !force) {
1325
1625
  die(`"${n}" is the active ${providerId} profile. Switch away first or use --force.`);
@@ -1330,15 +1630,54 @@ function cmdRemove(providerId, name, force = false) {
1330
1630
  die(`profile "${n}" has live Claude Code sessions (PIDs ${pids.join(", ")}). Close them or use --force.`);
1331
1631
  }
1332
1632
  }
1633
+ // An active rebind keeps both original credentials in two Keychain stash
1634
+ // entries. Deleting the profile would orphan them — restore first.
1635
+ const binding = providerId === "claude" ? readBinding(configDir("claude", n)) : null;
1636
+ if (binding && !force) {
1637
+ die(`profile "${n}" is rebound to "${binding.boundToProfile}"'s account. Restore it first: ` +
1638
+ `agent-switch rebind --restore --profile ${n} — or use --force to drop the rebind (its stashed credentials are deleted).`);
1639
+ }
1640
+ // The reverse case: another profile is serving THIS profile's account. Its
1641
+ // restore would write into the store of a profile that no longer exists.
1642
+ const borrower = providerId === "claude" ? borrowerOf(n) : null;
1643
+ if (borrower && !force) {
1644
+ die(`profile "${n}"'s account is currently borrowed by "${borrower}" (rebind). Restore it first: ` +
1645
+ `agent-switch rebind --restore --profile ${borrower} — or use --force to restore "${borrower}" and then remove "${n}".`);
1646
+ }
1647
+ const who = identity(providerId, n);
1648
+ if (!(await confirmAction(`Remove ${providerId} profile "${n}"${who ? ` (${who})` : ""} and its login? This cannot be undone.`, yes))) {
1649
+ console.log("Cancelled — nothing removed.");
1650
+ return;
1651
+ }
1333
1652
  // Keychain entries exist only for claude (darwin); codex/antigravity are file-based,
1334
1653
  // so computing a Claude hash for them would be a conceptual no-op — skip it.
1654
+ let restoredBorrower = null;
1655
+ if (borrower) {
1656
+ try {
1657
+ restoredBorrower = await releaseBorrowedAccount(n);
1658
+ }
1659
+ catch (e) {
1660
+ die(`could not restore "${borrower}" first, so "${n}" was not removed: ${e.message}`);
1661
+ }
1662
+ if (restoredBorrower)
1663
+ logActivity("rebind", `claude/${restoredBorrower}`, `restored own account (was on ${n})`, "user ran `agent-switch remove --force`");
1664
+ }
1665
+ const droppedRebind = binding ? discardBinding(n) : false;
1335
1666
  const removedEntry = providerId === "claude" ? credentials.removeEntry(configDir(providerId, n)) : false;
1667
+ removeLaunchersFor(providerId, n); // they would start a profile that no longer exists
1336
1668
  fs.rmSync(profileDir(providerId, n), { recursive: true, force: true });
1337
1669
  if (activeFor(providerId) === n)
1338
1670
  setActive(providerId, null);
1339
1671
  clearLabel(providerId, n); // drop its label so it can't linger as an orphan
1340
1672
  const pruned = pruneMappings(providerId, n);
1673
+ forgetProtection(providerId, n);
1674
+ renamePoolMember(`${providerId}/${n}`, null);
1675
+ logActivity("remove", `${providerId}/${n}`, "profile removed", yes ? "user ran `agent-switch remove --yes`" : "user ran `agent-switch remove`");
1341
1676
  console.log(`Removed ${providerId} profile "${n}".`);
1677
+ if (restoredBorrower)
1678
+ console.log(`Restored "${restoredBorrower}" to its own account first (it was borrowing "${n}").`);
1679
+ if (droppedRebind)
1680
+ console.log(`Dropped its active rebind (stashed credentials deleted; "${binding.boundToProfile}" keeps its own login).`);
1342
1681
  if (removedEntry)
1343
1682
  console.log("Removed its keychain credential entry.");
1344
1683
  if (pruned.length > 0)
@@ -1363,6 +1702,64 @@ function cmdLabel(providerId, name, label) {
1363
1702
  setLabel(providerId, name, label);
1364
1703
  console.log(`Labeled ${providerId}/${name} as ${label}.`);
1365
1704
  }
1705
+ /** Disable keeps a profile (login, history, settings) but takes it out of every
1706
+ * pick: no active slot, no pool or folder rule, no background polling. */
1707
+ /** `mark-shared <name> [--off]`: mark (or unmark) a profile as sharing the
1708
+ * default install's login — for imports made before `import` set the mark. */
1709
+ function cmdMarkShared(providerId, name, off) {
1710
+ const n = requireProfile(providerId, name, "mark-shared");
1711
+ setSharesDefaultLogin(providerId, n, !off);
1712
+ console.log(off
1713
+ ? `${providerId}/${n} no longer counts as sharing the default install's login.`
1714
+ : `${providerId}/${n} now counts as sharing the default install's login: agent-switch never refreshes it and never starts it by balancing. \`agent-switch login ${n}\` gives it its own login.`);
1715
+ }
1716
+ function cmdDisable(providerId, name, disabled) {
1717
+ const n = requireProfile(providerId, name, disabled ? "disable" : "enable");
1718
+ const wasActive = activeFor(providerId) === n;
1719
+ setDisabled(providerId, n, disabled);
1720
+ if (!disabled)
1721
+ return void console.log(`Turned ${providerId}/${n} back on.`);
1722
+ console.log(`Disabled ${providerId}/${n}. Nothing was deleted; \`agent-switch enable ${n}\` turns it back on.`);
1723
+ if (wasActive)
1724
+ console.log(`It was the active ${providerId} profile — new sessions now use the default login until you pick another.`);
1725
+ }
1726
+ /**
1727
+ * Make this CLI the one terminals and the background service use. Each app runs
1728
+ * it on start, so a dev build and an installed app each bring their own CLI:
1729
+ * the shell wrapper reads <home>/active-cli, and a service installed from the
1730
+ * other CLI is re-installed onto this one. An uninstalled service stays so.
1731
+ */
1732
+ function cmdActivate(json) {
1733
+ const cli = fs.realpathSync(fileURLToPath(import.meta.url));
1734
+ ensureRoot();
1735
+ fs.writeFileSync(path.join(ROOT, "active-cli"), `${cli}\n`, { mode: 0o600 });
1736
+ const before = serviceTarget();
1737
+ if (before === "other-cli")
1738
+ serviceInstall(() => { });
1739
+ const service = before === "other-cli" ? "repointed" : before === "this-cli" ? "unchanged" : "not-installed";
1740
+ if (json)
1741
+ return void console.log(JSON.stringify({ cli, service }));
1742
+ console.log(`Terminals now use ${cli}.`);
1743
+ if (service === "repointed")
1744
+ console.log("The background service was moved onto it.");
1745
+ if (service === "not-installed")
1746
+ console.log("The background service isn't installed (agent-switch service install).");
1747
+ }
1748
+ function cmdColor(providerId, name, color) {
1749
+ // Like a label, independent state keyed by provider/name: the GUI picks the
1750
+ // colour before `add` has created the profile.
1751
+ if (!name)
1752
+ die(`usage: agent-switch color <profile> [${PROFILE_COLORS.join("|")}|none] [--provider P]`);
1753
+ if (color === undefined || color === "none" || color === "clear") {
1754
+ setColor(providerId, name, null);
1755
+ console.log(`Cleared the colour of ${providerId}/${name}.`);
1756
+ return;
1757
+ }
1758
+ if (!isProfileColor(color))
1759
+ die(`invalid colour "${color}" (choose: ${PROFILE_COLORS.join(", ")}, or "none" to clear)`);
1760
+ setColor(providerId, name, color);
1761
+ console.log(`Coloured ${providerId}/${name} ${color}.`);
1762
+ }
1366
1763
  /** Enable/disable opt-in near-limit notifications (notify + suggest, never an
1367
1764
  * automatic switch), or show the current setting. */
1368
1765
  /** Rename a profile (move its config dir + carry active/label/mappings). */
@@ -1392,15 +1789,23 @@ function cmdRename(providerId, from, to) {
1392
1789
  if (m.provider === providerId && m.name === n)
1393
1790
  setMapping(m.path, providerId, to);
1394
1791
  }
1792
+ renameProtection(providerId, n, to);
1793
+ renamePoolMember(`${providerId}/${n}`, `${providerId}/${to}`);
1794
+ renameLaunchersFor(providerId, n, to);
1395
1795
  console.log(`Renamed ${providerId} profile "${n}" → "${to}".`);
1396
1796
  }
1397
1797
  /** Manually redeem one banked rate-limit reset for a Codex profile. Consumes a
1398
1798
  * real, scarce credit — the GUI gates this behind a confirmation. */
1399
- async function cmdReset(providerId, name) {
1799
+ async function cmdReset(providerId, name, yes = false) {
1400
1800
  if (providerId !== "codex")
1401
1801
  die("reset is only available for codex (banked rate-limit resets).");
1402
1802
  const n = requireProfile("codex", name, "reset");
1803
+ if (!(await confirmAction(`Redeem one banked rate-limit reset for codex/${n}? Credits are scarce and cannot be returned.`, yes))) {
1804
+ console.log("Cancelled — nothing redeemed.");
1805
+ return;
1806
+ }
1403
1807
  const r = await redeemResetCredit(configDir("codex", n));
1808
+ logActivity("redeem", `codex/${n}`, r.ok ? "redeemed one banked reset" : `redeem failed: ${r.reason ?? "unknown"}`, "user ran `agent-switch reset`", r.ok);
1404
1809
  if (r.ok) {
1405
1810
  console.log(`Redeemed a reset for codex/${n} (windows_reset=${r.windowsReset ?? "?"}).`);
1406
1811
  }
@@ -1441,7 +1846,7 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}) {
1441
1846
  if (cfg.enabled) {
1442
1847
  console.log(`Once the active ${providerId} profile hits the threshold, the daemon NOTIFIES you and SUGGESTS the\n` +
1443
1848
  "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" +
1849
+ "On Codex it also tells you when a banked reset is available — redeeming one stays your call (`agent-switch reset`).\n" +
1445
1850
  "Run `agent-switch service start` so the daemon is watching.");
1446
1851
  }
1447
1852
  return;
@@ -1528,6 +1933,7 @@ function cmdUninstall(flags = {}) {
1528
1933
  console.log(` - ${allProfiles.length} profile(s) and their configs under ${ROOT}`);
1529
1934
  console.log(` - ${claudeProfiles.length} Claude keychain credential entr(y/ies) (macOS)`);
1530
1935
  console.log(" - directory mappings, active-profile state, and the background daemon/service");
1936
+ console.log(" - app launchers it created in ~/Applications/Agent Switch");
1531
1937
  console.log("It does NOT touch your default claude/codex/antigravity installs.");
1532
1938
  console.log("\nRe-run with --force to proceed: agent-switch uninstall --force");
1533
1939
  return;
@@ -1554,8 +1960,12 @@ function cmdUninstall(flags = {}) {
1554
1960
  if (credentials.removeEntry(configDir("claude", n)))
1555
1961
  removedEntries++;
1556
1962
  }
1963
+ // Launchers live in ~/Applications, outside ROOT, and are listed in its manifest.
1964
+ const removedLaunchers = removeAllLaunchers();
1557
1965
  fs.rmSync(ROOT, { recursive: true, force: true });
1558
1966
  console.log(`Removed ${allProfiles.length} profile(s) and all agent-switch data under ${ROOT}.`);
1967
+ if (removedLaunchers > 0)
1968
+ console.log(`Removed ${removedLaunchers} app launcher(s) from ~/Applications/Agent Switch.`);
1559
1969
  if (removedEntries > 0)
1560
1970
  console.log(`Removed ${removedEntries} Claude keychain entr(y/ies).`);
1561
1971
  console.log("Follow-ups (manual, if you set them up):");
@@ -1602,6 +2012,7 @@ function cmdApps(json = false) {
1602
2012
  provider: a.provider,
1603
2013
  strategy: a.strategy,
1604
2014
  installed: isInstalled(a),
2015
+ copiesSettings: hasSettingsLayout(a.id),
1605
2016
  }));
1606
2017
  console.log(JSON.stringify(rows, null, 2));
1607
2018
  return;
@@ -1630,7 +2041,9 @@ function cmdNotify(flags) {
1630
2041
  }
1631
2042
  if (!title && !message)
1632
2043
  die("agent-switch notify needs at least --title or --message");
1633
- const created = appendNotification({ kind: kind, title, message });
2044
+ // --repeat: each occurrence is its own moment (a session waiting again), so
2045
+ // an identical notice is not collapsed into the last one.
2046
+ const created = appendNotification({ kind: kind, title, message }, undefined, undefined, !flags.repeat);
1634
2047
  if (flags.json)
1635
2048
  console.log(JSON.stringify(created));
1636
2049
  }
@@ -1717,7 +2130,35 @@ async function cmdUpdate() {
1717
2130
  console.log(`Updating ${c.current} → ${c.release.tag} via npm…`);
1718
2131
  else
1719
2132
  console.log(`Update check failed (${c.message}); attempting \`npm install\` anyway…`);
1720
- process.exit(selfUpdate());
2133
+ const code = selfUpdate();
2134
+ logActivity("update", null, code === 0 ? "self-update installed" : `self-update failed (exit ${code})`, "user ran `agent-switch update`", code === 0);
2135
+ if (code === 0)
2136
+ await serviceAfterSelfUpdate();
2137
+ process.exit(code);
2138
+ }
2139
+ /** Keeps the usage service on the version just installed. Without it the
2140
+ * balanced start has no fresh usage to weigh. */
2141
+ async function serviceAfterSelfUpdate() {
2142
+ switch (serviceAfterUpdate(serviceTarget(), !!process.stdin.isTTY)) {
2143
+ case "reinstall":
2144
+ console.log("Restarting the background service on the new version…");
2145
+ if (serviceInstallFromDisk() !== 0)
2146
+ console.error("Couldn't restart the service — run `agent-switch service install`.");
2147
+ return;
2148
+ case "ask":
2149
+ if (await confirmAction("Install the background service that keeps usage fresh (starts at login)?", false)) {
2150
+ if (serviceInstallFromDisk() !== 0)
2151
+ console.error("Couldn't install the service — run `agent-switch service install`.");
2152
+ }
2153
+ else
2154
+ console.log("Skipped. Install it any time with `agent-switch service install`.");
2155
+ return;
2156
+ case "hint":
2157
+ console.log("Tip: `agent-switch service install` keeps usage fresh so starts can be balanced.");
2158
+ return;
2159
+ case "leave":
2160
+ console.log("The background service runs another agent-switch build — left as it is.");
2161
+ }
1721
2162
  }
1722
2163
  function cmdOpen(appId, name) {
1723
2164
  if (!appId)
@@ -1733,6 +2174,12 @@ function cmdOpen(appId, name) {
1733
2174
  requireProfile(app.provider, n, "open");
1734
2175
  if (!isInstalled(app))
1735
2176
  die(`${app.displayName} is not installed.`);
2177
+ // A second launch on the same data dir would only hit the app's single-instance
2178
+ // lock — bring the window that profile already has to the front instead.
2179
+ if (app.strategy !== "env" && focusRunning(guiDataDir(app, n))) {
2180
+ console.log(`${app.displayName} on ${app.provider}/${n} is already open — brought it to the front.`);
2181
+ return;
2182
+ }
1736
2183
  let firstLaunch = false;
1737
2184
  if (app.strategy === "user-data-dir") {
1738
2185
  const dir = guiDataDir(app, n);
@@ -1747,6 +2194,95 @@ function cmdOpen(appId, name) {
1747
2194
  console.log("is saved to this profile. Quit other windows of this app before signing in.");
1748
2195
  }
1749
2196
  }
2197
+ /** `agent-switch launcher add|remove <app> <profile>` · `list [--json]` · `repair`. */
2198
+ function cmdLauncher(sub, args, flags) {
2199
+ const target = (verb) => {
2200
+ const app = findApp(args[0] ?? "");
2201
+ if (!app)
2202
+ die(`usage: agent-switch launcher ${verb} <app> <profile> (see \`agent-switch apps\`)`);
2203
+ const n = requireProfile(app.provider, args[1], `launcher ${verb}`);
2204
+ return { app, n };
2205
+ };
2206
+ switch (sub) {
2207
+ case "add": {
2208
+ const { app, n } = target("add");
2209
+ try {
2210
+ const e = createLauncher(app, n);
2211
+ logActivity("launcher", `${app.provider}/${n}`, `created ${app.displayName} launcher`, "user ran `agent-switch launcher add`");
2212
+ console.log(`Created ${e.path}`);
2213
+ console.log("Start it from Spotlight or drag it into the Dock. It opens the app on this account only.");
2214
+ }
2215
+ catch (err) {
2216
+ die(err instanceof Error ? err.message : String(err));
2217
+ }
2218
+ return;
2219
+ }
2220
+ case "remove":
2221
+ case "rm": {
2222
+ const { app, n } = target("remove");
2223
+ if (!removeLauncher(app.id, n))
2224
+ die(`no ${app.displayName} launcher for ${app.provider}/${n}.`);
2225
+ logActivity("launcher", `${app.provider}/${n}`, `removed ${app.displayName} launcher`, "user ran `agent-switch launcher remove`");
2226
+ console.log(`Removed the ${app.displayName} launcher for ${app.provider}/${n}.`);
2227
+ return;
2228
+ }
2229
+ case "repair": {
2230
+ const r = repairLaunchers();
2231
+ for (const e of r.rebuilt)
2232
+ console.log(`Rebuilt ${e.path}`);
2233
+ for (const f of r.failed)
2234
+ console.error(`Couldn't rebuild ${f.entry.path}: ${f.reason}`);
2235
+ if (!r.rebuilt.length && !r.failed.length)
2236
+ console.log("No launchers to repair.");
2237
+ if (r.failed.length)
2238
+ process.exit(1);
2239
+ return;
2240
+ }
2241
+ case "list":
2242
+ case undefined: {
2243
+ const rows = readLaunchers().map((e) => ({ ...e, exists: fs.existsSync(e.path) }));
2244
+ if (flags.json)
2245
+ return void console.log(JSON.stringify(rows, null, 2));
2246
+ if (!rows.length)
2247
+ return void console.log("No launchers. Create one with `agent-switch launcher add <app> <profile>`.");
2248
+ for (const r of rows)
2249
+ console.log(`${r.exists ? "●" : "○ missing"} ${r.app.padEnd(16)} ${r.provider}/${r.profile} ${r.path}`);
2250
+ return;
2251
+ }
2252
+ default:
2253
+ die("usage: agent-switch launcher add|remove <app> <profile> | list [--json] | repair");
2254
+ }
2255
+ }
2256
+ /** `agent-switch app-settings copy <app> --from <profile|default> --to <profile>`. */
2257
+ function cmdAppSettings(sub, args, flags) {
2258
+ const app = findApp(args[0] ?? "");
2259
+ const from = typeof flags.from === "string" ? flags.from : null;
2260
+ const to = typeof flags.to === "string" ? flags.to : null;
2261
+ if (sub !== "copy" || !app || !from || !to)
2262
+ die("usage: agent-switch app-settings copy <app> --from <profile|default> --to <profile>");
2263
+ if (!hasSettingsLayout(app.id))
2264
+ die(`copying settings is not supported for ${app.displayName}.`);
2265
+ if (from === to)
2266
+ die("--from and --to are the same profile.");
2267
+ const toProfile = requireProfile(app.provider, to, "app-settings copy");
2268
+ const fromDir = from === "default" ? DEFAULT_DATA_DIR[app.id] : guiDataDir(app, requireProfile(app.provider, from, "app-settings copy"));
2269
+ const toDir = guiDataDir(app, toProfile);
2270
+ if (!fs.existsSync(fromDir))
2271
+ die(`${from === "default" ? `The default ${app.displayName}` : `${app.provider}/${from}`} has no ${app.displayName} data yet.`);
2272
+ // The running app rewrites its own files on quit and would undo the copy.
2273
+ if (findRunning(toDir))
2274
+ die(`${app.displayName} is open on ${app.provider}/${toProfile} — quit it first, then copy.`);
2275
+ const r = copyDesktopSettings(app.id, fromDir, toDir);
2276
+ logActivity("settings", `${app.provider}/${toProfile}`, `copied ${app.displayName} settings from ${from}`, "user ran `agent-switch app-settings copy`");
2277
+ if (flags.json)
2278
+ return void console.log(JSON.stringify({ app: app.id, from, to: toProfile, ...r }, null, 2));
2279
+ if (!r.copied.length)
2280
+ return void console.log(`Nothing to copy — ${from} has no ${app.displayName} settings yet.`);
2281
+ console.log(`Copied ${r.copied.join(", ")} from ${from} to ${app.provider}/${toProfile}.`);
2282
+ if (r.backups.length)
2283
+ console.log(`Previous files kept as ${r.backups.map((b) => path.basename(b)).join(", ")}.`);
2284
+ console.log("Logins, chats and account-bound preferences were not copied.");
2285
+ }
1750
2286
  /**
1751
2287
  * `agent-switch guard [block|warn|off]` — read or set the single-load policy.
1752
2288
  * The bare form also reports what is loaded RIGHT NOW, because "what is my
@@ -1758,6 +2294,7 @@ function cmdGuard(value, json = false) {
1758
2294
  die(`unknown guard policy "${value}" (choose: ${GUARD_POLICIES.join(", ")})`);
1759
2295
  }
1760
2296
  setGuard(value);
2297
+ logActivity("guard", null, `set ${value}`, "user ran `agent-switch guard`");
1761
2298
  }
1762
2299
  const policy = readGuard();
1763
2300
  // Every profile of every provider that can report a live session.
@@ -1804,8 +2341,17 @@ function cmdGuard(value, json = false) {
1804
2341
  console.log(`Override once: ${OVERRIDE_ENV}=1 <binary> · Change: agent-switch guard <${GUARD_POLICIES.join("|")}>`);
1805
2342
  }
1806
2343
  }
1807
- function cmdShellenv(shellArg) {
1808
- console.log(shellenvScript(detectShell(shellArg)));
2344
+ function cmdShellenv(shellArg, sub, json = false) {
2345
+ if (sub !== "install")
2346
+ return void console.log(shellenvScript(detectShell(shellArg)));
2347
+ const shell = detectShell(shellArg);
2348
+ const present = shellIntegrationCheck(HOME).status === "ok";
2349
+ const r = installShellIntegration(shell, HOME, present);
2350
+ if (json)
2351
+ return void console.log(JSON.stringify(r));
2352
+ console.log(r.alreadyInstalled
2353
+ ? "The shell integration is already installed."
2354
+ : `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
2355
  }
1810
2356
  /** `agent-switch tooling` — ecosystem tool readout (agent-config, rtk, provider
1811
2357
  * CLIs). `--json` is the GUI IPC contract: the GUI renders this readout and
@@ -1922,8 +2468,11 @@ function usage() {
1922
2468
  Provider defaults to claude; pass --provider codex|antigravity for the others.
1923
2469
  Run with no command to launch the tray/menubar GUI (single-instance).
1924
2470
 
1925
- agent-switch add [--provider P] <name> create a profile and log it in
2471
+ agent-switch add [--provider P] <name> [--browser B] create a profile and sign it in
2472
+ 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
2473
+ agent-switch browsers [--json] browsers --browser can open (macOS / Linux)
1926
2474
  agent-switch import [--provider P] <name> migrate the default install (no re-login)
2475
+ agent-switch mark-shared [--provider P] <name> [--off] mark a profile as sharing the default install's login (never refreshed or balanced)
1927
2476
  agent-switch use [--provider P] <name> set the active profile for a provider
1928
2477
  agent-switch deactivate [--provider P] clear the active profile for a provider
1929
2478
  agent-switch run [--provider P] <name> [--tmux] [..] launch the provider's CLI (--tmux = managed tmux session, POSIX)
@@ -1931,9 +2480,10 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1931
2480
  agent-switch status [--provider P] [name] [--json] identity (+ Claude usage); --json = active only
1932
2481
  agent-switch current [--provider P] show the active profile(s)
1933
2482
  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
2483
+ agent-switch dir [--provider P] [--guard] [--json] [--plain] resolve profile for CWD (rule > balanced pick > active); --json adds why
2484
+ agent-switch guard [block|warn|off] one profile, one live session (default warn on new installs)
2485
+ agent-switch map [--provider P] <name|pool:<id>> [dir] map a directory to a profile or a pool
2486
+ agent-switch map --pause|--resume [--provider P] [dir] pause a folder rule (kept, but skipped) or turn it back on
1937
2487
  agent-switch unmap [--provider P] [dir] remove a directory mapping
1938
2488
  agent-switch mappings list directory mappings
1939
2489
  agent-switch share on|sync|off|status [--history] [--source <profile|default>] [--json] (Claude)
@@ -1945,18 +2495,29 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1945
2495
  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
2496
  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
2497
  agent-switch web <name> claude.ai in a persistent browser (Claude)
1948
- agent-switch remove [--provider P] <name> [--force] delete a profile
2498
+ agent-switch remove [--provider P] <name> [--force] [--yes] delete a profile (asks on a terminal; --yes skips)
1949
2499
  agent-switch label [--provider P] <name> [Work|Personal|Other|none] tag a profile
2500
+ agent-switch desktop [--json] which accounts ran sessions in each Claude Desktop instance
2501
+ agent-switch activate [--json] make this CLI the one terminals and the background service use
2502
+ agent-switch disable|enable [--provider P] <name> keep a profile but stop using it / turn it back on
2503
+ agent-switch color [--provider P] <name> [orange|red|yellow|green|teal|blue|purple|pink|none] pick a profile's colour
1950
2504
  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
2505
+ agent-switch reset <profile> --provider codex [--yes] redeem one banked Codex rate-limit reset (asks on a terminal)
2506
+ agent-switch protection [--json] | protection set <provider/name> [--on|--off] [--threshold N] per-profile limit warning (default on, 98%)
2507
+ agent-switch pool [list|create|rename|delete|add|remove|suggest] [--json] optional groups a folder rule can point at
2508
+ agent-switch auth-status [--provider P] [--json] per-profile login state from the provider's store (no tokens)
2509
+ agent-switch activity [--limit N] [--json] what agent-switch itself did, and why
1952
2510
  agent-switch rename <old> <new> [--provider P] rename a profile (name & keep its tag)
1953
2511
  agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] enable/disable a provider (default: Claude + Codex)
1954
2512
  agent-switch [gui] launch the tray/menubar GUI (the default with no command; won't start a second if one is running)
1955
2513
  agent-switch check-update [--json] check GitHub Releases for a newer version
1956
- agent-switch update self-update to the latest version (via npm)
2514
+ agent-switch update self-update to the latest version (via npm); restarts the service on it, or offers to install one
1957
2515
  agent-switch apps list launchable GUI apps (macOS)
1958
- agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
2516
+ agent-switch open <app> [profile] launch a GUI app on a profile, isolated; focuses it if already open (macOS)
2517
+ agent-switch launcher add|remove <app> <profile> | list [--json] | repair Dock/Spotlight launcher per account in ~/Applications/Agent Switch (macOS)
2518
+ agent-switch app-settings copy <app> --from <profile|default> --to <profile> [--json] copy desktop settings (MCP, preferences, extensions) — never logins
1959
2519
  agent-switch shellenv [--shell zsh|bash|fish|powershell] shell integration
2520
+ agent-switch shellenv install [--shell S] [--json] add it to your shell's startup file
1960
2521
  agent-switch service run|start|stop|status|install|uninstall background usage daemon
1961
2522
  agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
1962
2523
  agent-switch notify --kind K --title T --message M [--json] record a notification event
@@ -1966,7 +2527,7 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1966
2527
  agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
1967
2528
  agent-switch tooling install|upgrade <tool> install/upgrade an ecosystem tool (agent-config, rtk, claude, codex)
1968
2529
  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`);
2530
+ agent-switch doctor [--json] per-OS, per-provider self-check`);
1970
2531
  }
1971
2532
  /**
1972
2533
  * The interactive limit dialog (Phase 4) — `agent-switch rebind` with no account
@@ -2039,6 +2600,7 @@ async function rebindDialog(running) {
2039
2600
  }
2040
2601
  try {
2041
2602
  const r = await rebind({ account: choice.profile, profile: running });
2603
+ logActivity("rebind", `claude/${running}`, `now serves ${choice.profile}'s account`, "user picked it in the rebind dialog");
2042
2604
  console.log(r.uxNote);
2043
2605
  }
2044
2606
  catch (e) {
@@ -2088,6 +2650,7 @@ async function cmdRebind(providerId, providerExplicit, account, flags) {
2088
2650
  requireProfile("claude", profile, "rebind --restore --profile");
2089
2651
  try {
2090
2652
  const r = await restoreRebind({ profile });
2653
+ logActivity("rebind", `claude/${profile}`, `restored own account (was on ${r.wasBoundTo})`, "user ran `agent-switch rebind --restore`");
2091
2654
  console.log(`Restored "${r.restoredProfile}" to its own account (was rebound to "${r.wasBoundTo}").`);
2092
2655
  }
2093
2656
  catch (e) {
@@ -2110,12 +2673,233 @@ async function cmdRebind(providerId, providerExplicit, account, flags) {
2110
2673
  requireProfile("claude", profile, "rebind --profile");
2111
2674
  try {
2112
2675
  const r = await rebind({ account, profile });
2676
+ logActivity("rebind", `claude/${profile}`, `now serves ${account}'s account`, "user ran `agent-switch rebind`");
2113
2677
  console.log(r.uxNote);
2114
2678
  }
2115
2679
  catch (e) {
2116
2680
  die(e.message);
2117
2681
  }
2118
2682
  }
2683
+ // ---------- gui-settings / activity / auth-status / protection / pool ----------
2684
+ /** `gui-settings [--json]` · `gui-settings set pet-welcomed` ·
2685
+ * `gui-settings set autostart enabled|disabled --by first-run|user` ·
2686
+ * `gui-settings set autostart-degraded --reason <text>|none`. The GUI's
2687
+ * file-backed preferences (see gui-settings.ts); prints the result as JSON. */
2688
+ function cmdGuiSettings(sub, args, flags) {
2689
+ 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";
2690
+ if (sub === undefined || sub === "get" || sub === "status") {
2691
+ console.log(JSON.stringify(readGuiSettings(), null, 2));
2692
+ return;
2693
+ }
2694
+ if (sub !== "set")
2695
+ die(usageLine);
2696
+ const [key, value] = args;
2697
+ let u;
2698
+ if (key === "pet-welcomed")
2699
+ u = { key };
2700
+ else if (key === "autostart" && (value === "enabled" || value === "disabled")) {
2701
+ const by = flags.by === "first-run" || flags.by === "user" ? flags.by : die(usageLine);
2702
+ u = { key, state: value, by };
2703
+ }
2704
+ else if (key === "autostart-degraded") {
2705
+ const reason = typeof flags.reason === "string" ? flags.reason : die(usageLine);
2706
+ u = { key, reason: reason === "none" ? null : reason };
2707
+ }
2708
+ else
2709
+ die(usageLine);
2710
+ const before = readGuiSettings();
2711
+ let after;
2712
+ try {
2713
+ after = applyGuiSetting(before, u);
2714
+ }
2715
+ catch (e) {
2716
+ die(e.message);
2717
+ }
2718
+ writeGuiSettings(after);
2719
+ if (u.key === "autostart" && JSON.stringify(after.autostart) !== JSON.stringify(before.autostart)) {
2720
+ 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");
2721
+ }
2722
+ if (u.key === "autostart-degraded" && u.reason) {
2723
+ logActivity("autostart", null, "start at login could not be confirmed", "first-run default; retried next launch", false);
2724
+ }
2725
+ console.log(JSON.stringify(after, null, 2));
2726
+ }
2727
+ function cmdActivity(flags) {
2728
+ const limit = typeof flags.limit === "string" ? Number(flags.limit) : undefined;
2729
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1))
2730
+ die("--limit must be a whole number of at least 1");
2731
+ const entries = readActivity(limit ?? (flags.json ? undefined : 50));
2732
+ if (flags.json) {
2733
+ console.log(JSON.stringify({ entries, capacity: ACTIVITY_MAX_LINES }, null, 2));
2734
+ return;
2735
+ }
2736
+ if (entries.length === 0) {
2737
+ console.log("No activity recorded yet.");
2738
+ return;
2739
+ }
2740
+ for (const e of entries) {
2741
+ console.log(`${e.at} ${e.ok ? " " : "!"} ${e.kind.padEnd(10)} ${e.target ?? "-"} ${e.action} — ${e.reason}`);
2742
+ }
2743
+ }
2744
+ function authStatusFor(providerId, name) {
2745
+ const s = rawAuthStatusFor(providerId, name);
2746
+ const keptFreshBy = keptFreshByFor(providerId, { shared: sharesDefaultLogin(providerId, name), serviceInstalled: serviceTarget() !== "not-installed" }, s.keptFreshBy);
2747
+ return { ...s, keptFreshBy };
2748
+ }
2749
+ function rawAuthStatusFor(providerId, name) {
2750
+ const now = Date.now();
2751
+ const cfg = provider(providerId).configDirFor(configDir(providerId, name));
2752
+ if (providerId === "claude") {
2753
+ const file = claudeCredentialFile(cfg);
2754
+ const fromFile = readText(file);
2755
+ return claudeAuthStatus(name, credentials.read(cfg), fromFile !== null ? fileMtimeMs(file) : null, now);
2756
+ }
2757
+ if (providerId === "codex")
2758
+ return codexAuthStatus(name, readText(provider("codex").credentialPath(cfg)), now);
2759
+ return antigravityAuthStatus(name, fs.existsSync(provider("antigravity").credentialPath(cfg)));
2760
+ }
2761
+ function cmdAuthStatus(providerId, json) {
2762
+ const rows = (providerId ? listProfiles(providerId).map((n) => ({ provider: providerId, name: n })) : listAllProfiles()).map((r) => authStatusFor(r.provider, r.name));
2763
+ if (json) {
2764
+ console.log(JSON.stringify(rows, null, 2));
2765
+ return;
2766
+ }
2767
+ if (rows.length === 0)
2768
+ return void console.log("No profiles.");
2769
+ for (const r of rows) {
2770
+ const exp = r.expiresAt ? `, ends ${r.expiresAt}` : "";
2771
+ console.log(`${`${r.provider}/${r.name}`.padEnd(24)} ${r.state}${exp} — ${r.detail}`);
2772
+ }
2773
+ }
2774
+ function parseMemberArg(v, usageLine) {
2775
+ const m = v ? parseMember(v) : null;
2776
+ if (!m)
2777
+ die(usageLine);
2778
+ return m;
2779
+ }
2780
+ function cmdProtection(sub, args, flags) {
2781
+ const usageLine = "usage: agent-switch protection [--json] | protection set <provider/name> [--on|--off] [--threshold <1-100>]";
2782
+ if (sub === undefined || sub === "status" || sub === "list") {
2783
+ const rows = listAllProfiles().map((p) => ({ profile: `${p.provider}/${p.name}`, ...readProtection(p.provider, p.name) }));
2784
+ if (flags.json)
2785
+ return void console.log(JSON.stringify(rows, null, 2));
2786
+ if (rows.length === 0)
2787
+ return void console.log("No profiles.");
2788
+ for (const r of rows)
2789
+ console.log(`${r.profile.padEnd(24)} ${r.enabled ? "on " : "off"} at ${r.thresholdPct}%`);
2790
+ console.log("\nA warning when a profile reaches its threshold — nothing is paused or switched.");
2791
+ return;
2792
+ }
2793
+ if (sub !== "set")
2794
+ die(usageLine);
2795
+ const m = parseMemberArg(args[0], usageLine);
2796
+ requireProfile(m.provider, m.name, "protection set");
2797
+ if (flags.on && flags.off)
2798
+ die("pass either --on or --off, not both");
2799
+ const threshold = typeof flags.threshold === "string" ? Number(flags.threshold) : undefined;
2800
+ if (threshold !== undefined && !(Number.isInteger(threshold) && threshold >= 1 && threshold <= 100))
2801
+ die("--threshold must be a whole number between 1 and 100");
2802
+ if (!flags.on && !flags.off && threshold === undefined)
2803
+ die(usageLine);
2804
+ const next = setProtection(m.provider, m.name, {
2805
+ ...(flags.on ? { enabled: true } : {}),
2806
+ ...(flags.off ? { enabled: false } : {}),
2807
+ ...(threshold !== undefined ? { thresholdPct: threshold } : {}),
2808
+ });
2809
+ logActivity("protection", `${m.provider}/${m.name}`, `${next.enabled ? "on" : "off"} at ${next.thresholdPct}%`, "user ran `agent-switch protection set`");
2810
+ if (flags.json)
2811
+ return void console.log(JSON.stringify({ profile: `${m.provider}/${m.name}`, ...next }, null, 2));
2812
+ console.log(`Session protection for ${m.provider}/${m.name}: ${next.enabled ? "on" : "off"} at ${next.thresholdPct}%.`);
2813
+ }
2814
+ function poolMappings(id) {
2815
+ return mappingRows().filter((r) => r.name === `${POOL_PREFIX}${id}`).map((r) => `${r.path} (${r.provider})`);
2816
+ }
2817
+ function cmdPool(sub, args, flags) {
2818
+ 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]";
2819
+ const json = !!flags.json;
2820
+ const out = (v, text) => console.log(json ? JSON.stringify(v, null, 2) : text);
2821
+ try {
2822
+ switch (sub) {
2823
+ case undefined:
2824
+ case "list": {
2825
+ const pools = readPools().map((p) => ({ ...p, rules: poolMappings(p.id) }));
2826
+ if (json)
2827
+ return void console.log(JSON.stringify(pools, null, 2));
2828
+ if (pools.length === 0)
2829
+ return void console.log("No pools. Pools are optional — create one with: agent-switch pool create <name>");
2830
+ for (const p of pools) {
2831
+ console.log(`${p.id} "${p.name}" ${p.members.length ? p.members.join(", ") : "(no members)"}${p.rules.length ? ` · folders: ${p.rules.join(", ")}` : ""}`);
2832
+ }
2833
+ return;
2834
+ }
2835
+ case "create": {
2836
+ if (!args[0])
2837
+ die(usageLine);
2838
+ const p = createPool(args.join(" "));
2839
+ logActivity("pool", p.id, "created", "user ran `agent-switch pool create`");
2840
+ return out(p, `Created pool ${p.id} ("${p.name}"). Add members: agent-switch pool add ${p.id} <provider/name>`);
2841
+ }
2842
+ case "rename": {
2843
+ if (!args[0] || !args[1])
2844
+ die(usageLine);
2845
+ const p = renamePool(args[0], args.slice(1).join(" "));
2846
+ logActivity("pool", p.id, `renamed to "${p.name}"`, "user ran `agent-switch pool rename`");
2847
+ return out(p, `Pool ${p.id} is now called "${p.name}" (its id and folder rules are unchanged).`);
2848
+ }
2849
+ case "delete": {
2850
+ if (!args[0])
2851
+ die(usageLine);
2852
+ const rules = poolMappings(args[0]);
2853
+ if (rules.length > 0)
2854
+ die(`pool "${args[0]}" is used by folder rules: ${rules.join(", ")}. Remove them first (agent-switch unmap <dir>).`);
2855
+ deletePool(args[0]);
2856
+ logActivity("pool", args[0], "deleted", "user ran `agent-switch pool delete`");
2857
+ return out({ deleted: args[0] }, `Deleted pool ${args[0]}. Its profiles are untouched.`);
2858
+ }
2859
+ case "add":
2860
+ case "remove": {
2861
+ if (!args[0])
2862
+ die(usageLine);
2863
+ const m = parseMemberArg(args[1], usageLine);
2864
+ if (sub === "add")
2865
+ requireProfile(m.provider, m.name, "pool add");
2866
+ const key = `${m.provider}/${m.name}`;
2867
+ const p = sub === "add" ? addPoolMember(args[0], key) : removePoolMember(args[0], key);
2868
+ logActivity("pool", p.id, `${sub === "add" ? "added" : "removed"} ${key}`, `user ran \`agent-switch pool ${sub}\``);
2869
+ return out(p, `${sub === "add" ? "Added" : "Removed"} ${key} ${sub === "add" ? "to" : "from"} pool ${p.id}.`);
2870
+ }
2871
+ case "suggest": {
2872
+ const proposals = suggestPoolsFromLabels();
2873
+ if (!flags.confirm) {
2874
+ if (json)
2875
+ return void console.log(JSON.stringify({ preview: true, proposals }, null, 2));
2876
+ if (proposals.length === 0)
2877
+ return void console.log("No labelled profiles — nothing to suggest.");
2878
+ console.log("Preview — nothing is created until you run `agent-switch pool suggest --confirm`:");
2879
+ for (const p of proposals)
2880
+ console.log(` ${p.id} ${p.members.join(", ")}${p.exists ? " (exists — skipped)" : ""}`);
2881
+ return;
2882
+ }
2883
+ const created = [];
2884
+ for (const prop of proposals) {
2885
+ if (prop.exists)
2886
+ continue;
2887
+ const pool = createPool(prop.name);
2888
+ for (const m of prop.members)
2889
+ addPoolMember(pool.id, m);
2890
+ created.push(pool.id);
2891
+ logActivity("pool", pool.id, `created from label ${prop.name}`, "user confirmed `agent-switch pool suggest`");
2892
+ }
2893
+ return out({ created }, created.length ? `Created pools: ${created.join(", ")}.` : "Nothing to create.");
2894
+ }
2895
+ default:
2896
+ die(usageLine);
2897
+ }
2898
+ }
2899
+ catch (e) {
2900
+ die(e.message);
2901
+ }
2902
+ }
2119
2903
  // ---------- main -------------------------------------------------------------
2120
2904
  async function main() {
2121
2905
  const argv = process.argv.slice(2);
@@ -2144,13 +2928,22 @@ async function main() {
2144
2928
  }
2145
2929
  }
2146
2930
  switch (cmd) {
2147
- case "add": return cmdAdd(providerId, positional[0]);
2931
+ case "add": return cmdAdd(providerId, positional[0], typeof flags.browser === "string" ? flags.browser : undefined);
2932
+ case "login": return cmdLogin(providerId, positional[0], typeof flags.browser === "string" ? flags.browser : undefined);
2933
+ case "browsers": return cmdBrowsers(!!flags.json);
2148
2934
  case "import": return cmdImport(providerId, positional[0]);
2149
2935
  case "use": return cmdUse(providerId, positional[0]);
2150
2936
  case "deactivate": return cmdDeactivate(providerId);
2151
2937
  case "label": return cmdLabel(providerId, positional[0], positional[1]);
2938
+ case "color":
2939
+ case "colour": return cmdColor(providerId, positional[0], positional[1]);
2940
+ case "activate": return cmdActivate(!!flags.json);
2941
+ case "desktop": return cmdDesktop(!!flags.json);
2942
+ case "disable": return cmdDisable(providerId, positional[0], true);
2943
+ case "enable": return cmdDisable(providerId, positional[0], false);
2944
+ case "mark-shared": return cmdMarkShared(providerId, positional[0], !!flags.off);
2152
2945
  case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags);
2153
- case "reset": return cmdReset(providerId, positional[0]);
2946
+ case "reset": return cmdReset(providerId, positional[0], !!flags.yes);
2154
2947
  case "rename": return cmdRename(providerId, positional[0], positional[1]);
2155
2948
  case "providers": return cmdProviders(providerId, providerExplicit, positional[0], flags);
2156
2949
  case "gui": return cmdGui();
@@ -2159,14 +2952,23 @@ async function main() {
2159
2952
  case "check-update":
2160
2953
  case "check-updates": return cmdCheckUpdate(!!flags.json);
2161
2954
  case "open": return cmdOpen(positional[0], positional[1]);
2955
+ case "launcher":
2956
+ case "launchers": return cmdLauncher(positional[0], positional.slice(1), flags);
2957
+ case "app-settings": return cmdAppSettings(positional[0], positional.slice(1), flags);
2162
2958
  case "apps": return cmdApps(!!flags.json);
2163
2959
  case "notify": return cmdNotify(flags);
2164
2960
  case "notifications": return cmdNotifications(positional[0], !!flags.json);
2165
2961
  case "os-notify": return cmdOsNotify(positional[0], !!flags.json);
2166
2962
  case "uninstall": return cmdUninstall(flags);
2167
2963
  case "run": {
2168
- const r = parseRun(rest);
2169
- return cmdRun(r.providerId, r.name, r.args);
2964
+ let r;
2965
+ try {
2966
+ r = parseRun(rest);
2967
+ }
2968
+ catch (e) {
2969
+ die(e.message);
2970
+ }
2971
+ return cmdRun(r.providerId, r.name, r.args, r.cwd);
2170
2972
  }
2171
2973
  case "handoff": return cmdHandoff(providerId, positional[0], positional.slice(1), flags);
2172
2974
  case "list":
@@ -2174,11 +2976,11 @@ async function main() {
2174
2976
  case "status": return cmdStatus(providerExplicit ? providerId : undefined, positional[0], !!flags.json);
2175
2977
  case "current": return cmdCurrent(providerExplicit ? providerId : undefined);
2176
2978
  case "whoami": return cmdWhoami(providerId, positional[0]);
2177
- case "dir": return cmdDir(providerId, !!flags.guard);
2979
+ case "dir": return cmdDir(providerId, !!flags.guard, !!flags.json, !!flags.plain);
2178
2980
  case "guard": return cmdGuard(positional[0], !!flags.json);
2179
- case "map": return cmdMap(providerId, positional[0], positional[1]);
2981
+ case "map": return cmdMap(providerId, positional[0], positional[1], flags);
2180
2982
  case "unmap": return cmdUnmap(providerExplicit ? providerId : undefined, positional[0]);
2181
- case "mappings": return cmdMappings();
2983
+ case "mappings": return cmdMappings(rest.includes("--json"));
2182
2984
  case "share": return cmdShare(positional[0], rest.slice(1));
2183
2985
  case "config": return cmdConfig(positional[0], positional[1], flags);
2184
2986
  case "sessions":
@@ -2196,8 +2998,11 @@ async function main() {
2196
2998
  case "rebind": return cmdRebind(providerId, providerExplicit, positional[0], flags);
2197
2999
  case "web": return cmdWeb(positional[0]);
2198
3000
  case "remove":
2199
- case "rm": return cmdRemove(providerId, positional[0], !!flags.force);
2200
- case "shellenv": return cmdShellenv(flags.shell ?? positional[0]);
3001
+ case "rm": return cmdRemove(providerId, positional[0], !!flags.force, !!flags.yes);
3002
+ case "shellenv":
3003
+ return positional[0] === "install"
3004
+ ? cmdShellenv(flags.shell, "install", !!flags.json)
3005
+ : cmdShellenv(flags.shell ?? positional[0]);
2201
3006
  case "service": return cmdService(positional[0]);
2202
3007
  case "usage": return cmdUsageHistory(providerId, positional[0], flags);
2203
3008
  case "tooling":
@@ -2210,7 +3015,13 @@ async function main() {
2210
3015
  die(`usage: agent-switch tooling [install|upgrade <${TOOL_IDS.join("|")}>] [notes [<tool>]] [--json]`);
2211
3016
  }
2212
3017
  return cmdTooling(!!flags.json);
2213
- case "doctor": return process.exit(await runDoctor());
3018
+ case "doctor": return process.exit(await runDoctor(!!flags.json));
3019
+ case "auth-status": return cmdAuthStatus(providerExplicit ? providerId : undefined, !!flags.json);
3020
+ case "activity": return cmdActivity(flags);
3021
+ case "protection": return cmdProtection(positional[0], positional.slice(1), flags);
3022
+ case "pool":
3023
+ case "pools": return cmdPool(positional[0], positional.slice(1), flags);
3024
+ case "gui-settings": return cmdGuiSettings(positional[0], positional.slice(1), flags);
2214
3025
  case "help":
2215
3026
  case "--help":
2216
3027
  case "-h": return usage();