@event4u/agent-switch 1.6.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -13,20 +13,27 @@
13
13
  */
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
+ import * as readline from "node:readline";
16
17
  import { spawn, spawnSync } from "node:child_process";
17
- import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readSwitchStrategy, setSwitchStrategy, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readBinaryPaths, setBinaryPath, ROOT, } from "./profiles.js";
18
+ import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readBinaryPaths, setBinaryPath, ROOT, resetRebindKillSwitch, } from "./profiles.js";
18
19
  import { PROVIDER_IDS, provider, isProviderInstalled, resolveBinary } from "./providers.js";
20
+ import { rebind, restoreRebind } from "./rebind.js";
21
+ import { buildRebindDialog, resolveRebindChoice, selectableRows } from "./rebind-dialog.js";
19
22
  import { parseArgs, parseRun, resolveProviderValue } from "./args.js";
20
23
  import { extractBrief, writeBrief, sweepBriefs, seedPrompt } from "./handoff.js";
21
24
  import { credentialStore } from "./credentials.js";
22
25
  import { withProperLock } from "./locks.js";
23
26
  import { applySharing, removeSharing, syncSharing, sharedLinkHealth } from "./share.js";
27
+ import { assertNotFull, exportConfig, importConfig } from "./config-transfer.js";
24
28
  import { detectShell, shellenvScript } from "./shellenv.js";
25
29
  import { runDoctor } from "./doctor.js";
30
+ import { TOOL_IDS, checkTooling, formatToolingLines, runToolAction } from "./tooling.js";
31
+ import { readHistory, recordHistorySample } from "./history.js";
26
32
  import { launchGui } from "./gui-launch.js";
27
33
  import { checkForUpdate, selfUpdate } from "./updates.js";
28
34
  import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, } from "./mappings.js";
29
- import { accessTokenOf, checkAuth, fetchProfile, fetchUsage, liveSessionPids, readProfileCredential, } from "./api.js";
35
+ import { checkAuth, fetchProfile, fetchUsage, liveSessionPids, } from "./api.js";
36
+ import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
30
37
  import { formatSnapshot, parseUsage } from "./usage.js";
31
38
  import { readCodexUsage } from "./codex-usage.js";
32
39
  import { redeemResetCredit } from "./codex-reset.js";
@@ -270,8 +277,7 @@ async function claudeSnapshot(name) {
270
277
  if (cached)
271
278
  return cached;
272
279
  }
273
- const creds = readProfileCredential(configDir("claude", name));
274
- const token = creds ? accessTokenOf(creds) : null;
280
+ const token = freshAccessToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")));
275
281
  if (!token)
276
282
  return null;
277
283
  const raw = await fetchUsage(token);
