@event4u/agent-switch 1.7.0 → 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,22 +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";
26
30
  import { TOOL_IDS, checkTooling, formatToolingLines, runToolAction } from "./tooling.js";
27
- import { readHistory } from "./history.js";
31
+ import { readHistory, recordHistorySample } from "./history.js";
28
32
  import { launchGui } from "./gui-launch.js";
29
33
  import { checkForUpdate, selfUpdate } from "./updates.js";
30
34
  import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, } from "./mappings.js";
31
- 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";
32
37
  import { formatSnapshot, parseUsage } from "./usage.js";
33
38
  import { readCodexUsage } from "./codex-usage.js";
34
39
  import { redeemResetCredit } from "./codex-reset.js";
@@ -272,8 +277,7 @@ async function claudeSnapshot(name) {
272
277
  if (cached)
273
278
  return cached;
274
279
  }
275
- const creds = readProfileCredential(configDir("claude", name));
276
- const token = creds ? accessTokenOf(creds) : null;
280
+ const token = freshAccessToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")));
277
281
  if (!token)
278
282
  return null;
279
283
  const raw = await fetchUsage(token);
@@ -295,6 +299,12 @@ async function cmdStatus(providerId, name, json = false) {
295
299
  : pid === "codex"
296
300
  ? await readCodexUsage(configDir("codex", active))
297
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);
298
308
  const wc = worstLiveContext(pid, active);
299
309
  const context = wc
300
310
  ? { sessionId: wc.sessionId, pct: wc.pct, contextTokens: wc.contextTokens, windowTokens: wc.windowTokens, model: wc.model, confidence: wc.confidence }
@@ -328,8 +338,7 @@ async function cmdStatus(providerId, name, json = false) {
328
338
  console.log(" (no usage readout for this provider — shows own usage only where available)");
329
339
  continue;
330
340
  }
331
- const creds = readProfileCredential(configDir("claude", n));
332
- const token = creds ? accessTokenOf(creds) : null;
341
+ const token = freshAccessToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")));
333
342
  if (!token) {
334
343
  console.log(" (credential not readable — profile may not have run yet)");
335
344
  continue;
@@ -451,6 +460,55 @@ function cmdShare(mode, flags = []) {
451
460
  die("usage: agent-switch share on|sync|off|status [--history] [--source <profile|default>]");
452
461
  }
453
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
+ }
454
512
  // ---------- session inventory + takeover (Claude; Codex lands per G0.3) ------
455
513
  /** Relative age like "3m" / "2h" / "5d" for the sessions table. */
456
514
  function ageOf(mtimeMs) {
@@ -660,6 +718,16 @@ function cmdHookEvent() {
660
718
  at: new Date().toISOString(),
661
719
  };
662
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
+ }
663
731
  }
664
732
  catch {
665
733
  // never throw from a hook
@@ -1212,7 +1280,8 @@ function cmdLabel(providerId, name, label) {
1212
1280
  setLabel(providerId, name, label);
1213
1281
  console.log(`Labeled ${providerId}/${name} as ${label}.`);
1214
1282
  }
1215
- /** 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. */
1216
1285
  /** Rename a profile (move its config dir + carry active/label/mappings). */
1217
1286
  function cmdRename(providerId, from, to) {
1218
1287
  const n = requireProfile(providerId, from, "rename");
@@ -1256,23 +1325,7 @@ async function cmdReset(providerId, name) {
1256
1325
  die(`reset failed: ${r.reason ?? "unknown"}`);
1257
1326
  }
1258
1327
  }
1259
- function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
1260
- // `autoswitch strategy [reset-first|rotation-first]` — global switch behaviour.
1261
- if (mode === "strategy") {
1262
- if (value === "reset-first" || value === "rotation-first") {
1263
- setSwitchStrategy(value);
1264
- }
1265
- else if (value !== undefined) {
1266
- die("usage: agent-switch autoswitch strategy [reset-first|rotation-first]");
1267
- }
1268
- if (flags.json) {
1269
- console.log(JSON.stringify({ strategy: readSwitchStrategy() }));
1270
- }
1271
- else {
1272
- console.log(`Auto-switch strategy: ${readSwitchStrategy()}.`);
1273
- }
1274
- return;
1275
- }
1328
+ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}) {
1276
1329
  const thresholdFlag = flags.threshold;
1277
1330
  const threshold = typeof thresholdFlag === "string" ? Number(thresholdFlag) : undefined;
1278
1331
  if (threshold !== undefined && (!Number.isFinite(threshold) || threshold < 1 || threshold > 100)) {
@@ -1301,22 +1354,12 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
1301
1354
  ...(tag !== undefined ? { tag } : {}),
1302
1355
  });
