@event4u/agent-switch 2.2.0 → 2.3.1

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,7 +15,7 @@ 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, 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";
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
19
  import { browserWrapperScript, defaultBrowserDeps, listBrowsers, locateBrowser } from "./browsers.js";
20
20
  import { PROVIDER_IDS, provider, isProviderInstalled, resolveBinary } from "./providers.js";
21
21
  import { borrowerOf, discardBinding, readBinding, rebind, releaseBorrowedAccount, restoreRebind } from "./rebind.js";
@@ -33,22 +33,22 @@ import { readGuiSettings, writeGuiSettings, applyGuiSetting } from "./gui-settin
33
33
  import { readProtection, setProtection, renameProtection, forgetProtection } from "./protection.js";
34
34
  import { POOL_PREFIX, addPoolMember, createPool, deletePool, findPool, isPoolRef, parseMember, poolIdOf, readPools, removePoolMember, renamePool, renamePoolMember, suggestPoolsFromLabels, } from "./pools.js";
35
35
  import { resolveForDir } from "./dir-resolve.js";
36
- import { antigravityAuthStatus, claudeAuthStatus, claudeCredentialFile, codexAuthStatus, fileMtimeMs, readText, } from "./auth-status.js";
36
+ import { antigravityAuthStatus, claudeAuthStatus, claudeCredentialFile, codexAuthStatus, fileMtimeMs, readText, keptFreshByFor, } from "./auth-status.js";
37
37
  import { TOOL_IDS, checkTooling, formatToolNotes, formatToolingLines, runToolAction, toolNotesRefusal, toolReleaseRepo, } from "./tooling.js";
38
38
  import { releaseNoteBlocks } from "./release-notes.js";
39
- import { readHistory, recordHistorySample } from "./history.js";
39
+ import { LATEST_FILE, readHistory, readLatestSnapshot, recordHistorySample, writeLatestSnapshot } from "./history.js";
40
40
  import { launchGui } from "./gui-launch.js";
41
41
  import { GUARD_POLICIES, GUARD_REFUSED_EXIT, OVERRIDE_ENV, guardVerdict, inspectLoad, isGuardPolicy, overrideFromEnv, renderGuardMessage, } from "./single-load.js";
42
42
  import { checkForUpdate, fetchReleases, releasesNewerThan, selfUpdate } from "./updates.js";
43
43
  import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, setPaused, normalizePath, } from "./mappings.js";
44
44
  import { checkAuth, fetchProfile, fetchUsage, liveSessionPids, } from "./api.js";
45
- import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
45
+ import { defaultFreshenDeps, usageToken } from "./token-freshen.js";
46
46
  import { formatSnapshot, parseUsage } from "./usage.js";
47
47
  import { readCodexUsage } from "./codex-usage.js";
48
48
  import { redeemResetCredit } from "./codex-reset.js";
49
49
  import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, detectRunningClaudeProfile, listSessions, listCodexSessions, locateSession, locateCodexSession, markLive, openQuestion, restoreSession, sharedHistory, sweepTrash, transferSession, transferCodexSession, trashedSessionExists, } from "./sessions.js";
50
50
  import { isFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
51
- import { cmdService, serviceInstall, serviceTarget, serviceUninstall } from "./service.js";
51
+ import { cmdService, serviceAfterUpdate, serviceInstall, serviceInstallFromDisk, serviceTarget, serviceUninstall } from "./service.js";
52
52
  import { fileURLToPath } from "node:url";
53
53
  import { readContext, turnInFlight } from "./telemetry.js";
54
54
  import { readPreview } from "./session-preview.js";
@@ -56,6 +56,8 @@ import { installHooks, uninstallHooks, hooksInstalled, readSettings, appendEvent
56
56
  import { readTelemetryConfig, writeTelemetryConfig } from "./notify.js";
57
57
  import { appendNotification, readNotifications, clearNotifications } from "./notifications.js";
58
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";
59
61
  import { scanDesktop } from "./desktop-sessions.js";
60
62
  import { ensureAgyKeychain } from "./agy-keychain.js";
61
63
  import { currentManagedSession, currentTmuxSessionName, newSessionArgs, readTmuxRegistry, recordManagedSession, respawnPaneArgs, sendKeysArgs, tmuxAvailable, tmuxSessionName, } from "./tmux.js";
@@ -231,6 +233,9 @@ function cmdLogin(providerId, name, browserId) {
231
233
  console.error(`${providerId}/${n} is still not signed in (${after.detail})${code !== 0 ? ` — ${p.binary} exited with ${code}` : ""}.`);
232
234
  process.exit(1);
233
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);
234
239
  const id = identity(providerId, n);
235
240
  console.log(`${providerId}/${n} is signed in${id ? ` as ${id}` : ""}.`);
236
241
  }
@@ -262,6 +267,8 @@ async function importClaude(name) {
262
267
  fs.writeFileSync(path.join(dst, ".credentials.json"), creds, { mode: 0o600 });
263
268
  return creds !== null;
264
269
  }));