@@ -293,6 +299,12 @@ async function cmdStatus(providerId, name, json = false) {
293
299
  : pid === "codex"
294
300
  ? await readCodexUsage(configDir("codex", active))
295
301
  : null;
302
+ // Feed the 30-day usage history from the GUI's own fetches: the GUI polls
303
+ // every displayed profile on each refresh, so recording the fresh snapshot
304
+ // here (throttled to ~hourly) fills the chart for all profiles without
305
+ // depending on the background daemon or an "active" profile being set.
306
+ if (usage)
307
+ recordHistorySample(path.join(profileDir(pid, active), "usage-history.json"), usage);
296
308
  const wc = worstLiveContext(pid, active);
297
309
  const context = wc
298
310
  ? { sessionId: wc.sessionId, pct: wc.pct, contextTokens: wc.contextTokens, windowTokens: wc.windowTokens, model: wc.model, confidence: wc.confidence }
@@ -326,8 +338,7 @@ async function cmdStatus(providerId, name, json = false) {
326
338
  console.log(" (no usage readout for this provider — shows own usage only where available)");
327
339
  continue;
328
340
  }
329
- const creds = readProfileCredential(configDir("claude", n));
330
- const token = creds ? accessTokenOf(creds) : null;
341
+ const token = freshAccessToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")));
331
342
  if (!token) {
332
343
  console.log(" (credential not readable — profile may not have run yet)");
333
344
  continue;
@@ -449,6 +460,55 @@ function cmdShare(mode, flags = []) {
449
460
  die("usage: agent-switch share on|sync|off|status [--history] [--source <profile|default>]");
450
461
  }
451
462
  }
463
+ /**
464
+ * `agent-switch config export|import <profile> [--full]` — move a Claude
465
+ * profile's SHARED config (settings.json, keybindings.json, CLAUDE.md, skills/,
466
+ * commands/, agents/) between machines as a JSON bundle. Config-ONLY — it never
467
+ * touches credentials. Claude-scoped (config lives under the Claude layout).
468
+ *
469
+ * export → the bundle goes to STDOUT (pipe it: `| gpg -c > backup.gpg`), all
470
+ * status on STDERR so stdout stays a clean bundle.
471
+ * import → reads a bundle from STDIN into the profile's config dir.
472
+ * --full → REFUSED (would bundle live OAuth tokens; account-takeover vector).
473
+ */
474
+ function cmdConfig(sub, name, flags = {}) {
475
+ try {
476
+ assertNotFull(!!flags.full);
477
+ }
478
+ catch (e) {
479
+ die(e.message);
480
+ }
481
+ if (sub === "export") {
482
+ const n = requireProfile("claude", name, "config export");
483
+ const bundle = exportConfig(configDir("claude", n));
484
+ // Status on stderr — stdout is the bundle (so `... | gpg -c > backup.gpg` works).
485
+ console.error(`Exported config-only bundle for claude/${n} (SHARED_ITEMS allowlist; no credentials).`);
486
+ process.stdout.write(bundle + "\n");
487
+ return;
488
+ }
489
+ if (sub === "import") {
490
+ const n = requireProfile("claude", name, "config import");
491
+ let input;
492
+ try {
493
+ input = fs.readFileSync(0, "utf8"); // stdin
494
+ }
495
+ catch {
496
+ die("config import reads a bundle from stdin — pipe one in (e.g. `gpg -d backup.gpg | agent-switch config import <profile>`).");
497
+ }
498
+ let written;
499
+ try {
500
+ written = importConfig(configDir("claude", n), input);
501
+ }
502
+ catch (e) {
503
+ die(e.message);
504
+ }
505
+ console.error(`Imported ${written.length} config file(s) into claude/${n} (config-only; no credentials):`);
506
+ for (const w of written)
507
+ console.error(` ${w}`);
508
+ return;
509
+ }
510
+ die("usage: agent-switch config export|import <profile> (config-only; pipe export | gpg -c > backup.gpg)");
511
+ }
452
512
  // ---------- session inventory + takeover (Claude; Codex lands per G0.3) ------
453
513
  /** Relative age like "3m" / "2h" / "5d" for the sessions table. */
454
514
  function ageOf(mtimeMs) {
@@ -658,6 +718,16 @@ function cmdHookEvent() {
658
718
  at: new Date().toISOString(),
659
719
  };
660
720
  appendEvent(eventFile(ROOT, who.provider, who.profile), rec);
721
+ // Session awareness: surface "Claude needs you / finished" as a notification
722
+ // the GUI routes to the pet. dedupe=false — each turn is a distinct moment,
723
+ // so a solo session's repeated turns are never collapsed. Best-effort; a hook
724
+ // must never throw.
725
+ if (rec.event === "Notification") {
726
+ appendNotification({ kind: "warning", title: "Claude is waiting", message: `${who.provider}/${who.profile} needs your input.` }, undefined, undefined, false);
727
+ }
728
+ else if (rec.event === "Stop") {
729
+ appendNotification({ kind: "info", title: "Claude finished", message: `${who.provider}/${who.profile} finished its turn.` }, undefined, undefined, false);
730
+ }
661
731
  }
662
732
  catch {
663
733
  // never throw from a hook
@@ -1210,7 +1280,8 @@ function cmdLabel(providerId, name, label) {
1210
1280
  setLabel(providerId, name, label);
1211
1281
  console.log(`Labeled ${providerId}/${name} as ${label}.`);
1212
1282
  }
1213
- /** Enable/disable opt-in auto-switch, or show the current setting. */
1283
+ /** Enable/disable opt-in near-limit notifications (notify + suggest, never an
1284
+ * automatic switch), or show the current setting. */
1214
1285
  /** Rename a profile (move its config dir + carry active/label/mappings). */
1215
1286
  function cmdRename(providerId, from, to) {
1216
1287
  const n = requireProfile(providerId, from, "rename");
@@ -1254,23 +1325,7 @@ async function cmdReset(providerId, name) {
1254
1325
  die(`reset failed: ${r.reason ?? "unknown"}`);
1255
1326
  }
1256
1327
  }
1257
- function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
1258
- // `autoswitch strategy [reset-first|rotation-first]` — global switch behaviour.
1259
- if (mode === "strategy") {
1260
- if (value === "reset-first" || value === "rotation-first") {
1261
- setSwitchStrategy(value);
1262
- }
1263
- else if (value !== undefined) {
1264
- die("usage: agent-switch autoswitch strategy [reset-first|rotation-first]");
1265
- }
1266
- if (flags.json) {
1267
- console.log(JSON.stringify({ strategy: readSwitchStrategy() }));
1268
- }
1269
- else {
1270
- console.log(`Auto-switch strategy: ${readSwitchStrategy()}.`);
1271
- }
1272
- return;
1273
- }
1328
+ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}) {
1274
1329
  const thresholdFlag = flags.threshold;
1275
1330
  const threshold = typeof thresholdFlag === "string" ? Number(thresholdFlag) : undefined;
1276
1331
  if (threshold !== undefined && (!Number.isFinite(threshold) || threshold < 1 || threshold > 100)) {
@@ -1299,22 +1354,12 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
1299
1354
  ...(tag !== undefined ? { tag } : {}),
1300
1355
  });