1303
1356
  const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
1304
- 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}).`);
1305
1358
  if (cfg.enabled) {
1306
- // Usage-policy disclosure — restored deliberately. Automated rotation on
1307
- // quota signals pools separate subscriptions to route around per-account
1308
- // rate limits; a prior review found this can violate the provider's usage
1309
- // policy. Enabling is the user's decision, but the risk must be stated.
1310
- console.warn("⚠️ Usage-policy warning: quota-triggered rotation across accounts can violate\n" +
1311
- " the providers' usage policies — it pools separate subscriptions to route around\n" +
1312
- " per-account rate limits. An internal review unanimously recommended removing it;\n" +
1313
- " it ships off-by-default anyway. See the README before enabling. (Per-context\n" +
1314
- " profile switching — private / work / client — is unaffected; this applies only\n" +
1315
- " to automated quota-driven rotation.)");
1316
- const strat = readSwitchStrategy();
1317
- console.log(`Strategy: ${strat}${strat === "reset-first" ? " (redeem a banked reset before switching, Codex)" : ""}.\n` +
1318
- `The daemon moves the active ${providerId} profile to the account with the most headroom once the\n` +
1319
- "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.");
1320
1363
  }
1321
1364
  return;
1322
1365
  }
@@ -1326,15 +1369,14 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
1326
1369
  console.log(JSON.stringify(readAutoSwitchAll()));
1327
1370
  return;
1328
1371
  }
1329
- console.log(`strategy: ${readSwitchStrategy()}`);
1330
1372
  for (const p of providerExplicit ? [providerId] : PROVIDER_IDS) {
1331
1373
  const cfg = readAutoSwitch(p);
1332
1374
  const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
1333
- 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}).`);
1334
1376
  }
1335
1377
  return;
1336
1378
  }
1337
- 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]");
1338
1380
  }