270
+ if (seeded)
271
+ setSharesDefaultLogin("claude", name, true);
265
272
  console.log(`Imported default Claude setup into profile "${name}".`);
266
273
  console.log(seeded
267
274
  ? "Credentials were seeded — no re-login needed. Claude Code migrates the seed into\n" +
@@ -289,6 +296,8 @@ function importFileProvider(p, name) {
289
296
  if (path.join(srcDir, file) === p.credentialPath(srcDir))
290
297
  seeded = true;
291
298
  }
299
+ if (seeded)
300
+ setSharesDefaultLogin(p.id, name, true);
292
301
  console.log(`Imported default ${p.binary} setup into profile "${name}".`);
293
302
  console.log(seeded
294
303
  ? `Credential seeded — no re-login needed.\nIMPORTANT: this profile shares one OAuth ` +
@@ -455,7 +464,7 @@ async function claudeSnapshot(name) {
455
464
  if (cached)
456
465
  return cached;
457
466
  }
458
- const token = freshAccessToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")));
467
+ const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name));
459
468
  if (!token)
460
469
  return null;
461
470
  const raw = await fetchUsage(token);
@@ -481,8 +490,11 @@ async function cmdStatus(providerId, name, json = false) {
481
490
  // every displayed profile on each refresh, so recording the fresh snapshot
482
491
  // here (throttled to ~hourly) fills the chart for all profiles without
483
492
  // depending on the background daemon or an "active" profile being set.
484
- if (usage)
493
+ if (usage) {
485
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
+ }
486
498
  const wc = worstLiveContext(pid, active);
487
499
  const context = wc
488
500
  ? { sessionId: wc.sessionId, pct: wc.pct, contextTokens: wc.contextTokens, windowTokens: wc.windowTokens, model: wc.model, confidence: wc.confidence }
@@ -516,7 +528,7 @@ async function cmdStatus(providerId, name, json = false) {
516
528
  console.log(" (no usage readout for this provider — shows own usage only where available)");
517
529
  continue;
518
530
  }
519
- const token = freshAccessToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")));
531
+ const token = usageToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", n));
520
532
  if (!token) {
521
533
  const auth = authStatusFor("claude", n);
522
534
  console.log(auth.state === "sign-in-needed"
@@ -546,6 +558,12 @@ async function cmdStatus(providerId, name, json = false) {
546
558
  /** Cached usage for one profile: the daemon's snapshot or the newest history
547
559
  * sample, whichever is newer. Local reads only — `dir` runs on every start. */
548
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) {
549
567
  const daemonSnap = readDaemonState()?.profiles?.[`${providerId}/${name}`] ?? null;
550
568
  const daemonMs = daemonSnap ? Date.parse(daemonSnap.capturedAt) : NaN;
551
569
  const hist = readHistory(path.join(profileDir(providerId, name), "usage-history.json"));
@@ -581,6 +599,7 @@ function resolveDir(providerId, plain = false) {
581
599
  exists: (n) => profileExistsSafe(providerId, n),
582
600
  signedIn: (n) => identity(providerId, n) !== null,
583
601
  disabled: (n) => isDisabled(providerId, n),
602
+ sharesDefaultLogin: (n) => profileExistsSafe(providerId, n) && sharesDefaultLogin(providerId, n),
584
603
  pool: (id) => findPool(id),
585
604
  usage: (n) => cachedUsage(providerId, n),
586
605
  // Only providers that write a live-session signal can report one; the
@@ -605,9 +624,22 @@ function resolveDir(providerId, plain = false) {
605
624
  function cmdDir(providerId, guarded = false, json = false, plain = false) {
606
625
  const r = resolveDir(providerId, plain);
607
626
  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));
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);
609
635
  return;
610
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
+ }
611
643
  const name = r.name;
612
644
  if (!name)
613
645
  return;
@@ -1632,6 +1664,7 @@ async function cmdRemove(providerId, name, force = false, yes = false) {
1632
1664
  }
1633
1665
  const droppedRebind = binding ? discardBinding(n) : false;
1634
1666
  const removedEntry = providerId === "claude" ? credentials.removeEntry(configDir(providerId, n)) : false;
1667
+ removeLaunchersFor(providerId, n); // they would start a profile that no longer exists
1635
1668
  fs.rmSync(profileDir(providerId, n), { recursive: true, force: true });
1636
1669
  if (activeFor(providerId) === n)
1637
1670
  setActive(providerId, null);
@@ -1671,6 +1704,15 @@ function cmdLabel(providerId, name, label) {
1671
1704
  }
1672
1705
  /** Disable keeps a profile (login, history, settings) but takes it out of every
1673
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
+ }
1674
1716
  function cmdDisable(providerId, name, disabled) {
1675
1717
  const n = requireProfile(providerId, name, disabled ? "disable" : "enable");
1676
1718
  const wasActive = activeFor(providerId) === n;
@@ -1749,6 +1791,7 @@ function cmdRename(providerId, from, to) {
1749
1791
  }
1750
1792
  renameProtection(providerId, n, to);
1751
1793
  renamePoolMember(`${providerId}/${n}`, `${providerId}/${to}`);
1794
+ renameLaunchersFor(providerId, n, to);
1752
1795
  console.log(`Renamed ${providerId} profile "${n}" → "${to}".`);
1753
1796
  }
1754
1797
  /** Manually redeem one banked rate-limit reset for a Codex profile. Consumes a
@@ -1890,6 +1933,7 @@ function cmdUninstall(flags = {}) {
1890
1933
  console.log(` - ${allProfiles.length} profile(s) and their configs under ${ROOT}`);
1891
1934
  console.log(` - ${claudeProfiles.length} Claude keychain credential entr(y/ies) (macOS)`);
1892
1935
  console.log(" - directory mappings, active-profile state, and the background daemon/service");
1936
+ console.log(" - app launchers it created in ~/Applications/Agent Switch");
1893
1937
  console.log("It does NOT touch your default claude/codex/antigravity installs.");
1894
1938
  console.log("\nRe-run with --force to proceed: agent-switch uninstall --force");
1895
1939
  return;
@@ -1916,8 +1960,12 @@ function cmdUninstall(flags = {}) {
1916
1960
  if (credentials.removeEntry(configDir("claude", n)))
1917
1961
  removedEntries++;
1918
1962
  }
1963
+ // Launchers live in ~/Applications, outside ROOT, and are listed in its manifest.
1964
+ const removedLaunchers = removeAllLaunchers();
1919
1965
  fs.rmSync(ROOT, { recursive: true, force: true });
1920
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.`);
1921
1969
  if (removedEntries > 0)
1922
1970
  console.log(`Removed ${removedEntries} Claude keychain entr(y/ies).`);
1923
1971
  console.log("Follow-ups (manual, if you set them up):");
@@ -1964,6 +2012,7 @@ function cmdApps(json = false) {
1964
2012
  provider: a.provider,
1965
2013
  strategy: a.strategy,
1966
2014
  installed: isInstalled(a),
2015
+ copiesSettings: hasSettingsLayout(a.id),
1967
2016
  }));
1968
2017
  console.log(JSON.stringify(rows, null, 2));
1969
2018
  return;
@@ -2009,8 +2058,11 @@ function cmdOsNotify(sub, json = false) {
2009
2058
  const enabled = readOsNotifications();
2010
2059
  if (json)
2011
2060
  console.log(JSON.stringify({ enabled }));
2012
- else
2061
+ else {
2013
2062
  console.log(`Daemon OS notifications ${enabled ? "ON" : "OFF"}.`);
2063
+ if (enabled && process.platform === "darwin")
2064
+ console.log("On macOS the daemon shows none itself — the Agent Switch app shows them while it runs.");
2065
+ }
2014
2066
  }
2015
2067
  /** `agent-switch notifications [clear] [--json]` — list the recent
2016
2068
  * notifications (newest first) or clear the log. */
@@ -2083,8 +2135,34 @@ async function cmdUpdate() {
2083
2135
  console.log(`Update check failed (${c.message}); attempting \`npm install\` anyway…`);
2084
2136
  const code = selfUpdate();
2085
2137
  logActivity("update", null, code === 0 ? "self-update installed" : `self-update failed (exit ${code})`, "user ran `agent-switch update`", code === 0);
2138
+ if (code === 0)
2139
+ await serviceAfterSelfUpdate();
2086
2140
  process.exit(code);
2087
2141
  }
2142
+ /** Keeps the usage service on the version just installed. Without it the
2143
+ * balanced start has no fresh usage to weigh. */
2144
+ async function serviceAfterSelfUpdate() {
2145
+ switch (serviceAfterUpdate(serviceTarget(), !!process.stdin.isTTY)) {
2146
+ case "reinstall":
2147
+ console.log("Restarting the background service on the new version…");
2148
+ if (serviceInstallFromDisk() !== 0)
2149
+ console.error("Couldn't restart the service — run `agent-switch service install`.");
2150
+ return;
2151
+ case "ask":
2152
+ if (await confirmAction("Install the background service that keeps usage fresh (starts at login)?", false)) {
2153
+ if (serviceInstallFromDisk() !== 0)
2154
+ console.error("Couldn't install the service — run `agent-switch service install`.");
2155
+ }
2156
+ else
2157
+ console.log("Skipped. Install it any time with `agent-switch service install`.");
2158
+ return;
2159
+ case "hint":
2160
+ console.log("Tip: `agent-switch service install` keeps usage fresh so starts can be balanced.");
2161
+ return;
2162
+ case "leave":
2163
+ console.log("The background service runs another agent-switch build — left as it is.");
2164
+ }
2165
+ }
2088
2166
  function cmdOpen(appId, name) {
2089
2167
  if (!appId)
2090
2168
  die("usage: agent-switch open <app> [profile] (see `agent-switch apps`)");
@@ -2099,6 +2177,12 @@ function cmdOpen(appId, name) {
2099
2177
  requireProfile(app.provider, n, "open");
2100
2178
  if (!isInstalled(app))
2101
2179
  die(`${app.displayName} is not installed.`);
2180
+ // A second launch on the same data dir would only hit the app's single-instance
2181
+ // lock — bring the window that profile already has to the front instead.
2182
+ if (app.strategy !== "env" && focusRunning(guiDataDir(app, n))) {
2183
+ console.log(`${app.displayName} on ${app.provider}/${n} is already open — brought it to the front.`);
2184
+ return;
2185
+ }
2102
2186
  let firstLaunch = false;
2103
2187
  if (app.strategy === "user-data-dir") {
2104
2188
  const dir = guiDataDir(app, n);
@@ -2113,6 +2197,95 @@ function cmdOpen(appId, name) {
2113
2197
  console.log("is saved to this profile. Quit other windows of this app before signing in.");
2114
2198
  }
2115
2199
  }
2200
+ /** `agent-switch launcher add|remove <app> <profile>` · `list [--json]` · `repair`. */
2201
+ function cmdLauncher(sub, args, flags) {
2202
+ const target = (verb) => {
2203
+ const app = findApp(args[0] ?? "");
2204
+ if (!app)
2205
+ die(`usage: agent-switch launcher ${verb} <app> <profile> (see \`agent-switch apps\`)`);
2206
+ const n = requireProfile(app.provider, args[1], `launcher ${verb}`);
2207
+ return { app, n };
2208
+ };
2209
+ switch (sub) {
2210
+ case "add": {
2211
+ const { app, n } = target("add");
2212
+ try {
2213
+ const e = createLauncher(app, n);
2214
+ logActivity("launcher", `${app.provider}/${n}`, `created ${app.displayName} launcher`, "user ran `agent-switch launcher add`");
2215
+ console.log(`Created ${e.path}`);
2216
+ console.log("Start it from Spotlight or drag it into the Dock. It opens the app on this account only.");
2217
+ }
2218
+ catch (err) {
2219
+ die(err instanceof Error ? err.message : String(err));
2220
+ }
2221
+ return;
2222
+ }
2223
+ case "remove":
2224
+ case "rm": {
2225
+ const { app, n } = target("remove");
2226
+ if (!removeLauncher(app.id, n))
2227
+ die(`no ${app.displayName} launcher for ${app.provider}/${n}.`);
2228
+ logActivity("launcher", `${app.provider}/${n}`, `removed ${app.displayName} launcher`, "user ran `agent-switch launcher remove`");
2229
+ console.log(`Removed the ${app.displayName} launcher for ${app.provider}/${n}.`);
2230
+ return;
2231
+ }
2232
+ case "repair": {
2233
+ const r = repairLaunchers();
2234
+ for (const e of r.rebuilt)
2235
+ console.log(`Rebuilt ${e.path}`);
2236
+ for (const f of r.failed)
2237
+ console.error(`Couldn't rebuild ${f.entry.path}: ${f.reason}`);
2238
+ if (!r.rebuilt.length && !r.failed.length)
2239
+ console.log("No launchers to repair.");
2240
+ if (r.failed.length)
2241
+ process.exit(1);
2242
+ return;
2243
+ }
2244
+ case "list":
2245
+ case undefined: {
2246
+ const rows = readLaunchers().map((e) => ({ ...e, exists: fs.existsSync(e.path) }));
2247
+ if (flags.json)
2248
+ return void console.log(JSON.stringify(rows, null, 2));
2249
+ if (!rows.length)
2250
+ return void console.log("No launchers. Create one with `agent-switch launcher add <app> <profile>`.");
2251
+ for (const r of rows)
2252
+ console.log(`${r.exists ? "●" : "○ missing"} ${r.app.padEnd(16)} ${r.provider}/${r.profile} ${r.path}`);
2253
+ return;
2254
+ }
2255
+ default:
2256
+ die("usage: agent-switch launcher add|remove <app> <profile> | list [--json] | repair");
2257
+ }
2258
+ }
2259
+ /** `agent-switch app-settings copy <app> --from <profile|default> --to <profile>`. */
2260
+ function cmdAppSettings(sub, args, flags) {
2261
+ const app = findApp(args[0] ?? "");
2262
+ const from = typeof flags.from === "string" ? flags.from : null;
2263
+ const to = typeof flags.to === "string" ? flags.to : null;
2264
+ if (sub !== "copy" || !app || !from || !to)
2265
+ die("usage: agent-switch app-settings copy <app> --from <profile|default> --to <profile>");
2266
+ if (!hasSettingsLayout(app.id))
2267
+ die(`copying settings is not supported for ${app.displayName}.`);
2268
+ if (from === to)
2269
+ die("--from and --to are the same profile.");
2270
+ const toProfile = requireProfile(app.provider, to, "app-settings copy");
2271
+ const fromDir = from === "default" ? DEFAULT_DATA_DIR[app.id] : guiDataDir(app, requireProfile(app.provider, from, "app-settings copy"));
2272
+ const toDir = guiDataDir(app, toProfile);
2273
+ if (!fs.existsSync(fromDir))
2274
+ die(`${from === "default" ? `The default ${app.displayName}` : `${app.provider}/${from}`} has no ${app.displayName} data yet.`);
2275
+ // The running app rewrites its own files on quit and would undo the copy.
2276
+ if (findRunning(toDir))
2277
+ die(`${app.displayName} is open on ${app.provider}/${toProfile} — quit it first, then copy.`);
2278
+ const r = copyDesktopSettings(app.id, fromDir, toDir);
2279
+ logActivity("settings", `${app.provider}/${toProfile}`, `copied ${app.displayName} settings from ${from}`, "user ran `agent-switch app-settings copy`");
2280
+ if (flags.json)
2281
+ return void console.log(JSON.stringify({ app: app.id, from, to: toProfile, ...r }, null, 2));
2282
+ if (!r.copied.length)
2283
+ return void console.log(`Nothing to copy — ${from} has no ${app.displayName} settings yet.`);
2284
+ console.log(`Copied ${r.copied.join(", ")} from ${from} to ${app.provider}/${toProfile}.`);
2285
+ if (r.backups.length)
2286
+ console.log(`Previous files kept as ${r.backups.map((b) => path.basename(b)).join(", ")}.`);
2287
+ console.log("Logins, chats and account-bound preferences were not copied.");
2288
+ }
2116
2289
  /**
2117
2290
  * `agent-switch guard [block|warn|off]` — read or set the single-load policy.
2118
2291
  * The bare form also reports what is loaded RIGHT NOW, because "what is my
@@ -2302,6 +2475,7 @@ Run with no command to launch the tray/menubar GUI (single-instance).
2302
2475
  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
2476
  agent-switch browsers [--json] browsers --browser can open (macOS / Linux)
2304
2477
  agent-switch import [--provider P] <name> migrate the default install (no re-login)
2478
+ agent-switch mark-shared [--provider P] <name> [--off] mark a profile as sharing the default install's login (never refreshed or balanced)
2305
2479
  agent-switch use [--provider P] <name> set the active profile for a provider
2306
2480
  agent-switch deactivate [--provider P] clear the active profile for a provider
2307
2481
  agent-switch run [--provider P] <name> [--tmux] [..] launch the provider's CLI (--tmux = managed tmux session, POSIX)
@@ -2340,15 +2514,17 @@ Run with no command to launch the tray/menubar GUI (single-instance).
2340
2514
  agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] enable/disable a provider (default: Claude + Codex)
2341
2515
  agent-switch [gui] launch the tray/menubar GUI (the default with no command; won't start a second if one is running)
2342
2516
  agent-switch check-update [--json] check GitHub Releases for a newer version
2343
- agent-switch update self-update to the latest version (via npm)
2517
+ agent-switch update self-update to the latest version (via npm); restarts the service on it, or offers to install one
2344
2518
  agent-switch apps list launchable GUI apps (macOS)
2345
- agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
2519
+ agent-switch open <app> [profile] launch a GUI app on a profile, isolated; focuses it if already open (macOS)
2520
+ agent-switch launcher add|remove <app> <profile> | list [--json] | repair Dock/Spotlight launcher per account in ~/Applications/Agent Switch (macOS)
2521
+ agent-switch app-settings copy <app> --from <profile|default> --to <profile> [--json] copy desktop settings (MCP, preferences, extensions) — never logins
2346
2522
  agent-switch shellenv [--shell zsh|bash|fish|powershell] shell integration
2347
2523
  agent-switch shellenv install [--shell S] [--json] add it to your shell's startup file
2348
2524
  agent-switch service run|start|stop|status|install|uninstall background usage daemon
2349
2525
  agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
2350
2526
  agent-switch notify --kind K --title T --message M [--json] record a notification event
2351
- agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications (default off)
2527
+ agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications, Linux/Windows (default off; macOS: the app shows them)
2352
2528
  agent-switch uninstall [--force] remove all agent-switch data + daemon
2353
2529
  agent-switch usage history [--provider P] [--profile <name>] [--json] daemon-recorded usage samples per profile
2354
2530
  agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
@@ -2569,6 +2745,11 @@ function cmdActivity(flags) {
2569
2745
  }
2570
2746
  }
2571
2747
  function authStatusFor(providerId, name) {
2748
+ const s = rawAuthStatusFor(providerId, name);
2749
+ const keptFreshBy = keptFreshByFor(providerId, { shared: sharesDefaultLogin(providerId, name), serviceInstalled: serviceTarget() !== "not-installed" }, s.keptFreshBy);
2750
+ return { ...s, keptFreshBy };
2751
+ }
2752
+ function rawAuthStatusFor(providerId, name) {
2572
2753
  const now = Date.now();
2573
2754
  const cfg = provider(providerId).configDirFor(configDir(providerId, name));
2574
2755
  if (providerId === "claude") {
@@ -2763,6 +2944,7 @@ async function main() {
2763
2944
  case "desktop": return cmdDesktop(!!flags.json);
2764
2945
  case "disable": return cmdDisable(providerId, positional[0], true);
2765
2946
  case "enable": return cmdDisable(providerId, positional[0], false);
2947
+ case "mark-shared": return cmdMarkShared(providerId, positional[0], !!flags.off);
2766
2948
  case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags);
2767
2949
  case "reset": return cmdReset(providerId, positional[0], !!flags.yes);
2768
2950
  case "rename": return cmdRename(providerId, positional[0], positional[1]);
@@ -2773,6 +2955,9 @@ async function main() {
2773
2955
  case "check-update":
2774
2956
  case "check-updates": return cmdCheckUpdate(!!flags.json);
2775
2957
  case "open": return cmdOpen(positional[0], positional[1]);
2958
+ case "launcher":
2959
+ case "launchers": return cmdLauncher(positional[0], positional.slice(1), flags);
2960
+ case "app-settings": return cmdAppSettings(positional[0], positional.slice(1), flags);
2776
2961
  case "apps": return cmdApps(!!flags.json);
2777
2962
  case "notify": return cmdNotify(flags);
2778
2963
  case "notifications": return cmdNotifications(positional[0], !!flags.json);
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Managed desktop launchers (macOS): one small app per app × profile in
3
+ * `~/Applications/Agent Switch/`, which a Dock or Spotlight click can start.
4
+ *
5
+ * Each launcher is an AppleScript applet (`osacompile`) whose only line runs
6
+ * this CLI's `open <app> <profile>` — the same isolated start the app menu
7
+ * uses, including "focus the window it already has". The original app is never
8
+ * copied, patched or re-signed. A locally built applet carries no quarantine
9
+ * flag, so Gatekeeper does not prompt; swapping its icon breaks the ad-hoc
10
+ * seal, which is why the bundle is re-signed ad hoc afterwards.
11
+ *
12
+ * Every launcher made is recorded in `launchers.json`, so remove, repair and
13
+ * uninstall touch exactly those bundles.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { findApp } from "./apps.js";
21
+ import { ROOT } from "./profiles.js";
22
+ const MANIFEST = path.join(ROOT, "launchers.json");
23
+ /** macOS-only paths: built with path.posix so they are right on any host. */
24
+ export function launchersDir(home = os.homedir()) {
25
+ return path.posix.join(home, "Applications", "Agent Switch");
26
+ }
27
+ export function launcherPath(app, profile, home = os.homedir()) {
28
+ return path.posix.join(launchersDir(home), `${app.displayName} – ${profile}.app`);
29
+ }
30
+ function asLiteral(s) {
31
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
32
+ }
33
+ /** The applet's single line: run `<node> <index.js> open <app> <profile>`,
34
+ * carrying a custom AGENT_SWITCH_HOME — a Dock start has none in its env. */
35
+ export function appleScriptFor(node, index, appId, profile, home) {
36
+ const env = home ? `"AGENT_SWITCH_HOME=" & quoted form of ${asLiteral(home)} & " " & ` : "";
37
+ return (`do shell script ${env}quoted form of ${asLiteral(node)} & " " & quoted form of ${asLiteral(index)}` +
38
+ ` & " open " & quoted form of ${asLiteral(appId)} & " " & quoted form of ${asLiteral(profile)}`);
39
+ }
40
+ /** A brew/installer `bin/node` symlink that resolves to the running node — it
41
+ * survives an upgrade, the versioned path behind it does not. */
42
+ export function stableNode(execPath, candidates = ["/opt/homebrew/bin/node", "/usr/local/bin/node"], realpath = (p) => fs.realpathSync(p)) {
43
+ let running;
44
+ try {
45
+ running = realpath(execPath);
46
+ }
47
+ catch {
48
+ return execPath;
49
+ }
50
+ for (const c of candidates) {
51
+ try {
52
+ if (realpath(c) === running)
53
+ return c;
54
+ }
55
+ catch {
56
+ /* not installed there */
57
+ }
58
+ }
59
+ return execPath;
60
+ }
61
+ /** The main (non-helper) process started with exactly this `--user-data-dir`. */
62
+ export function findMainPid(psOutput, userDataDir) {
63
+ const flag = `--user-data-dir=${userDataDir}`;
64
+ for (const line of psOutput.split("\n")) {
65
+ const m = line.match(/^\s*(\d+)\s+(.*)$/);
66
+ if (!m)
67
+ continue;
68
+ const args = m[2];
69
+ const at = args.indexOf(flag);
70
+ if (at < 0 || args.includes("--type="))
71
+ continue;
72
+ const next = args[at + flag.length];
73
+ if (next === undefined || next === " ")
74
+ return Number(m[1]);
75
+ }
76
+ return null;
77
+ }
78
+ /** Brings an already running profile instance to the front. False = none runs
79
+ * (or it could not be activated), so the caller starts one. */
80
+ export function findRunning(userDataDir) {
81
+ if (process.platform !== "darwin")
82
+ return null;
83
+ const ps = spawnSync("ps", ["-axo", "pid=,args="], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
84
+ return ps.status === 0 ? findMainPid(ps.stdout, userDataDir) : null;
85
+ }
86
+ export function focusRunning(userDataDir) {
87
+ const pid = findRunning(userDataDir);
88
+ if (pid === null)
89
+ return false;
90
+ // NSRunningApplication.activate needs no Accessibility/Automation permission.
91
+ const js = `ObjC.import('AppKit'); const a = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${pid}); a.isNil() ? 'no' : (a.activateWithOptions($.NSApplicationActivateAllWindows) ? 'yes' : 'no')`;
92
+ const r = spawnSync("osascript", ["-l", "JavaScript", "-e", js], { encoding: "utf8" });
93
+ return r.status === 0 && r.stdout.trim() === "yes";
94
+ }
95
+ export function upsertEntry(entries, e) {
96
+ return [...withoutEntry(entries, e.app, e.profile), e];
97
+ }
98
+ export function withoutEntry(entries, app, profile) {
99
+ return entries.filter((x) => !(x.app === app && x.profile === profile));
100
+ }
101
+ export function entriesFor(entries, provider, profile) {
102
+ return entries.filter((x) => x.provider === provider && x.profile === profile);
103
+ }
104
+ export function readLaunchers() {
105
+ try {
106
+ const raw = JSON.parse(fs.readFileSync(MANIFEST, "utf8"));
107
+ return Array.isArray(raw) ? raw.filter((x) => x && typeof x.app === "string" && typeof x.profile === "string" && typeof x.path === "string") : [];
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ function writeLaunchers(entries) {
114
+ fs.mkdirSync(ROOT, { recursive: true });
115
+ fs.writeFileSync(MANIFEST, JSON.stringify(entries, null, 2) + "\n", { mode: 0o600 });
116
+ }
117
+ /** Where the app is installed (first Spotlight hit), or null. */
118
+ export function appBundlePath(app) {
119
+ const r = spawnSync("mdfind", [`kMDItemCFBundleIdentifier == '${app.bundleId}'`], { encoding: "utf8" });
120
+ const hit = r.status === 0 ? r.stdout.split("\n").find((l) => l.endsWith(".app")) : undefined;
121
+ return hit ?? null;
122
+ }
123
+ function appIcon(bundle) {
124
+ const r = spawnSync("plutil", ["-extract", "CFBundleIconFile", "raw", path.join(bundle, "Contents", "Info.plist")], { encoding: "utf8" });
125
+ if (r.status !== 0)
126
+ return null;
127
+ const name = r.stdout.trim();
128
+ const file = path.join(bundle, "Contents", "Resources", name.endsWith(".icns") ? name : `${name}.icns`);
129
+ return fs.existsSync(file) ? file : null;
130
+ }
131
+ /** This CLI as the launcher should call it: a stable node + this index.js. */
132
+ function cliInvocation() {
133
+ return { node: stableNode(process.execPath), index: path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js") };
134
+ }
135
+ function run(cmd, args) {
136
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
137
+ if (r.status !== 0)
138
+ throw new Error(`${cmd} failed: ${(r.stderr || r.stdout).trim()}`);
139
+ }
140
+ /** Builds (or rebuilds) the launcher for app × profile and records it. */
141
+ export function createLauncher(app, profile) {
142
+ if (process.platform !== "darwin")
143
+ throw new Error("launchers are macOS-only for now.");
144
+ const bundle = appBundlePath(app);
145
+ if (!bundle)
146
+ throw new Error(`${app.displayName} is not installed.`);
147
+ const target = launcherPath(app, profile);
148
+ fs.mkdirSync(path.dirname(target), { recursive: true });
149
+ // Built next to the target, then swapped in: a failed build leaves the old one.
150
+ // The name must end in .app, or osacompile writes a bare script, not a bundle.
151
+ const tmp = path.join(path.dirname(target), `.${path.basename(target, ".app")}.building.app`);
152
+ fs.rmSync(tmp, { recursive: true, force: true });
153
+ try {
154
+ const { node, index } = cliInvocation();
155
+ run("osacompile", ["-o", tmp, "-e", appleScriptFor(node, index, app.id, profile, process.env.AGENT_SWITCH_HOME)]);
156
+ const icon = appIcon(bundle);
157
+ if (icon) {
158
+ // The applet's asset catalog would win over applet.icns and show a blank icon.
159
+ fs.rmSync(path.join(tmp, "Contents", "Resources", "Assets.car"), { force: true });
160
+ fs.copyFileSync(icon, path.join(tmp, "Contents", "Resources", "applet.icns"));
161
+ }
162
+ run("codesign", ["--force", "--sign", "-", tmp]);
163
+ fs.rmSync(target, { recursive: true, force: true });
164
+ fs.renameSync(tmp, target);
165
+ }
166
+ finally {
167
+ fs.rmSync(tmp, { recursive: true, force: true });
168
+ }
169
+ const entry = { app: app.id, provider: app.provider, profile, path: target };
170
+ writeLaunchers(upsertEntry(readLaunchers(), entry));
171
+ return entry;
172
+ }
173
+ /** Removes a recorded launcher; a path the manifest doesn't list is never deleted. */
174
+ export function removeLauncher(appId, profile) {
175
+ const entries = readLaunchers();
176
+ const hit = entries.find((x) => x.app === appId && x.profile === profile);
177
+ if (!hit)
178
+ return false;
179
+ fs.rmSync(hit.path, { recursive: true, force: true });
180
+ writeLaunchers(withoutEntry(entries, appId, profile));
181
+ return true;
182
+ }
183
+ export function removeLaunchersFor(provider, profile) {
184
+ const mine = entriesFor(readLaunchers(), provider, profile);
185
+ for (const e of mine)
186
+ removeLauncher(e.app, e.profile);
187
+ return mine.length;
188
+ }
189
+ /** A renamed profile's launchers are rebuilt under the new name (best effort:
190
+ * one that can't be rebuilt is removed rather than left pointing at nothing). */
191
+ export function renameLaunchersFor(provider, from, to) {
192
+ for (const e of entriesFor(readLaunchers(), provider, from)) {
193
+ removeLauncher(e.app, e.profile);
194
+ const app = findApp(e.app);
195
+ if (!app)
196
+ continue;
197
+ try {
198
+ createLauncher(app, to);
199
+ }
200
+ catch {
201
+ /* app gone — nothing to rebuild */
202
+ }
203
+ }
204
+ }
205
+ /** Rebuilds every recorded launcher — after a move of this CLI or a node upgrade. */
206
+ export function repairLaunchers() {
207
+ const rebuilt = [];
208
+ const failed = [];
209
+ for (const e of readLaunchers()) {
210
+ const app = findApp(e.app);
211
+ try {
212
+ if (!app)
213
+ throw new Error(`unknown app ${e.app}`);
214
+ rebuilt.push(createLauncher(app, e.profile));
215
+ }
216
+ catch (err) {
217
+ failed.push({ entry: e, reason: err instanceof Error ? err.message : String(err) });
218
+ }
219
+ }
220
+ return { rebuilt, failed };
221
+ }
222
+ export function removeAllLaunchers() {
223
+ const entries = readLaunchers();
224
+ for (const e of entries)
225
+ fs.rmSync(e.path, { recursive: true, force: true });
226
+ writeLaunchers([]);
227
+ return entries.length;
228
+ }