1301
1356
  const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
1302
- console.log(`Auto-switch for ${providerId} ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
1357
+ console.log(`Near-limit notifications for ${providerId} ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
1303
1358
  if (cfg.enabled) {
1304
- // Usage-policy disclosure — restored deliberately. Automated rotation on
1305
- // quota signals pools separate subscriptions to route around per-account
1306
- // rate limits; a prior review found this can violate the provider's usage
1307
- // policy. Enabling is the user's decision, but the risk must be stated.
1308
- console.warn("⚠️ Usage-policy warning: quota-triggered rotation across accounts can violate\n" +
1309
- " the providers' usage policies — it pools separate subscriptions to route around\n" +
1310
- " per-account rate limits. An internal review unanimously recommended removing it;\n" +
1311
- " it ships off-by-default anyway. See the README before enabling. (Per-context\n" +
1312
- " profile switching — private / work / client — is unaffected; this applies only\n" +
1313
- " to automated quota-driven rotation.)");
1314
- const strat = readSwitchStrategy();
1315
- console.log(`Strategy: ${strat}${strat === "reset-first" ? " (redeem a banked reset before switching, Codex)" : ""}.\n` +
1316
- `The daemon moves the active ${providerId} profile to the account with the most headroom once the\n` +
1317
- "active one hits the threshold. Run `agent-switch service start` so the daemon is watching.");
1359
+ console.log(`Once the active ${providerId} profile hits the threshold, the daemon NOTIFIES you and SUGGESTS the\n` +
1360
+ "account with the most headroom — it never switches automatically; switching stays a manual action.\n" +
1361
+ "On Codex it redeems a banked reset first (and stays on the account) before suggesting a switch.\n" +
1362
+ "Run `agent-switch service start` so the daemon is watching.");
1318
1363
  }
1319
1364
  return;
1320
1365
  }
@@ -1324,15 +1369,14 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
1324
1369
  console.log(JSON.stringify(readAutoSwitchAll()));
1325
1370
  return;
1326
1371
  }
1327
- console.log(`strategy: ${readSwitchStrategy()}`);
1328
1372
  for (const p of providerExplicit ? [providerId] : PROVIDER_IDS) {
1329
1373
  const cfg = readAutoSwitch(p);
1330
1374
  const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
1331
- console.log(`${p}: auto-switch ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
1375
+ console.log(`${p}: near-limit notifications ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
1332
1376
  }
1333
1377
  return;
1334
1378
  }
1335
- die("usage: agent-switch autoswitch on|off|status|strategy [reset-first|rotation-first] [--provider P] [--threshold <1-100>] [--tag all|work|personal|other] [--json]");
1379
+ die("usage: agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] [--tag all|work|personal|other] [--json]");
1336
1380
  }