1339
1381
  /**
1340
1382
  * Enable/disable a provider's surfaces (cli / ui), or show the current setting.
@@ -1551,8 +1593,8 @@ async function cmdGui() {
1551
1593
  // Launch the tray/menubar GUI — downloads its prebuilt artifact from the
1552
1594
  // matching GitHub Release on first use, then caches it (see gui-launch.ts).
1553
1595
  try {
1554
- await launchGui();
1555
- 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.");
1556
1598
  }
1557
1599
  catch (err) {
1558
1600
  die(String(err?.message ?? err));
@@ -1683,6 +1725,7 @@ function usage() {
1683
1725
  console.log(`agent-switch — switch accounts for Claude Code, Codex, and Antigravity (macOS · Linux · Windows)
1684
1726
 
1685
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).
1686
1729
 
1687
1730
  agent-switch add [--provider P] <name> create a profile and log it in
1688
1731
  agent-switch import [--provider P] <name> migrate the default install (no re-login)
@@ -1698,26 +1741,28 @@ Provider defaults to claude; pass --provider codex|antigravity for the others.
1698
1741
  agent-switch unmap [--provider P] [dir] remove a directory mapping
1699
1742
  agent-switch mappings list directory mappings
1700
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)
1701
1745
  agent-switch sessions [profile] [--recent N] [--json] recent + live Claude sessions per profile (with context %)
1702
1746
  agent-switch hooks install|uninstall|status [profile] manage lifecycle push hooks in Claude settings.json
1703
1747
  agent-switch alerts on|off|status [--threshold 80,95] record context/usage crossings to the notification log (off by default)
1704
1748
  agent-switch compact <profile> [--clear] [--dry-run] [--force] type /compact (or /clear) into the profile's managed tmux pane
1705
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)
1706
1751
  agent-switch web <name> claude.ai in a persistent browser (Claude)
1707
1752
  agent-switch remove [--provider P] <name> [--force] delete a profile
1708
1753
  agent-switch label [--provider P] <name> [Work|Personal|Other|none] tag a profile
1709
- 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)
1710
1755
  agent-switch reset <profile> --provider codex redeem one banked Codex rate-limit reset
1711
1756
  agent-switch rename <old> <new> [--provider P] rename a profile (name & keep its tag)
1712
1757
  agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] enable/disable a provider (default: Claude + Codex)
1713
- 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)
1714
1759
  agent-switch check-update [--json] check GitHub Releases for a newer version
1715
1760
  agent-switch update self-update to the latest version (via npm)
1716
1761
  agent-switch apps list launchable GUI apps (macOS)
1717
1762
  agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
1718
1763
  agent-switch shellenv [--shell zsh|bash|fish|powershell] shell integration
1719
1764
  agent-switch service run|start|stop|status|install|uninstall background usage daemon
1720
- agent-switch notifications [clear] [--json] recent notifications (auto-switches, fetch failures)
1765
+ agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
1721
1766
  agent-switch notify --kind K --title T --message M [--json] record a notification event
1722
1767
  agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications (default off)
1723
1768
  agent-switch uninstall [--force] remove all agent-switch data + daemon
@@ -1726,6 +1771,135 @@ Provider defaults to claude; pass --provider codex|antigravity for the others.
1726
1771
  agent-switch tooling install|upgrade <tool> install/upgrade an ecosystem tool (agent-config, rtk, claude, codex)
1727
1772
  agent-switch doctor per-OS, per-provider self-check`);
1728
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
+ }
1729
1903
  // ---------- main -------------------------------------------------------------
1730
1904
  async function main() {
1731
1905
  const argv = process.argv.slice(2);
@@ -1740,7 +1914,9 @@ async function main() {
1740
1914
  // profiles) skip it.
1741
1915
  if (cmd === "__hook-event")
1742
1916
  return cmdHookEvent(); // internal, silent, no migration
1743
- 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)) {
1744
1920
  const moved = migrateLegacyLayout();
1745
1921
  if (moved.length > 0) {
1746
1922
  // stderr, not stdout: `dir` is machine-consumed by the shell wrapper
@@ -1755,7 +1931,7 @@ async function main() {
1755
1931
  case "use": return cmdUse(providerId, positional[0]);
1756
1932
  case "deactivate": return cmdDeactivate(providerId);
1757
1933
  case "label": return cmdLabel(providerId, positional[0], positional[1]);
1758
- case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags, positional[1]);
1934
+ case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags);
1759
1935
  case "reset": return cmdReset(providerId, positional[0]);
1760
1936
  case "rename": return cmdRename(providerId, positional[0], positional[1]);
1761
1937
  case "providers": return cmdProviders(providerId, providerExplicit, positional[0], flags);
@@ -1785,6 +1961,7 @@ async function main() {
1785
1961
  case "unmap": return cmdUnmap(providerExplicit ? providerId : undefined, positional[0]);
1786
1962
  case "mappings": return cmdMappings();
1787
1963
  case "share": return cmdShare(positional[0], rest.slice(1));
1964
+ case "config": return cmdConfig(positional[0], positional[1], flags);
1788
1965
  case "sessions":
1789
1966
  if (positional[0] === "rm")
1790
1967
  return cmdSessionsRm(providerId, providerExplicit, positional[1], flags);
@@ -1797,6 +1974,7 @@ async function main() {
1797
1974
  case "alerts": return cmdAlerts(positional[0], flags);
1798
1975
  case "compact": return cmdCompact(providerId, positional[0], flags);
1799
1976
  case "takeover": return cmdTakeover(providerId, providerExplicit, positional[0], flags);
1977
+ case "rebind": return cmdRebind(providerId, providerExplicit, positional[0], flags);
1800
1978
  case "web": return cmdWeb(positional[0]);
1801
1979
  case "remove":
1802
1980
  case "rm": return cmdRemove(providerId, positional[0], !!flags.force);
@@ -1815,9 +1993,13 @@ async function main() {
1815
1993
  case "help":
1816
1994
  case "--help":
1817
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.
1818
1998
  default:
1999
+ if (cmd === undefined)
2000
+ return cmdGui();
1819
2001
  usage();
1820
- process.exit(cmd ? 1 : 0);
2002
+ process.exit(1);
1821
2003
  }
1822
2004
  }
1823
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 });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The limit-dialog model (Phase 4) — the PURE, testable core behind the CLI's
3
+ * interactive `agent-switch rebind` picker. It computes the switch-target rows
4
+ * and the pre-selected best-headroom suggestion; it performs NO switch and does
5
+ * NO I/O. The switch itself is always a user interaction (§ Out of scope,
6
+ * road-to-live-rebind.md): this module only shapes what the user is shown and
7
+ * maps their keystroke to a target. The credential write lives in rebind.ts.
8
+ *
9
+ * The suggestion reuses the usage helper `maxUtilization` — the same ranking
10
+ * `pickSwitchTarget` uses (lowest max-utilization = most headroom); no new
11
+ * ranking is invented. Unlike `pickSwitchTarget` it is NOT threshold-gated: the
12
+ * user opened the dialog on demand, so a best-headroom target is always
13
+ * pre-selected as a convenience (the pick, not the suggestion, is the switch).
14
+ */
15
+ import { maxUtilization } from "./usage.js";
16
+ /**
17
+ * Build the dialog model. `runningProfile` is the profile the live session
18
+ * serves; it is excluded from the suggestion (and, marked `isRunning`, from the
19
+ * selectable rows). Pure — takes plain data, returns plain data.
20
+ */
21
+ export function buildRebindDialog(candidates, runningProfile) {
22
+ const rows = candidates.map((c) => ({
23
+ profile: c.name,
24
+ label: c.label ?? null,
25
+ maxUtil: c.snapshot ? maxUtilization(c.snapshot) : null,
26
+ isRunning: c.name === runningProfile,
27
+ }));
28
+ // Suggestion = lowest known max-utilization among the non-running rows (most
29
+ // headroom). Same ranking as pickSwitchTarget's `m < best.max`, reusing the
30
+ // maxUtilization helper — no new ranking invented. Ties keep the first row.
31
+ let best = null;
32
+ for (const r of rows) {
33
+ if (r.isRunning || r.maxUtil === null)
34
+ continue;
35
+ if (best === null || r.maxUtil < best.maxUtil)
36
+ best = r;
37
+ }
38
+ return { rows, suggestedProfile: best ? best.profile : null };
39
+ }
40
+ /** The selectable rows (non-running), in dialog order — the list the CLI
41
+ * numbers 1..N and that {@link resolveRebindChoice} indexes against. */
42
+ export function selectableRows(dialog) {
43
+ return dialog.rows.filter((r) => !r.isRunning);
44
+ }
45
+ /**
46
+ * Map a raw picker keystroke to a target profile or a control action — the pure
47
+ * selection→target mapping the readline loop delegates to. `q`/`c` (any case) =
48
+ * cancel; empty = the pre-selected suggestion (or invalid when none); a 1-based
49
+ * index into the selectable rows = that profile; anything else = invalid. Never
50
+ * switches — it only resolves intent.
51
+ */
52
+ export function resolveRebindChoice(input, dialog) {
53
+ const trimmed = input.trim().toLowerCase();
54
+ if (trimmed === "q" || trimmed === "c")
55
+ return { kind: "cancel" };
56
+ const selectable = selectableRows(dialog);
57
+ if (trimmed === "") {
58
+ return dialog.suggestedProfile ? { kind: "target", profile: dialog.suggestedProfile } : { kind: "invalid" };
59
+ }
60
+ const n = Number(trimmed);
61
+ if (Number.isInteger(n) && n >= 1 && n <= selectable.length) {
62
+ return { kind: "target", profile: selectable[n - 1].profile };
63
+ }
64
+ return { kind: "invalid" };
65
+ }