1337
1381
  /**
1338
1382
  * Enable/disable a provider's surfaces (cli / ui), or show the current setting.
@@ -1549,8 +1593,8 @@ async function cmdGui() {
1549
1593
  // Launch the tray/menubar GUI — downloads its prebuilt artifact from the
1550
1594
  // matching GitHub Release on first use, then caches it (see gui-launch.ts).
1551
1595
  try {
1552
- await launchGui();
1553
- console.log("Launched the agent-switch GUI.");
1596
+ const result = await launchGui();
1597
+ console.log(result === "already-running" ? "The agent-switch GUI is already running." : "Launched the agent-switch GUI.");
1554
1598
  }
1555
1599
  catch (err) {
1556
1600
  die(String(err?.message ?? err));
@@ -1623,10 +1667,65 @@ function cmdOpen(appId, name) {
1623
1667
  function cmdShellenv(shellArg) {
1624
1668
  console.log(shellenvScript(detectShell(shellArg)));
1625
1669
  }
1670
+ /** `agent-switch tooling` — ecosystem tool readout (agent-config, rtk, provider
1671
+ * CLIs). `--json` is the GUI IPC contract: the GUI renders this readout and
1672
+ * never shells out to detect on its own. */
1673
+ function cmdTooling(json = false) {
1674
+ const tools = checkTooling();
1675
+ if (json) {
1676
+ console.log(JSON.stringify(tools, null, 2));
1677
+ return;
1678
+ }
1679
+ console.log(`agent-switch tooling — platform ${process.platform}\n`);
1680
+ for (const l of formatToolingLines(tools))
1681
+ console.log(l);
1682
+ }
1683
+ /** `agent-switch tooling install|upgrade <id>` — run the tool's per-platform
1684
+ * install/upgrade command as a visible child process (the GUI runs this inside
1685
+ * its embedded terminal). Exit code = the child's exit code. */
1686
+ function cmdToolingAction(action, id) {
1687
+ if (!id || !TOOL_IDS.includes(id)) {
1688
+ die(`usage: agent-switch tooling ${action} <${TOOL_IDS.join("|")}>`);
1689
+ }
1690
+ process.exit(runToolAction(id, action));
1691
+ }
1692
+ /**
1693
+ * `agent-switch usage history [--provider claude] [--profile <name>] [--json]`
1694
+ * — the daemon-recorded usage-history ring per profile (same file the daemon
1695
+ * appends: `<profileDir>/usage-history.json`). `--json` is the GUI contract:
1696
+ * `[{ profile, samples: [{ at, windows }] }]`. Own-profile data only.
1697
+ */
1698
+ function cmdUsageHistory(providerId, sub, flags = {}) {
1699
+ if (sub !== "history")
1700
+ die("usage: agent-switch usage history [--provider P] [--profile <name>] [--json]");
1701
+ const profiles = typeof flags.profile === "string"
1702
+ ? [requireProfile(providerId, flags.profile, "usage history --profile")]
1703
+ : listProfiles(providerId);
1704
+ if (profiles.length === 0)
1705
+ die(`no ${providerId} profiles`);
1706
+ const rows = profiles.map((p) => ({
1707
+ profile: p,
1708
+ samples: readHistory(path.join(profileDir(providerId, p), "usage-history.json")),
1709
+ }));
1710
+ if (flags.json) {
1711
+ console.log(JSON.stringify(rows, null, 2));
1712
+ return;
1713
+ }
1714
+ for (const r of rows) {
1715
+ if (r.samples.length === 0) {
1716
+ console.log(`${providerId}/${r.profile}: no usage history yet (the daemon records samples — agent-switch service start)`);
1717
+ continue;
1718
+ }
1719
+ const first = r.samples[0].at;
1720
+ const last = r.samples[r.samples.length - 1].at;
1721
+ console.log(`${providerId}/${r.profile}: ${r.samples.length} sample${r.samples.length === 1 ? "" : "s"} (${first} → ${last})`);
1722
+ }
1723
+ }
1626
1724
  function usage() {
1627
1725
  console.log(`agent-switch — switch accounts for Claude Code, Codex, and Antigravity (macOS · Linux · Windows)
1628
1726
 
1629
1727
  Provider defaults to claude; pass --provider codex|antigravity for the others.
1728
+ Run with no command to launch the tray/menubar GUI (single-instance).
1630
1729
 
1631
1730
  agent-switch add [--provider P] <name> create a profile and log it in
1632
1731
  agent-switch import [--provider P] <name> migrate the default install (no re-login)
@@ -1642,31 +1741,165 @@ Provider defaults to claude; pass --provider codex|antigravity for the others.
1642
1741
  agent-switch unmap [--provider P] [dir] remove a directory mapping
1643
1742
  agent-switch mappings list directory mappings
1644
1743
  agent-switch share on|sync|off|status [--history] [--source <profile|default>] [--json] (Claude)
1744
+ agent-switch config export|import <profile> config-only cross-machine bundle to stdout / from stdin (settings, keybindings, CLAUDE.md, skills/, commands/, agents/ — NEVER credentials; pipe export | gpg -c)
1645
1745
  agent-switch sessions [profile] [--recent N] [--json] recent + live Claude sessions per profile (with context %)
1646
1746
  agent-switch hooks install|uninstall|status [profile] manage lifecycle push hooks in Claude settings.json
1647
1747
  agent-switch alerts on|off|status [--threshold 80,95] record context/usage crossings to the notification log (off by default)
1648
1748
  agent-switch compact <profile> [--clear] [--dry-run] [--force] type /compact (or /clear) into the profile's managed tmux pane
1649
1749
  agent-switch takeover <id> --to <profile> [--from <profile>] [--keep-source] [--in-place] [--print-only] [--force] move a session to another profile and resume it
1750
+ 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)
1650
1751
  agent-switch web <name> claude.ai in a persistent browser (Claude)
1651
1752
  agent-switch remove [--provider P] <name> [--force] delete a profile
1652
1753
  agent-switch label [--provider P] <name> [Work|Personal|Other|none] tag a profile
1653
- agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] per-provider auto-switch (default OFF)
1754
+ agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] notify + suggest near a usage limit; never switches automatically (default OFF)
1654
1755
  agent-switch reset <profile> --provider codex redeem one banked Codex rate-limit reset
1655
1756
  agent-switch rename <old> <new> [--provider P] rename a profile (name & keep its tag)
1656
1757
  agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] enable/disable a provider (default: Claude + Codex)
1657
- agent-switch gui launch the tray/menubar GUI (bundled via npm)
1758
+ agent-switch [gui] launch the tray/menubar GUI (the default with no command; won't start a second if one is running)
1658
1759
  agent-switch check-update [--json] check GitHub Releases for a newer version
1659
1760
  agent-switch update self-update to the latest version (via npm)
1660
1761
  agent-switch apps list launchable GUI apps (macOS)
1661
1762
  agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
1662
1763
  agent-switch shellenv [--shell zsh|bash|fish|powershell] shell integration
1663
1764
  agent-switch service run|start|stop|status|install|uninstall background usage daemon
1664
- agent-switch notifications [clear] [--json] recent notifications (auto-switches, fetch failures)
1765
+ agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
1665
1766
  agent-switch notify --kind K --title T --message M [--json] record a notification event
1666
1767
  agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications (default off)
1667
1768
  agent-switch uninstall [--force] remove all agent-switch data + daemon
1769
+ agent-switch usage history [--provider P] [--profile <name>] [--json] daemon-recorded usage samples per profile
1770
+ agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
1771
+ agent-switch tooling install|upgrade <tool> install/upgrade an ecosystem tool (agent-config, rtk, claude, codex)
1668
1772
  agent-switch doctor per-OS, per-provider self-check`);
1669
1773
  }
1774
+ /**
1775
+ * The interactive limit dialog (Phase 4) — `agent-switch rebind` with no account
1776
+ * and no `--restore`. Gathers the OTHER claude profiles + their usage, pre-selects
1777
+ * the best-headroom profile as a convenience, and prompts the user to pick. Only
1778
+ * an explicit selection switches (via `rebind`); cancel stays put. The user
1779
+ * interaction IS the compliance line — there is NO automatic switch anywhere in
1780
+ * here. macOS-only for the actual switch; a non-TTY stdin prints the suggested
1781
+ * command instead of hanging on a prompt no one can answer.
1782
+ */
1783
+ async function rebindDialog(running) {
1784
+ const others = listProfiles("claude").filter((n) => n !== running);
1785
+ const candidates = [];
1786
+ for (const n of others) {
1787
+ // Same usage read path `status` uses (daemon cache when fresh, else the
1788
+ // OAuth /usage endpoint); null when unreadable — the row shows no %.
1789
+ candidates.push({ name: n, snapshot: await claudeSnapshot(n), label: labelFor("claude", n) });
1790
+ }
1791
+ const dialog = buildRebindDialog(candidates, running);
1792
+ const rows = selectableRows(dialog);
1793
+ if (rows.length === 0) {
1794
+ die(`no other claude profile to rebind "${running}" to — add one with \`agent-switch add <name>\`.`);
1795
+ }
1796
+ // Non-interactive stdin: never hang. Print the pre-selected profile + the exact
1797
+ // command that performs the switch, then return (no switch — the user has to run it).
1798
+ if (!process.stdin.isTTY) {
1799
+ const sug = dialog.suggestedProfile;
1800
+ if (sug) {
1801
+ console.error(`Suggested profile (most headroom): ${sug}`);
1802
+ console.error(`Switch "${running}" to it with: agent-switch rebind ${sug} --profile ${running}`);
1803
+ }
1804
+ else {
1805
+ console.error(`No usage data to pre-select a profile. Switch with: agent-switch rebind <account> --profile ${running}`);
1806
+ }
1807
+ return;
1808
+ }
1809
+ // The actual switch is macOS-only (the Linux/Windows credential backend is
1810
+ // unproven — spike R0.1). Refuse before prompting rather than after a pick.
1811
+ if (process.platform !== "darwin") {
1812
+ die("rebind is macOS-only for now (the Linux/Windows credential backend is unproven — spike R0.1).");
1813
+ }
1814
+ console.log(`Rebind "${running}" to which account? A running Claude session adopts it on its next message.`);
1815
+ rows.forEach((r, i) => {
1816
+ const marker = r.profile === dialog.suggestedProfile ? " ← suggested (most headroom)" : "";
1817
+ const label = r.label ? ` [${r.label}]` : "";
1818
+ const util = r.maxUtil === null ? "usage n/a" : `${r.maxUtil}% used`;
1819
+ console.log(` ${i + 1}) ${(r.profile + label).padEnd(24)} ${util}${marker}`);
1820
+ });
1821
+ const enterHint = dialog.suggestedProfile ?? "(no suggestion)";
1822
+ console.log(` [enter] = ${enterHint} · q = cancel (stay on "${running}")`);
1823
+ const SIGINT = "\u0003"; // sentinel distinct from empty input (empty = accept the suggestion)
1824
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1825
+ const answer = await new Promise((resolve) => {
1826
+ rl.on("SIGINT", () => resolve(SIGINT)); // Ctrl-C -> cancel, no switch
1827
+ rl.question("> ", (a) => resolve(a));
1828
+ });
1829
+ rl.close();
1830
+ if (answer === SIGINT) {
1831
+ console.log(`\nStayed on "${running}" — no switch.`);
1832
+ return;
1833
+ }
1834
+ const choice = resolveRebindChoice(answer, dialog);
1835
+ if (choice.kind === "cancel") {
1836
+ console.log(`Stayed on "${running}" — no switch.`);
1837
+ return;
1838
+ }
1839
+ if (choice.kind === "invalid") {
1840
+ console.log(`No valid selection — stayed on "${running}".`);
1841
+ return;
1842
+ }
1843
+ try {
1844
+ const r = await rebind({ account: choice.profile, profile: running });
1845
+ console.log(r.uxNote);
1846
+ }
1847
+ catch (e) {
1848
+ die(e.message);
1849
+ }
1850
+ }
1851
+ /**
1852
+ * `agent-switch rebind <account> [--profile <p>]` — point a RUNNING profile's
1853
+ * credential store at another account, so a live Claude session adopts it on its
1854
+ * next message (macOS; the user-interaction switch, ADR-003). `--restore` reverses
1855
+ * it. With NO account and NO `--restore`, opens the interactive limit dialog
1856
+ * ({@link rebindDialog}). Claude only; the target + running profiles must be logged in.
1857
+ */
1858
+ async function cmdRebind(providerId, providerExplicit, account, flags) {
1859
+ if (providerExplicit && providerId !== "claude")
1860
+ die(`rebind supports claude only (not ${providerId})`);
1861
+ const profile = typeof flags.profile === "string" ? flags.profile : activeFor("claude") ?? undefined;
1862
+ if (flags.reset) {
1863
+ // Kill-switch reset (ADR-003): clear `disabled` + the failure counter so a
1864
+ // circuit-breaker trip (or a manual disable) no longer refuses rebind.
1865
+ resetRebindKillSwitch();
1866
+ console.log("rebind re-enabled — circuit-breaker cleared (disabled=false, consecutiveFailures=0).");
1867
+ return;
1868
+ }
1869
+ if (flags.restore) {
1870
+ if (!profile)
1871
+ die("usage: agent-switch rebind --restore [--profile <p>] — no active claude profile to restore");
1872
+ requireProfile("claude", profile, "rebind --restore --profile");
1873
+ try {
1874
+ const r = await restoreRebind({ profile });
1875
+ console.log(`Restored "${r.restoredProfile}" to its own account (was rebound to "${r.wasBoundTo}").`);
1876
+ }
1877
+ catch (e) {
1878
+ die(e.message);
1879
+ }
1880
+ return;
1881
+ }
1882
+ if (!account) {
1883
+ // No account + no --restore → the interactive limit dialog (Phase 4). The
1884
+ // switch is user-interaction-gated: the picker pre-selects the best-headroom
1885
+ // profile as a convenience, but only an explicit pick switches.
1886
+ if (!profile)
1887
+ die("no running claude profile — pass --profile <name> or set one active with `asw <name>`.");
1888
+ requireProfile("claude", profile, "rebind --profile");
1889
+ return rebindDialog(profile);
1890
+ }
1891
+ if (!profile)
1892
+ die("no running claude profile — pass --profile <name> or set one active with `asw <name>`.");
1893
+ requireProfile("claude", account, "rebind");
1894
+ requireProfile("claude", profile, "rebind --profile");
1895
+ try {
1896
+ const r = await rebind({ account, profile });
1897
+ console.log(r.uxNote);
1898
+ }
1899
+ catch (e) {
1900
+ die(e.message);
1901
+ }
1902
+ }
1670
1903
  // ---------- main -------------------------------------------------------------
1671
1904
  async function main() {
1672
1905
  const argv = process.argv.slice(2);
@@ -1681,7 +1914,9 @@ async function main() {
1681
1914
  // profiles) skip it.
1682
1915
  if (cmd === "__hook-event")
1683
1916
  return cmdHookEvent(); // internal, silent, no migration
1684
- if (cmd && !["help", "--help", "-h", "shellenv"].includes(cmd)) {
1917
+ // A bare `agent-switch` (no command) launches the GUI, which reads profiles —
1918
+ // so it runs the migration too. Only pure help / shellenv skip it.
1919
+ if (cmd === undefined || !["help", "--help", "-h", "shellenv"].includes(cmd)) {
1685
1920
  const moved = migrateLegacyLayout();
1686
1921
  if (moved.length > 0) {
1687
1922
  // stderr, not stdout: `dir` is machine-consumed by the shell wrapper
@@ -1696,7 +1931,7 @@ async function main() {
1696
1931
  case "use": return cmdUse(providerId, positional[0]);
1697
1932
  case "deactivate": return cmdDeactivate(providerId);
1698
1933
  case "label": return cmdLabel(providerId, positional[0], positional[1]);
1699
- case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags, positional[1]);
1934
+ case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags);
1700
1935
  case "reset": return cmdReset(providerId, positional[0]);
1701
1936
  case "rename": return cmdRename(providerId, positional[0], positional[1]);
1702
1937
  case "providers": return cmdProviders(providerId, providerExplicit, positional[0], flags);
@@ -1726,6 +1961,7 @@ async function main() {
1726
1961
  case "unmap": return cmdUnmap(providerExplicit ? providerId : undefined, positional[0]);
1727
1962
  case "mappings": return cmdMappings();
1728
1963
  case "share": return cmdShare(positional[0], rest.slice(1));
1964
+ case "config": return cmdConfig(positional[0], positional[1], flags);
1729
1965
  case "sessions":
1730
1966
  if (positional[0] === "rm")
1731
1967
  return cmdSessionsRm(providerId, providerExplicit, positional[1], flags);
@@ -1738,18 +1974,32 @@ async function main() {
1738
1974
  case "alerts": return cmdAlerts(positional[0], flags);
1739
1975
  case "compact": return cmdCompact(providerId, positional[0], flags);
1740
1976
  case "takeover": return cmdTakeover(providerId, providerExplicit, positional[0], flags);
1977
+ case "rebind": return cmdRebind(providerId, providerExplicit, positional[0], flags);
1741
1978
  case "web": return cmdWeb(positional[0]);
1742
1979
  case "remove":
1743
1980
  case "rm": return cmdRemove(providerId, positional[0], !!flags.force);
1744
1981
  case "shellenv": return cmdShellenv(flags.shell ?? positional[0]);
1745
1982
  case "service": return cmdService(positional[0]);
1983
+ case "usage": return cmdUsageHistory(providerId, positional[0], flags);
1984
+ case "tooling":
1985
+ if (positional[0] === "install" || positional[0] === "upgrade") {
1986
+ return cmdToolingAction(positional[0], positional[1]);
1987
+ }
1988
+ if (positional[0] !== undefined) {
1989
+ die(`usage: agent-switch tooling [install|upgrade <${TOOL_IDS.join("|")}>] [--json]`);
1990
+ }
1991
+ return cmdTooling(!!flags.json);
1746
1992
  case "doctor": return process.exit(await runDoctor());
1747
1993
  case "help":
1748
1994
  case "--help":
1749
1995
  case "-h": return usage();
1996
+ // A bare `agent-switch` (no command) launches the GUI; an unknown command
1997
+ // still prints usage and exits non-zero.
1750
1998
  default:
1999
+ if (cmd === undefined)
2000
+ return cmdGui();
1751
2001
  usage();
1752
- process.exit(cmd ? 1 : 0);
2002
+ process.exit(1);
1753
2003
  }
1754
2004
  }
1755
2005
  main().catch((err) => die(String(err?.message ?? err)));
package/dist/keychain.js CHANGED
@@ -48,6 +48,20 @@ export function deletePassword(service) {
48
48
  return false;
49
49
  return (security(["delete-generic-password", "-a", os.userInfo().username, "-s", service]) !== null);
50
50
  }
51
+ /**
52
+ * Upsert a generic password entry (`-U` overwrites an existing value). Returns
53
+ * true on success. This is the ONE credential-store WRITE in agent-switch — used
54
+ * ONLY by `src/rebind.ts` under Claude Code's lock (ADR-003). Elsewhere the
55
+ * credential store stays read-only.
56
+ *
57
+ * Note: the value is passed on argv (as `security` requires with `-w`), so it is
58
+ * briefly visible to `ps` — the same trade-off Claude Code's own tooling makes.
59
+ */
60
+ export function addPassword(service, value) {
61
+ if (!isMac())
62
+ return false;
63
+ return security(["add-generic-password", "-U", "-a", os.userInfo().username, "-s", service, "-w", value]) !== null;
64
+ }
51
65
  /** The live credential of the default ~/.claude install (keychain, macOS). */
52
66
  export function readDefaultCredential() {
53
67
  return getPassword(DEFAULT_SERVICE);
@@ -33,11 +33,16 @@ function writeNotifications(list, file) {
33
33
  * Append an event (kept newest-last). Deduplicates a byte-identical
34
34
  * kind+title+message against the most recent entry within DEDUP_WINDOW_MS.
35
35
  * Returns the created notification, or `null` when it was deduplicated.
36
+ *
37
+ * `dedupe: false` skips that collapse — for session-awareness events (Claude
38
+ * finished / is waiting) where each occurrence is a distinct moment the user
39
+ * wants surfaced, so a solo session's repeated turns are never swallowed.
36
40
  */
37
- export function appendNotification(event, now = Date.now(), file = NOTIFICATIONS_FILE) {
41
+ export function appendNotification(event, now = Date.now(), file = NOTIFICATIONS_FILE, dedupe = true) {
38
42
  const list = readNotifications(file);
39
43
  const last = list[list.length - 1];
40
- if (last &&
44
+ if (dedupe &&
45
+ last &&
41
46
  last.kind === event.kind &&
42
47
  last.title === event.title &&
43
48
  last.message === event.message &&
package/dist/profiles.js CHANGED
@@ -13,7 +13,6 @@ export function isProfileLabel(v) {
13
13
  return typeof v === "string" && PROFILE_LABELS.includes(v);
14
14
  }
15
15
  export const DEFAULT_AUTOSWITCH = { enabled: false, threshold: 95, tag: "all" };
16
- export const DEFAULT_SWITCH_STRATEGY = "reset-first";
17
16
  /**
18
17
  * Providers enabled out of the box. Everything else is available but off by
19
18
  * default — the user opts it in from the Providers settings tab. A provider that
@@ -21,6 +20,7 @@ export const DEFAULT_SWITCH_STRATEGY = "reset-first";
21
20
  * upgrading never hides an account the user already set up.
22
21
  */
23
22
  export const PROVIDERS_ON_BY_DEFAULT = ["claude", "codex"];
23
+ export const DEFAULT_REBIND_STATE = { disabled: false, consecutiveFailures: 0 };
24
24
  function emptyActive() {
25
25
  return { claude: null, codex: null, antigravity: null };
26
26
  }
@@ -155,6 +155,11 @@ function normalizeBinaryPaths(raw) {
155
155
  out[p] = r[p];
156
156
  return out;
157
157
  }
158
+ function normalizeRebindState(raw) {
159
+ const r = (raw ?? {});
160
+ const consecutiveFailures = typeof r.consecutiveFailures === "number" && r.consecutiveFailures >= 0 ? Math.floor(r.consecutiveFailures) : 0;
161
+ return { disabled: r.disabled === true, consecutiveFailures };
162
+ }
158
163
  export function readState() {
159
164
  try {
160
165
  const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
@@ -162,29 +167,21 @@ export function readState() {
162
167
  const autoSwitch = normalizeAutoSwitchMap(raw?.autoSwitch);
163
168
  const providers = normalizeProvidersMap(raw?.providers);
164
169
  const binaryPaths = normalizeBinaryPaths(raw?.binaryPaths);
165
- const switchStrategy = raw?.switchStrategy === "rotation-first" ? "rotation-first" : DEFAULT_SWITCH_STRATEGY;
166
170
  const osNotifications = raw?.osNotifications === true;
171
+ const rebind = normalizeRebindState(raw?.rebind);
167
172
  // v1: { active: "<name>" } — a single Claude profile.
168
173
  if (typeof raw?.active === "string") {
169
- return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, switchStrategy, osNotifications };
174
+ return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
170
175
  }
171
176
  if (raw?.active && typeof raw.active === "object") {
172
- return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, switchStrategy, osNotifications };
177
+ return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
173
178
  }
174
- return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, switchStrategy, osNotifications };
179
+ return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
175
180
  }
176
181
  catch {
177
182
  /* absent / unparsable → default */
178
183
  }
179
- return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, switchStrategy: DEFAULT_SWITCH_STRATEGY, osNotifications: false };
180
- }
181
- export function readSwitchStrategy() {
182
- return readState().switchStrategy;
183
- }
184
- export function setSwitchStrategy(strategy) {
185
- const state = readState();
186
- state.switchStrategy = strategy;
187
- writeState(state);
184
+ return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE } };
188
185
  }
189
186
  export function readOsNotifications() {
190
187
  return readState().osNotifications;
@@ -194,6 +191,21 @@ export function setOsNotifications(on) {
194
191
  state.osNotifications = on;
195
192
  writeState(state);
196
193
  }
194
+ // ---------- rebind kill-switch / circuit-breaker (ADR-003) ----------
195
+ /** Current rollback/circuit-breaker state for the `rebind` write path. */
196
+ export function readRebindKillSwitch() {
197
+ return readState().rebind;
198
+ }
199
+ /** Persist the rebind kill-switch state (breaker counter + disabled flag). */
200
+ export function writeRebindKillSwitch(next) {
201
+ const state = readState();
202
+ state.rebind = normalizeRebindState(next);
203
+ writeState(state);
204
+ }
205
+ /** Re-enable `rebind`: clear `disabled` + reset the failure counter (`--reset`). */
206
+ export function resetRebindKillSwitch() {
207
+ writeRebindKillSwitch({ ...DEFAULT_REBIND_STATE });
208
+ }
197
209
  export function writeState(state) {
198
210
  ensureRoot();
199
211
  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });