@event4u/agent-switch 2.0.0 → 2.1.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/README.md CHANGED
@@ -29,6 +29,39 @@ Most naive switchers snapshot the macOS Keychain entry (`Claude Code-credentials
29
29
 
30
30
  The same restraint governs the whole tool: no proxy, no traffic interception, no fingerprinting, no prompt rewriting — see [NON-GOALS.md](NON-GOALS.md) for the explicit list and the alternatives this ecosystem offers instead.
31
31
 
32
+ ## One profile, one session
33
+
34
+ Because `agent-switch use` sets a **global** pointer that every shell re-reads,
35
+ nothing used to stop two terminals from starting `claude` on the same account —
36
+ sharing one set of rate limits and both refreshing one rotating OAuth token,
37
+ while each terminal looked like a separate account.
38
+
39
+ The single-load guard refuses that second start and says which session already
40
+ holds the profile and which accounts are free:
41
+
42
+ ```
43
+ $ claude
44
+ agent-switch: profile "work" is already loaded by a running claude session.
45
+
46
+ pid 41234 started 12m ago ~/projects/foo
47
+
48
+ Two sessions on one account share its rate limits, and both refresh the
49
+ same rotating OAuth token.
50
+
51
+ Ways forward:
52
+ agent-switch use privat switch to a free account — also free: event4u
53
+ AGENT_SWITCH_ALLOW_DUPLICATE=1 claude start it anyway, this once
54
+ agent-switch guard warn warn instead of refusing, from now on
55
+ agent-switch guard off turn the guard off entirely
56
+ ```
57
+
58
+ It never gets in the way where it has no business being: a `claude` started
59
+ *inside* a running session is a child, not a duplicate; a crashed session leaves
60
+ nothing behind that can lock you out; and if `agent-switch` itself is missing or
61
+ broken, the wrapper falls straight through to the real binary. Set the policy
62
+ with `agent-switch guard <block|warn|off>` — see the
63
+ [CLI reference](https://event4u-app.github.io/agent-switch/reference/cli/#single-load-guard).
64
+
32
65
  ## Install
33
66
 
34
67
  **npm / npx** (any OS — needs Node ≥ 20). The package is published as
@@ -36,9 +36,19 @@ const DENY_SET = new Set([".claude.json", ".credentials.json", "plugins", "sessi
36
36
  export const MAX_ENTRIES = 5000;
37
37
  /** Bound untrusted input: refuse a single decoded file larger than this. */
38
38
  export const MAX_FILE_BYTES = 25 * 1024 * 1024;
39
- export const FULL_REFUSAL = "--full (bundling live OAuth tokens) is intentionally not implemented — it is an account-takeover vector; export is config-only by design.";
39
+ export const FULL_REFUSAL = "--full (bundling live OAuth tokens) is intentionally not implemented — it is an account-takeover vector; export is config-only by design. " +
40
+ "To move an account to another machine: `agent-switch config export` (config-only) → `config import` on the target → `agent-switch <profile>` and re-run `claude` to log in there — a fresh login is machine-specific by design.";
40
41
  /** Refuse the `--full` (credential-bundling) variant. Throws so callers can
41
- * surface it via `die`; kept here because it is a config-transfer policy. */
42
+ * surface it via `die`; kept here because it is a config-transfer policy.
43
+ *
44
+ * Re-affirmed 2026-07-29: an AI-council threat-modelled review of whether to
45
+ * build `--full` (with mandatory encryption) concluded KEEP IT REFUSED — even
46
+ * AES-256-GCM + Argon2id cannot fix the threat-model shift (an exportable
47
+ * credential bundle is a persistent cloud-sync / malware / phishing exfil
48
+ * vector), the 30-second `config import → auth login` path covers the real
49
+ * need, and industry practice (aws / gh / kubectl) never exports credentials.
50
+ * Reversing this requires the repo owner's explicit sign-off, not agent
51
+ * convergence. */
42
52
  export function assertNotFull(full) {
43
53
  if (full)
44
54
  throw new Error(FULL_REFUSAL);
package/dist/doctor.js CHANGED
@@ -7,11 +7,11 @@
7
7
  */
8
8
  import { spawnSync } from "node:child_process";
9
9
  import * as fs from "node:fs";
10
- import { ROOT, activeFor, configDir, listProfiles, profileExists } from "./profiles.js";
10
+ import { ROOT, activeFor, configDir, listProfiles, profileExists, readGuard } from "./profiles.js";
11
11
  import { allProviders } from "./providers.js";
12
12
  import { credentialStore } from "./credentials.js";
13
13
  import { sharedLinkHealth } from "./share.js";
14
- import { checkAuth } from "./api.js";
14
+ import { checkAuth, liveSessionPids } from "./api.js";
15
15
  import { checkTooling, doctorToolingLine } from "./tooling.js";
16
16
  const OK = "✅";
17
17
  const WARN = "⚠️";
@@ -113,6 +113,27 @@ export async function runDoctor() {
113
113
  line(WARN, `claude/${n}: ${parts.join("; ")} — run \`agent-switch share sync\`.`);
114
114
  }
115
115
  }
116
+ // Single-load guard: the policy, and whether any profile is doubled RIGHT NOW.
117
+ // Reported even when the policy is `warn`/`off` — the point of doctor is to
118
+ // surface the state, and a silently tolerated duplicate is exactly the thing
119
+ // the user came here to find.
120
+ {
121
+ const policy = readGuard();
122
+ line(policy === "off" ? WARN : OK, `single-load guard: ${policy}${policy === "off" ? " — a profile can be loaded twice" : ""}.`);
123
+ const doubled = [];
124
+ for (const p of allProviders()) {
125
+ if (!p.hasLiveSessionSignal)
126
+ continue;
127
+ for (const n of listProfiles(p.id)) {
128
+ const count = liveSessionPids(configDir(p.id, n)).length;
129
+ if (count > 1)
130
+ doubled.push(`${p.id}/${n} (${count} sessions)`);
131
+ }
132
+ }
133
+ if (doubled.length > 0) {
134
+ line(WARN, ` loaded more than once: ${doubled.join(", ")} — they share one account's rate limits.`);
135
+ }
136
+ }
116
137
  // Ecosystem tooling (agent-config + rtk) — same detection module as
117
138
  // `agent-switch tooling`, never a second implementation. Both tools are
118
139
  // optional, so an unhealthy row is a WARN, never a blocking error.
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, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readBinaryPaths, setBinaryPath, ROOT, resetRebindKillSwitch, } from "./profiles.js";
18
+ import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readGuard, setGuard, readBinaryPaths, setBinaryPath, ROOT, resetRebindKillSwitch, } from "./profiles.js";
19
19
  import { PROVIDER_IDS, provider, isProviderInstalled, resolveBinary } from "./providers.js";
20
20
  import { rebind, restoreRebind } from "./rebind.js";
21
21
  import { buildRebindDialog, resolveRebindChoice, selectableRows } from "./rebind-dialog.js";
@@ -27,17 +27,19 @@ import { applySharing, removeSharing, syncSharing, sharedLinkHealth } from "./sh
27
27
  import { assertNotFull, exportConfig, importConfig } from "./config-transfer.js";
28
28
  import { detectShell, shellenvScript } from "./shellenv.js";
29
29
  import { runDoctor } from "./doctor.js";
30
- import { TOOL_IDS, checkTooling, formatToolingLines, runToolAction } from "./tooling.js";
30
+ import { TOOL_IDS, checkTooling, formatToolNotes, formatToolingLines, runToolAction, toolNotesRefusal, toolReleaseRepo, } from "./tooling.js";
31
+ import { releaseNoteBlocks } from "./release-notes.js";
31
32
  import { readHistory, recordHistorySample } from "./history.js";
32
33
  import { launchGui } from "./gui-launch.js";
33
- import { checkForUpdate, selfUpdate } from "./updates.js";
34
+ import { GUARD_POLICIES, GUARD_REFUSED_EXIT, OVERRIDE_ENV, guardVerdict, inspectLoad, isGuardPolicy, overrideFromEnv, renderGuardMessage, } from "./single-load.js";
35
+ import { checkForUpdate, fetchReleases, releasesNewerThan, selfUpdate } from "./updates.js";
34
36
  import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, } from "./mappings.js";
35
37
  import { checkAuth, fetchProfile, fetchUsage, liveSessionPids, } from "./api.js";
36
38
  import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
37
39
  import { formatSnapshot, parseUsage } from "./usage.js";
38
40
  import { readCodexUsage } from "./codex-usage.js";
39
41
  import { redeemResetCredit } from "./codex-reset.js";
40
- import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, listSessions, listCodexSessions, locateSession, locateCodexSession, markLive, restoreSession, sharedHistory, sweepTrash, transferSession, transferCodexSession, trashedSessionExists, } from "./sessions.js";
42
+ import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, detectRunningClaudeProfile, listSessions, listCodexSessions, locateSession, locateCodexSession, markLive, restoreSession, sharedHistory, sweepTrash, transferSession, transferCodexSession, trashedSessionExists, } from "./sessions.js";
41
43
  import { isFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
42
44
  import { cmdService, serviceUninstall } from "./service.js";
43
45
  import { readContext, turnInFlight } from "./telemetry.js";
@@ -52,6 +54,46 @@ import { currentManagedSession, currentTmuxSessionName, newSessionArgs, readTmux
52
54
  const credentials = credentialStore();
53
55
  const CLAUDE_HOME = provider("claude").defaultConfigDir(); // ~/.claude
54
56
  const CLAUDE_JSON = path.join(HOME, ".claude.json");
57
+ /** Same-provider profiles that carry no live session — the concrete
58
+ * alternatives a refusal names, so the user can act on it without first
59
+ * running `list` themselves. */
60
+ function freeProfiles(providerId, exclude) {
61
+ return listProfiles(providerId).filter((n) => n !== exclude && liveSessionPids(configDir(providerId, n)).length === 0);
62
+ }
63
+ function checkSingleLoad(providerId, name, opts = {}) {
64
+ const p = provider(providerId);
65
+ // Early-out before touching the filesystem: `dir --guard` runs on every
66
+ // interactive start, so a disabled guard (or a provider with no live-session
67
+ // signal at all) must cost a state read and nothing more.
68
+ const policy = readGuard();
69
+ if (policy === "off" || !p.hasLiveSessionSignal) {
70
+ return { verdict: "allow", inspection: { sessions: [], loaded: false, nested: false }, message: null };
71
+ }
72
+ const cfg = configDir(providerId, name);
73
+ const inspection = inspectLoad(cfg, p.envVar);
74
+ const verdict = guardVerdict({
75
+ policy,
76
+ inspection,
77
+ overridden: opts.forced === true || overrideFromEnv(),
78
+ detectable: p.hasLiveSessionSignal,
79
+ });
80
+ return {
81
+ verdict,
82
+ inspection,
83
+ message: verdict === "allow"
84
+ ? null
85
+ : renderGuardMessage({
86
+ profile: name,
87
+ binary: p.binary,
88
+ verdict,
89
+ inspection,
90
+ offerForceFlag: opts.offerForceFlag === true,
91
+ context: opts.context ?? "start",
92
+ // Only worth computing on a message we are actually going to print.
93
+ alternatives: verdict === "refuse" ? freeProfiles(providerId, name) : [],
94
+ }),
95
+ };
96
+ }
55
97
  // ---------- launcher ---------------------------------------------------------
56
98
  function launch(p, name, args) {
57
99
  const home = configDir(p.id, name);
@@ -165,6 +207,14 @@ function cmdUse(providerId, name) {
165
207
  const id = identity(providerId, n);
166
208
  console.log(`Active ${providerId} profile: ${n}${id ? ` (${id})` : ""}`);
167
209
  console.log(`New \`${provider(providerId).binary}\` sessions use this profile. Running sessions are unaffected.`);
210
+ // `use` loads nothing — it states an intent — so it NEVER blocks. But it is
211
+ // the cheapest possible moment to tell the user that the profile they just
212
+ // pointed at is already busy, before they type the command that gets refused.
213
+ const check = checkSingleLoad(providerId, n, { context: "use" });
214
+ if (check.message) {
215
+ console.log("");
216
+ console.warn(check.message);
217
+ }
168
218
  }
169
219
  function cmdDeactivate(providerId) {
170
220
  const active = activeFor(providerId);
@@ -179,9 +229,18 @@ function cmdDeactivate(providerId) {
179
229
  function cmdRun(providerId, name, args) {
180
230
  const n = requireProfile(providerId, name, "run");
181
231
  const p = provider(providerId);
182
- // `--tmux` is an agent-switch flag, not a passthrough flag — strip it.
232
+ // `--tmux` and `--force` are agent-switch flags, not passthrough flags — strip
233
+ // them before the rest reaches the provider binary.
183
234
  const wantTmux = args.includes("--tmux");
184
- const passthrough = args.filter((a) => a !== "--tmux");
235
+ const forced = args.includes("--force");
236
+ const passthrough = args.filter((a) => a !== "--tmux" && a !== "--force");
237
+ // agent-switch spawns the binary itself here, so this is the strongest
238
+ // enforcement point: refuse before anything is launched.
239
+ const guard = checkSingleLoad(providerId, n, { forced, offerForceFlag: true });
240
+ if (guard.message)
241
+ console.error(guard.verdict === "refuse" ? `error: ${guard.message}` : guard.message);
242
+ if (guard.verdict === "refuse")
243
+ process.exit(GUARD_REFUSED_EXIT);
185
244
  if (wantTmux) {
186
245
  if (!tmuxAvailable()) {
187
246
  console.log("(--tmux unavailable — tmux not found or Windows; running normally.)");
@@ -204,8 +263,12 @@ function printProfileLine(providerId, name, showLive) {
204
263
  let live = "";
205
264
  if (showLive && providerId === "claude") {
206
265
  const pids = liveSessionPids(configDir("claude", name));
207
- if (pids.length > 0)
208
- live = ` [${pids.length} live session${pids.length > 1 ? "s" : ""}]`;
266
+ // More than one session on one account is the state the single-load guard
267
+ // exists to prevent, so it is marked rather than blending into the count.
268
+ if (pids.length > 1)
269
+ live = ` ⚠️ [loaded ${pids.length}× — ${pids.length} live sessions]`;
270
+ else if (pids.length === 1)
271
+ live = " [1 live session]";
209
272
  }
210
273
  console.log(`${mark} ${name.padEnd(16)} ${id}${live}`);
211
274
  }
@@ -362,17 +425,37 @@ async function cmdStatus(providerId, name, json = false) {
362
425
  }
363
426
  }
364
427
  }
365
- function cmdDir(providerId) {
366
- // Precedence: directory mapping (nearest ancestor of CWD) > active-for-provider.
428
+ /**
429
+ * Resolve which profile a shell wrapper should load: directory mapping (nearest
430
+ * ancestor of CWD) > active-for-provider. Empty output → the wrapper falls back
431
+ * to the default config dir.
432
+ *
433
+ * With `--guard`, stdout is unchanged (the wrapper still reads the path from
434
+ * it) and the guard speaks on STDERR, exiting {@link GUARD_REFUSED_EXIT} on a
435
+ * refusal. Splitting the streams is what lets one command both feed the wrapper
436
+ * and talk to the human; a distinct exit code is what lets the wrapper block on
437
+ * a refusal without ever blocking on a missing or older `agent-switch`.
438
+ */
439
+ function cmdDir(providerId, guarded = false) {
367
440
  const mapped = resolveMapping(process.cwd(), providerId);
368
- if (mapped && profileExists(providerId, mapped.name)) {
369
- console.log(configDir(providerId, mapped.name));
441
+ const name = mapped && profileExists(providerId, mapped.name)
442
+ ? mapped.name
443
+ : (() => {
444
+ const active = activeFor(providerId);
445
+ return active && profileExists(providerId, active) ? active : null;
446
+ })();
447
+ if (!name)
370
448
  return;
371
- }
372
- const active = activeFor(providerId);
373
- if (active && profileExists(providerId, active))
374
- console.log(configDir(providerId, active));
375
- // Empty output -> shell wrapper falls back to the default config dir.
449
+ console.log(configDir(providerId, name));
450
+ if (!guarded)
451
+ return;
452
+ // A wrapper cannot pass `--force` (it would reach the provider binary), so
453
+ // only the env override is offered here.
454
+ const guard = checkSingleLoad(providerId, name, { offerForceFlag: false });
455
+ if (guard.message)
456
+ console.error(`agent-switch: ${guard.message}`);
457
+ if (guard.verdict === "refuse")
458
+ process.exit(GUARD_REFUSED_EXIT);
376
459
  }
377
460
  function cmdMap(providerId, name, dir) {
378
461
  const n = requireProfile(providerId, name, `map [--provider ${providerId}] <profile> [dir]`);
@@ -1664,6 +1747,63 @@ function cmdOpen(appId, name) {
1664
1747
  console.log("is saved to this profile. Quit other windows of this app before signing in.");
1665
1748
  }
1666
1749
  }
1750
+ /**
1751
+ * `agent-switch guard [block|warn|off]` — read or set the single-load policy.
1752
+ * The bare form also reports what is loaded RIGHT NOW, because "what is my
1753
+ * policy" and "is anything currently doubled" are the same question in practice.
1754
+ */
1755
+ function cmdGuard(value, json = false) {
1756
+ if (value !== undefined) {
1757
+ if (!isGuardPolicy(value)) {
1758
+ die(`unknown guard policy "${value}" (choose: ${GUARD_POLICIES.join(", ")})`);
1759
+ }
1760
+ setGuard(value);
1761
+ }
1762
+ const policy = readGuard();
1763
+ // Every profile of every provider that can report a live session.
1764
+ const rows = [];
1765
+ for (const pid of PROVIDER_IDS) {
1766
+ if (!provider(pid).hasLiveSessionSignal)
1767
+ continue;
1768
+ for (const n of listProfiles(pid)) {
1769
+ const count = liveSessionPids(configDir(pid, n)).length;
1770
+ if (count > 0)
1771
+ rows.push({ provider: pid, name: n, sessions: count });
1772
+ }
1773
+ }
1774
+ if (json) {
1775
+ console.log(JSON.stringify({ policy, loaded: rows }, null, 2));
1776
+ return;
1777
+ }
1778
+ const explain = {
1779
+ block: "a second session on a profile that is already loaded is refused",
1780
+ warn: "a second session is reported, then allowed",
1781
+ off: "no check at all",
1782
+ };
1783
+ console.log(`Single-load guard: ${policy} — ${explain[policy]}.`);
1784
+ if (rows.length === 0) {
1785
+ console.log("No profile is currently loaded by a live session.");
1786
+ }
1787
+ else {
1788
+ console.log("");
1789
+ for (const r of rows) {
1790
+ const doubled = r.sessions > 1;
1791
+ const mark = doubled ? "⚠️ " : " ";
1792
+ const detail = doubled ? `loaded ${r.sessions}× — this is what the guard exists to prevent` : "1 live session";
1793
+ console.log(`${mark} ${`${r.provider}/${r.name}`.padEnd(24)} ${detail}`);
1794
+ }
1795
+ }
1796
+ const inert = PROVIDER_IDS.filter((pid) => !provider(pid).hasLiveSessionSignal && listProfiles(pid).length > 0);
1797
+ if (inert.length > 0) {
1798
+ console.log("");
1799
+ console.log(`Not covered: ${inert.join(", ")} — these CLIs do not register a running session on disk,`);
1800
+ console.log("so agent-switch has no way to tell a second load from a first.");
1801
+ }
1802
+ if (policy !== "off") {
1803
+ console.log("");
1804
+ console.log(`Override once: ${OVERRIDE_ENV}=1 <binary> · Change: agent-switch guard <${GUARD_POLICIES.join("|")}>`);
1805
+ }
1806
+ }
1667
1807
  function cmdShellenv(shellArg) {
1668
1808
  console.log(shellenvScript(detectShell(shellArg)));
1669
1809
  }
@@ -1679,6 +1819,61 @@ function cmdTooling(json = false) {
1679
1819
  console.log(`agent-switch tooling — platform ${process.platform}\n`);
1680
1820
  for (const l of formatToolingLines(tools))
1681
1821
  console.log(l);
1822
+ // This readout stays offline by contract; the notes command is where the
1823
+ // network lives, so point at it rather than fetching here.
1824
+ console.log("\nWhat changed in a release: `agent-switch tooling notes [<tool>]`");
1825
+ }
1826
+ /**
1827
+ * `agent-switch tooling notes [<tool>] [--latest] [--json]` — read what an
1828
+ * ecosystem update actually contains, in the terminal.
1829
+ *
1830
+ * Deliberately a SEPARATE subcommand: `tooling` (no args) is a local, offline
1831
+ * readout the GUI consumes over `--json`, and that contract stays. This one
1832
+ * fetches, and says so in the help.
1833
+ *
1834
+ * Default tool is `agent-config` (the one agent-switch announces updates for).
1835
+ * Shown: every release newer than the installed version, newest first — so
1836
+ * someone several versions behind reads all of them, not just the last. Up to
1837
+ * date or not installed → the newest release, so the command always answers.
1838
+ * A tool with no verified release source gets a named refusal, never an
1839
+ * invented source.
1840
+ */
1841
+ async function cmdToolingNotes(id, flags) {
1842
+ const toolId = (id ?? "agent-config");
1843
+ if (!TOOL_IDS.includes(toolId)) {
1844
+ die(`usage: agent-switch tooling notes [<${TOOL_IDS.join("|")}>] [--latest] [--json]`);
1845
+ }
1846
+ const repo = toolReleaseRepo(toolId);
1847
+ if (!repo)
1848
+ die(toolNotesRefusal(toolId));
1849
+ const installed = checkTooling({ ids: [toolId] })[0]?.version ?? null;
1850
+ let releases = [];
1851
+ let failure = null;
1852
+ try {
1853
+ releases = await fetchReleases(repo);
1854
+ }
1855
+ catch (e) {
1856
+ failure = e instanceof Error ? e.message : String(e);
1857
+ }
1858
+ const latest = releases[0]?.tag ?? null;
1859
+ // Unread first; when there is nothing unread (up to date, not installed, or
1860
+ // the list is empty) fall back to the newest release so the command answers.
1861
+ const unread = installed ? releasesNewerThan(releases, installed) : [];
1862
+ const selected = unread.length > 0 ? unread : releases.slice(0, 1);
1863
+ const view = { id: toolId, installed, latest, releases: flags.latest ? selected.slice(0, 1) : selected };
1864
+ if (flags.json) {
1865
+ console.log(JSON.stringify({
1866
+ ...view,
1867
+ repo,
1868
+ error: failure,
1869
+ releases: view.releases.map((r) => ({ ...r, blocks: releaseNoteBlocks(r.notes) })),
1870
+ }, null, 2));
1871
+ return;
1872
+ }
1873
+ for (const line of formatToolNotes(view))
1874
+ console.log(line);
1875
+ if (failure)
1876
+ console.log(`\nCould not reach the release source (${failure}) — showing what was available.`);
1682
1877
  }
1683
1878
  /** `agent-switch tooling install|upgrade <id>` — run the tool's per-platform
1684
1879
  * install/upgrade command as a visible child process (the GUI runs this inside
@@ -1736,7 +1931,8 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1736
1931
  agent-switch status [--provider P] [name] [--json] identity (+ Claude usage); --json = active only
1737
1932
  agent-switch current [--provider P] show the active profile(s)
1738
1933
  agent-switch whoami [--provider P] [name] show a profile's account identity
1739
- agent-switch dir [--provider P] resolve profile for CWD (mapping > active)
1934
+ agent-switch dir [--provider P] [--guard] resolve profile for CWD (mapping > active)
1935
+ agent-switch guard [block|warn|off] one profile, one live session (default block)
1740
1936
  agent-switch map [--provider P] <name> [dir] map a directory to a profile
1741
1937
  agent-switch unmap [--provider P] [dir] remove a directory mapping
1742
1938
  agent-switch mappings list directory mappings
@@ -1769,6 +1965,7 @@ Run with no command to launch the tray/menubar GUI (single-instance).
1769
1965
  agent-switch usage history [--provider P] [--profile <name>] [--json] daemon-recorded usage samples per profile
1770
1966
  agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
1771
1967
  agent-switch tooling install|upgrade <tool> install/upgrade an ecosystem tool (agent-config, rtk, claude, codex)
1968
+ agent-switch tooling notes [<tool>] [--latest] [--json] read an ecosystem tool's release notes (default agent-config; fetches from GitHub)
1772
1969
  agent-switch doctor per-OS, per-provider self-check`);
1773
1970
  }
1774
1971
  /**
@@ -1858,7 +2055,26 @@ async function rebindDialog(running) {
1858
2055
  async function cmdRebind(providerId, providerExplicit, account, flags) {
1859
2056
  if (providerExplicit && providerId !== "claude")
1860
2057
  die(`rebind supports claude only (not ${providerId})`);
1861
- const profile = typeof flags.profile === "string" ? flags.profile : activeFor("claude") ?? undefined;
2058
+ // Resolve which running profile rebind targets: explicit --profile wins; else
2059
+ // prefer the profile that actually has a LIVE session (rebind's whole point is
2060
+ // the running session, which may not be the "active" default-for-new-processes
2061
+ // profile). Only auto-target when exactly one profile is running — a credential
2062
+ // write must never guess across ambiguity — otherwise keep the active fallback.
2063
+ const explicitProfile = typeof flags.profile === "string" ? flags.profile : undefined;
2064
+ let profile = explicitProfile ?? activeFor("claude") ?? undefined;
2065
+ if (!explicitProfile) {
2066
+ const running = detectRunningClaudeProfile(listProfiles("claude"), (p) => configDir("claude", p));
2067
+ if (running.kind === "single") {
2068
+ if (running.profile !== profile) {
2069
+ console.log(`Auto-detected the running claude profile "${running.profile}" (its session is live) — targeting it. Pass --profile to override.`);
2070
+ }
2071
+ profile = running.profile;
2072
+ }
2073
+ else if (running.kind === "ambiguous") {
2074
+ console.log(`Multiple running claude profiles (${running.profiles.join(", ")}) — ` +
2075
+ `${profile ? `using the active profile "${profile}"` : "no active default"}; pass --profile <name> to target a specific one.`);
2076
+ }
2077
+ }
1862
2078
  if (flags.reset) {
1863
2079
  // Kill-switch reset (ADR-003): clear `disabled` + the failure counter so a
1864
2080
  // circuit-breaker trip (or a manual disable) no longer refuses rebind.
@@ -1920,8 +2136,10 @@ async function main() {
1920
2136
  const moved = migrateLegacyLayout();
1921
2137
  if (moved.length > 0) {
1922
2138
  // stderr, not stdout: `dir` is machine-consumed by the shell wrapper
1923
- // (`dir="$(agent-switch dir 2>/dev/null)"`), so a status line on stdout
1924
- // would pollute the resolved config path.
2139
+ // (`dir="$(agent-switch dir --provider … --guard)"`), so a status line on
2140
+ // stdout would pollute the resolved config path. Since the single-load
2141
+ // guard needs to speak, the wrapper no longer discards stderr — this
2142
+ // one-time migration note is now visible to the user, which is right.
1925
2143
  console.error(`Migrated ${moved.length} Claude profile(s) to the new layout: ${moved.join(", ")}.`);
1926
2144
  }
1927
2145
  }
@@ -1956,7 +2174,8 @@ async function main() {
1956
2174
  case "status": return cmdStatus(providerExplicit ? providerId : undefined, positional[0], !!flags.json);
1957
2175
  case "current": return cmdCurrent(providerExplicit ? providerId : undefined);
1958
2176
  case "whoami": return cmdWhoami(providerId, positional[0]);
1959
- case "dir": return cmdDir(providerId);
2177
+ case "dir": return cmdDir(providerId, !!flags.guard);
2178
+ case "guard": return cmdGuard(positional[0], !!flags.json);
1960
2179
  case "map": return cmdMap(providerId, positional[0], positional[1]);
1961
2180
  case "unmap": return cmdUnmap(providerExplicit ? providerId : undefined, positional[0]);
1962
2181
  case "mappings": return cmdMappings();
@@ -1985,8 +2204,10 @@ async function main() {
1985
2204
  if (positional[0] === "install" || positional[0] === "upgrade") {
1986
2205
  return cmdToolingAction(positional[0], positional[1]);
1987
2206
  }
2207
+ if (positional[0] === "notes")
2208
+ return cmdToolingNotes(positional[1], flags);
1988
2209
  if (positional[0] !== undefined) {
1989
- die(`usage: agent-switch tooling [install|upgrade <${TOOL_IDS.join("|")}>] [--json]`);
2210
+ die(`usage: agent-switch tooling [install|upgrade <${TOOL_IDS.join("|")}>] [notes [<tool>]] [--json]`);
1990
2211
  }
1991
2212
  return cmdTooling(!!flags.json);
1992
2213
  case "doctor": return process.exit(await runDoctor());
package/dist/profiles.js CHANGED
@@ -4,6 +4,7 @@ import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { PROVIDER_IDS, provider, isProviderId } from "./providers.js";
6
6
  import { credentialStore } from "./credentials.js";
7
+ import { DEFAULT_GUARD_POLICY, isGuardPolicy } from "./single-load.js";
7
8
  export const HOME = os.homedir();
8
9
  export const ROOT = process.env.AGENT_SWITCH_HOME ?? path.join(HOME, ".agent-switch");
9
10
  export const STATE_FILE = path.join(ROOT, "state.json");
@@ -160,6 +161,9 @@ function normalizeRebindState(raw) {
160
161
  const consecutiveFailures = typeof r.consecutiveFailures === "number" && r.consecutiveFailures >= 0 ? Math.floor(r.consecutiveFailures) : 0;
161
162
  return { disabled: r.disabled === true, consecutiveFailures };
162
163
  }
164
+ function normalizeGuard(raw) {
165
+ return isGuardPolicy(raw) ? raw : DEFAULT_GUARD_POLICY;
166
+ }
163
167
  export function readState() {
164
168
  try {
165
169
  const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
@@ -169,19 +173,20 @@ export function readState() {
169
173
  const binaryPaths = normalizeBinaryPaths(raw?.binaryPaths);
170
174
  const osNotifications = raw?.osNotifications === true;
171
175
  const rebind = normalizeRebindState(raw?.rebind);
176
+ const guard = normalizeGuard(raw?.guard);
172
177
  // v1: { active: "<name>" } — a single Claude profile.
173
178
  if (typeof raw?.active === "string") {
174
- return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
179
+ return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
175
180
  }
176
181
  if (raw?.active && typeof raw.active === "object") {
177
- return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
182
+ return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
178
183
  }
179
- return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
184
+ return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
180
185
  }
181
186
  catch {
182
187
  /* absent / unparsable → default */
183
188
  }
184
- return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE } };
189
+ return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE }, guard: DEFAULT_GUARD_POLICY };
185
190
  }
186
191
  export function readOsNotifications() {
187
192
  return readState().osNotifications;
@@ -191,6 +196,17 @@ export function setOsNotifications(on) {
191
196
  state.osNotifications = on;
192
197
  writeState(state);
193
198
  }
199
+ // ---------- single-load guard policy ----------
200
+ /** How the single-load guard reacts to a second load of one profile. */
201
+ export function readGuard() {
202
+ return readState().guard;
203
+ }
204
+ /** Persist the guard policy. */
205
+ export function setGuard(policy) {
206
+ const state = readState();
207
+ state.guard = policy;
208
+ writeState(state);
209
+ }
194
210
  // ---------- rebind kill-switch / circuit-breaker (ADR-003) ----------
195
211
  /** Current rollback/circuit-breaker state for the `rebind` write path. */
196
212
  export function readRebindKillSwitch() {
package/dist/providers.js CHANGED
@@ -77,6 +77,7 @@ const claude = {
77
77
  oneShotArgs: (prompt) => ["-p", prompt],
78
78
  importFiles: [],
79
79
  hasUsageReadout: true,
80
+ hasLiveSessionSignal: true,
80
81
  };
81
82
  const codex = {
82
83
  id: "codex",
@@ -95,6 +96,7 @@ const codex = {
95
96
  oneShotArgs: (prompt) => ["exec", prompt],
96
97
  importFiles: ["auth.json"],
97
98
  hasUsageReadout: true, // live via wham/usage (ChatGPT backend)
99
+ hasLiveSessionSignal: false, // rollouts are date-partitioned; no live-pid marker
98
100
  };
99
101
  const antigravity = {
100
102
  id: "antigravity",
@@ -128,6 +130,7 @@ const antigravity = {
128
130
  oneShotArgs: (prompt) => ["--print", prompt],
129
131
  importFiles: [],
130
132
  hasUsageReadout: false,
133
+ hasLiveSessionSignal: false,
131
134
  };
132
135
  const PROVIDERS = { claude, codex, antigravity };
133
136
  export function provider(id) {
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Release-note normalisation — turn a GitHub release body into something a
3
+ * card or a terminal can show without it reading as broken.
4
+ *
5
+ * Mirrored in `gui/src/release-notes.ts` (same boundary reason as updates.ts:
6
+ * the GUI's tsconfig only includes `gui/src`, so pure helpers are duplicated
7
+ * rather than imported across it). `tests/release-notes.test.ts` byte-compares
8
+ * the two below their header comment, so a fix applied to one and not the other
9
+ * fails there — and it asserts against the real captured body
10
+ * (`tests/fixtures/agent-config-release-14.2.0.md`), which the GUI mirror's own
11
+ * suite cannot read: that project ships no `@types/node` on purpose.
12
+ *
13
+ * Why a normaliser and not `body.slice(0, 500)`: measured on agent-config
14
+ * 14.2.0, the first 500 characters are a heading, an HTML comment addressed to
15
+ * the release author, and five `_none_` placeholders. The shapes handled here
16
+ * are the ones that release template actually emits — headings, `*` items with
17
+ * a `**scope:**` prefix, `([sha](commit-url))` suffixes, `**bold**` / `_italic_`
18
+ * and horizontal rules. Everything is pure; no I/O, no markdown dependency.
19
+ */
20
+ /** Drop HTML comments, including multi-line ones. This is what removes the
21
+ * release template's author-facing instruction line ("fill before merge …") —
22
+ * a comment is invisible on GitHub and must stay invisible here. */
23
+ export function stripHtmlComments(md) {
24
+ return String(md).replace(/<!--[\s\S]*?-->/g, "");
25
+ }
26
+ /** Inline cleanup applied to every line's text: commit-link suffixes go, real
27
+ * links collapse to their label, bold/italic markers are unwrapped, and runs
28
+ * of whitespace collapse. Inline code keeps its backticks on purpose — they
29
+ * mark a literal name, which is information, not noise. Pure. */
30
+ export function normalizeInline(s) {
31
+ return String(s)
32
+ // ` ([abc1234](https://…/commit/abc…))` — the generated changelog's sha
33
+ // suffix. Removed before generic link handling, which would otherwise
34
+ // leave a bare `(abc1234)` behind.
35
+ .replace(/\s*\(\[[0-9a-f]{7,40}\]\([^)\s]*\)\)/gi, "")
36
+ // `[label](url)` → `label`
37
+ .replace(/\[([^\]]*)\]\([^)\s]*\)/g, "$1")
38
+ // `**bold**` / `__bold__` → bold
39
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
40
+ .replace(/__([^_]+)__/g, "$1")
41
+ // `_italic_` → italic (this is what turns `_none_` into `none`).
42
+ // Underscore-delimited only at word boundaries so `snake_case_names` and
43
+ // `file_name.ts` survive untouched.
44
+ .replace(/(^|[\s(])_([^_]+)_(?=[\s).,;:!?]|$)/g, "$1$2")
45
+ .replace(/[ \t]{2,}/g, " ")
46
+ .trim();
47
+ }
48
+ /** True for a markdown horizontal rule (`---`, `***`, `___`). */
49
+ function isHorizontalRule(line) {
50
+ return /^\s*([-*_])\s*(\1\s*){2,}$/.test(line);
51
+ }
52
+ /**
53
+ * Parse a release body into ordered blocks. Blank lines carry no block — the
54
+ * renderers space by `kind` instead, so a body with erratic blank runs comes
55
+ * out evenly spaced. An empty or comment-only body yields `[]`, which every
56
+ * caller must treat as "no notes" (render nothing — never a placeholder).
57
+ */
58
+ export function releaseNoteBlocks(md) {
59
+ const blocks = [];
60
+ for (const raw of stripHtmlComments(md).split(/\r?\n/)) {
61
+ if (!raw.trim() || isHorizontalRule(raw))
62
+ continue;
63
+ const heading = /^\s*#{1,6}\s+(.*)$/.exec(raw);
64
+ if (heading) {
65
+ const text = normalizeInline(heading[1]);
66
+ if (text)
67
+ blocks.push({ kind: "heading", text });
68
+ continue;
69
+ }
70
+ const item = /^\s*[-*+]\s+(.*)$/.exec(raw);
71
+ if (item) {
72
+ const text = normalizeInline(item[1]);
73
+ if (text)
74
+ blocks.push({ kind: "item", text });
75
+ continue;
76
+ }
77
+ const text = normalizeInline(raw);
78
+ if (text)
79
+ blocks.push({ kind: "text", text });
80
+ }
81
+ return blocks;
82
+ }
83
+ /** A `_none_`-style empty value — the release template's way of saying "this
84
+ * section genuinely has nothing", which must not be offered as a highlight. */
85
+ function isEmptyValue(value) {
86
+ return /^(none|n\/a|-|—)$/i.test(value.trim());
87
+ }
88
+ /**
89
+ * The one line worth putting in a notification: the first non-empty bullet
90
+ * under a "highlights"-ish heading, else the first bullet in the body. Returns
91
+ * null when the body carries no bullet with content — callers then say nothing
92
+ * rather than inventing a summary. Pure.
93
+ */
94
+ export function firstHighlight(blocks) {
95
+ const start = blocks.findIndex((b) => b.kind === "heading" && /highlight/i.test(b.text));
96
+ const scan = (from, stopAtHeading) => {
97
+ for (let i = from; i < blocks.length; i++) {
98
+ const b = blocks[i];
99
+ if (b.kind === "heading" && stopAtHeading && i > from)
100
+ return null;
101
+ if (b.kind !== "item")
102
+ continue;
103
+ // `Behaviour changes: none` → value `none`; a bullet with no colon is
104
+ // its own value.
105
+ const colon = b.text.indexOf(":");
106
+ const value = colon >= 0 ? b.text.slice(colon + 1) : b.text;
107
+ if (!isEmptyValue(value))
108
+ return b.text;
109
+ }
110
+ return null;
111
+ };
112
+ return start >= 0 ? (scan(start + 1, true) ?? scan(0, false)) : scan(0, false);
113
+ }
114
+ /**
115
+ * Render blocks as terminal lines: a blank line before each heading (never as
116
+ * the first line), items bulleted, text as-is. `omitted` is how many lines the
117
+ * cap dropped, so the caller can print an honest "… N more" plus the release
118
+ * URL instead of silently truncating. Pure.
119
+ */
120
+ export function releaseNotesText(blocks, opts = {}) {
121
+ const bullet = opts.bullet ?? "•";
122
+ const indent = opts.indent ?? "";
123
+ const all = [];
124
+ for (const b of blocks) {
125
+ if (b.kind === "heading") {
126
+ if (all.length)
127
+ all.push("");
128
+ all.push(`${indent}${b.text}`);
129
+ }
130
+ else if (b.kind === "item") {
131
+ all.push(`${indent} ${bullet} ${b.text}`);
132
+ }
133
+ else {
134
+ all.push(`${indent} ${b.text}`);
135
+ }
136
+ }
137
+ const max = opts.maxLines;
138
+ if (max === undefined || all.length <= max)
139
+ return { lines: all, omitted: 0 };
140
+ return { lines: all.slice(0, max), omitted: all.length - max };
141
+ }
142
+ /** Clamp to `max` characters on a word boundary, appending an ellipsis when
143
+ * anything was cut. Used for the one-line notification summary, where a raw
144
+ * bullet can run to several hundred characters. Pure. */
145
+ export function clampText(s, max) {
146
+ const t = String(s).trim();
147
+ if (t.length <= max)
148
+ return t;
149
+ const cut = t.slice(0, max);
150
+ const space = cut.lastIndexOf(" ");
151
+ // Prefer the word boundary, but only when it still keeps at least half the
152
+ // budget — otherwise a long first word would collapse the summary to nothing.
153
+ return `${(space > max * 0.5 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-]+$/, "")}…`;
154
+ }
package/dist/sessions.js CHANGED
@@ -174,6 +174,25 @@ export function markLive(configDir, rows, cwdOf = pidCwd) {
174
174
  row.live = true;
175
175
  }
176
176
  }
177
+ /**
178
+ * Auto-detect which Claude profile has a live session, for `rebind`'s
179
+ * no-`--profile` resolution. A profile is "running" when its config dir carries
180
+ * a live session pid (`<configDir>/sessions/<pid>.json` whose pid is alive).
181
+ *
182
+ * Returns `single` ONLY when exactly one profile is running — rebind writes a
183
+ * credential store, so the caller must never guess a target across ambiguity;
184
+ * `none` / `ambiguous` leave the caller on its explicit-`--profile` / active
185
+ * fallback. Pure (`livePidsOf` injected) so every branch is unit-tested without
186
+ * touching real processes.
187
+ */
188
+ export function detectRunningClaudeProfile(profiles, configDirOf, livePidsOf = liveSessionPids) {
189
+ const running = profiles.filter((p) => livePidsOf(configDirOf(p)).length > 0);
190
+ if (running.length === 1)
191
+ return { kind: "single", profile: running[0] };
192
+ if (running.length > 1)
193
+ return { kind: "ambiguous", profiles: running };
194
+ return { kind: "none" };
195
+ }
177
196
  /** Where a session id lives inside ONE profile config dir, or null. */
178
197
  export function locateSession(configDir, sessionId) {
179
198
  const projects = path.join(configDir, "projects");
package/dist/shellenv.js CHANGED
@@ -13,8 +13,29 @@
13
13
  * `asw` convenience: bare `asw` lists all providers' profiles; `asw <name>`
14
14
  * switches the active Claude profile; `asw <provider> <name>` switches that
15
15
  * provider's.
16
+ *
17
+ * SINGLE-LOAD GUARD. The wrapper is the one place every interactive start
18
+ * funnels through, so it is where the guard has to sit. `dir --guard` keeps the
19
+ * config path on stdout (unchanged) and writes the human message to stderr, so
20
+ * one invocation both feeds the wrapper and talks to the user — no second
21
+ * process launch per command.
22
+ *
23
+ * The wrapper blocks on EXACTLY ONE exit code (86, GUARD_REFUSED_EXIT) and on
24
+ * nothing else. That asymmetry is deliberate and load-bearing: a missing,
25
+ * older, or broken `agent-switch` exits 127 / 1 / 2 and the wrapper carries on
26
+ * to the real binary. This tool must never become the reason a user cannot
27
+ * reach their assistant.
28
+ *
29
+ * Each wrapper first checks that `agent-switch` exists at all and otherwise
30
+ * calls the real binary straight away. That check replaces the old
31
+ * `2>/dev/null`: the guard has to be able to SPEAK on stderr, so the blanket
32
+ * redirect had to go, and without the existence check an uninstalled
33
+ * `agent-switch` would print "command not found" on every single invocation.
34
+ * A side effect worth having: a genuinely broken agent-switch now surfaces its
35
+ * error instead of silently degrading the shell onto the default config dir.
16
36
  */
17
37
  import { allProviders } from "./providers.js";
38
+ import { GUARD_REFUSED_EXIT } from "./single-load.js";
18
39
  export const SHELLS = ["zsh", "bash", "fish", "powershell"];
19
40
  /** Resolve the target shell: explicit request wins, else detect from the
20
41
  * environment / platform. Windows defaults to PowerShell; POSIX reads $SHELL. */
@@ -40,8 +61,11 @@ export function detectShell(requested, env = process.env, platform = process.pla
40
61
  }
41
62
  function posixWrapper(binary, envVar, id) {
42
63
  return `${binary}() {
43
- local dir
44
- dir="$(command agent-switch dir --provider ${id} 2>/dev/null)"
64
+ local dir rc
65
+ command -v agent-switch >/dev/null 2>&1 || { command ${binary} "$@"; return; }
66
+ dir="$(command agent-switch dir --provider ${id} --guard)"
67
+ rc=$?
68
+ if [ $rc -eq ${GUARD_REFUSED_EXIT} ]; then return $rc; fi
45
69
  if [ -n "$dir" ]; then
46
70
  ${envVar}="$dir" command ${binary} "$@"
47
71
  else
@@ -51,7 +75,15 @@ function posixWrapper(binary, envVar, id) {
51
75
  }
52
76
  function fishWrapper(binary, envVar, id) {
53
77
  return `function ${binary}
54
- set -l dir (command agent-switch dir --provider ${id} 2>/dev/null)
78
+ if not type -q agent-switch
79
+ command ${binary} $argv
80
+ return
81
+ end
82
+ set -l dir (command agent-switch dir --provider ${id} --guard)
83
+ set -l rc $status
84
+ if test $rc -eq ${GUARD_REFUSED_EXIT}
85
+ return $rc
86
+ end
55
87
  if test -n "$dir"
56
88
  ${envVar}=$dir command ${binary} $argv
57
89
  else
@@ -63,7 +95,9 @@ function powershellWrapper(binary, envVar, id) {
63
95
  return `function ${binary} {
64
96
  $exe = Get-Command ${binary} -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
65
97
  if (-not $exe) { Write-Error '${binary} not found on PATH.'; return }
66
- $dir = (& agent-switch dir --provider ${id} 2>$null)
98
+ if (-not (Get-Command agent-switch -ErrorAction SilentlyContinue)) { & $exe.Source @args; return }
99
+ $dir = (& agent-switch dir --provider ${id} --guard)
100
+ if ($LASTEXITCODE -eq ${GUARD_REFUSED_EXIT}) { return }
67
101
  if ($dir) {
68
102
  $prev = Get-Item -Path Env:\\${envVar} -ErrorAction SilentlyContinue
69
103
  $env:${envVar} = $dir
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Single-load guard: one profile, one live session.
3
+ *
4
+ * The problem it solves: `agent-switch use` writes a GLOBAL active pointer and
5
+ * the shell wrapper re-resolves it on every invocation, so nothing stops two
6
+ * terminals from starting `claude` on the same profile. Two live sessions on
7
+ * one account share its rate-limit windows and both refresh one rotating OAuth
8
+ * store (the rotation `src/locks.ts` documents) — and, worst of the three, the
9
+ * user believes the second terminal is on a different account because they
10
+ * typed `asw <other>` there.
11
+ *
12
+ * Ground truth is Claude Code's own bookkeeping, never ours: a live session
13
+ * writes `<config>/sessions/<pid>.json`, and `liveSessionPids` filters dead
14
+ * pids on every read. That makes the signal self-healing — a crashed session
15
+ * leaves a stale file that is simply never counted, so this module needs no
16
+ * cleanup job and holds no lease of its own. (An agent-switch-owned lease would
17
+ * need a holder process; the shell wrapper execs `claude` and exits, so every
18
+ * crash would leak one.)
19
+ *
20
+ * Everything here is pure — processes, environment, and clock are injected — so
21
+ * every branch is unit-tested without spawning anything.
22
+ */
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import { liveSessionPids } from "./api.js";
26
+ import { pidCwd } from "./sessions.js";
27
+ export const GUARD_POLICIES = ["block", "warn", "off"];
28
+ export const DEFAULT_GUARD_POLICY = "block";
29
+ export function isGuardPolicy(v) {
30
+ return typeof v === "string" && GUARD_POLICIES.includes(v);
31
+ }
32
+ /** Env var that waives the guard for exactly one invocation. An env var, not a
33
+ * flag: the shell wrapper passes every unknown flag THROUGH to the provider
34
+ * binary, so a `--force` there would reach `claude`, not us. */
35
+ export const OVERRIDE_ENV = "AGENT_SWITCH_ALLOW_DUPLICATE";
36
+ /** Exit code the wrapper keys on. Distinct from every generic failure (1, 2,
37
+ * 127 …) so the wrapper blocks on a REFUSAL and never on a missing or older
38
+ * `agent-switch` — fail-open is deliberate: this tool must never be the reason
39
+ * a user cannot start their assistant. */
40
+ export const GUARD_REFUSED_EXIT = 86;
41
+ function pidFileMtime(configDir, pid) {
42
+ try {
43
+ return fs.statSync(path.join(configDir, "sessions", `${pid}.json`)).mtimeMs;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /**
50
+ * Who currently holds `configDir`, and whether we are a child of one of them.
51
+ *
52
+ * `envVar` is the provider's isolation variable (`CLAUDE_CONFIG_DIR`, …). The
53
+ * nested comparison is on the RAW string, deliberately unresolved: that same
54
+ * unresolved string is what Claude Code hashes into its keychain service name
55
+ * (`profiles.ts` § configDir), so resolving it here would compare a different
56
+ * identity than the one the rest of the tool uses.
57
+ */
58
+ export function inspectLoad(configDir, envVar, deps = {}) {
59
+ const livePidsOf = deps.livePidsOf ?? liveSessionPids;
60
+ const cwdOf = deps.cwdOf ?? pidCwd;
61
+ const startedMsOf = deps.startedMsOf ?? pidFileMtime;
62
+ const env = deps.env ?? process.env;
63
+ const nested = env[envVar] === configDir;
64
+ const pids = livePidsOf(configDir);
65
+ // A nested start is allowed unconditionally, so its message is never
66
+ // rendered — and resolving a cwd costs an `lsof` per pid on darwin. Skip the
67
+ // lookup rather than pay it on a path that cannot use the answer.
68
+ const sessions = pids.map((pid) => ({
69
+ pid,
70
+ cwd: nested ? null : cwdOf(pid),
71
+ startedMs: startedMsOf(configDir, pid),
72
+ }));
73
+ return { sessions, loaded: sessions.length > 0, nested };
74
+ }
75
+ /**
76
+ * Pure policy resolution. Order matters and each precedence is load-bearing:
77
+ * an undetectable provider can never refuse (no evidence), a nested start is
78
+ * a deliberate child rather than a duplicate, and an explicit override outranks
79
+ * the policy because the user just stated an intent this turn.
80
+ */
81
+ export function guardVerdict({ policy, inspection, overridden = false, detectable = true }) {
82
+ if (!detectable)
83
+ return "allow";
84
+ if (policy === "off")
85
+ return "allow";
86
+ if (!inspection.loaded)
87
+ return "allow";
88
+ if (inspection.nested)
89
+ return "allow";
90
+ if (overridden)
91
+ return "warn"; // proceed, but still say what is happening
92
+ return policy === "warn" ? "warn" : "refuse";
93
+ }
94
+ /** True when the one-shot override env var is set to a meaningful value. */
95
+ export function overrideFromEnv(env = process.env) {
96
+ const v = env[OVERRIDE_ENV];
97
+ return typeof v === "string" && v !== "" && v !== "0" && v.toLowerCase() !== "false";
98
+ }
99
+ /** Relative age like "3m" / "2h" — mirrors the sessions listing's `ageOf`. */
100
+ function ageOf(startedMs, now) {
101
+ if (startedMs === null)
102
+ return null;
103
+ const s = Math.max(0, Math.round((now - startedMs) / 1000));
104
+ if (s < 60)
105
+ return `${s}s`;
106
+ if (s < 3600)
107
+ return `${Math.round(s / 60)}m`;
108
+ if (s < 86400)
109
+ return `${Math.round(s / 3600)}h`;
110
+ return `${Math.round(s / 86400)}d`;
111
+ }
112
+ /** Render actions as a table whose column width comes from the widest action,
113
+ * so a long override line can never collide with its own explanation. */
114
+ function actionTable(rows) {
115
+ const width = Math.max(...rows.map(([action]) => action.length));
116
+ return rows.map(([action, explanation]) => ` ${action.padEnd(width + 2)}${explanation}`);
117
+ }
118
+ /**
119
+ * The one place the guard speaks. Every enforcement point renders through this
120
+ * so the CLI, the wrapper path, and `use` never drift into three dialects.
121
+ *
122
+ * A refusal that only says "no" hands the problem straight back, so the message
123
+ * names WHO holds the profile (pid, age, directory), WHY that matters, and the
124
+ * concrete ways forward — including the names of the profiles that are actually
125
+ * free, and how to switch the guard off.
126
+ */
127
+ export function renderGuardMessage(input) {
128
+ const { profile, binary, verdict, inspection, offerForceFlag = false, alternatives = [], context = "start", now = Date.now(), } = input;
129
+ const n = inspection.sessions.length;
130
+ const held = `profile "${profile}" is already loaded by ${n === 1 ? "a running" : `${n} running`} ${binary} session${n === 1 ? "" : "s"}`;
131
+ const headline = context === "use"
132
+ ? verdict === "refuse"
133
+ ? `${held} — the next \`${binary}\` started here will be refused.`
134
+ : `${held} — a second session will be allowed (guard: warn).`
135
+ : verdict === "refuse"
136
+ ? `${held}.`
137
+ : `${held} — continuing anyway.`;
138
+ const lines = [headline, ""];
139
+ for (const sess of inspection.sessions) {
140
+ const age = ageOf(sess.startedMs, now);
141
+ const parts = [` pid ${sess.pid}`];
142
+ if (age)
143
+ parts.push(`started ${age} ago`);
144
+ if (sess.cwd)
145
+ parts.push(sess.cwd);
146
+ lines.push(parts.join(" "));
147
+ }
148
+ lines.push("", "Two sessions on one account share its rate limits, and both refresh the", "same rotating OAuth token.");
149
+ if (verdict === "refuse") {
150
+ const rows = [];
151
+ rows.push([
152
+ `agent-switch use ${alternatives.length > 0 ? alternatives[0] : "<other-profile>"}`,
153
+ alternatives.length > 1
154
+ ? `switch to a free account — also free: ${alternatives.slice(1).join(", ")}`
155
+ : alternatives.length === 1
156
+ ? "switch to the account that is free"
157
+ : "point new sessions at another account",
158
+ ]);
159
+ // Both overrides do the same thing, so only one is offered: `--force` where
160
+ // the caller can accept a flag, the env var everywhere else. A shell wrapper
161
+ // passes unknown flags THROUGH to the provider binary, which is why the env
162
+ // var exists at all — offering both would just be two names for one action.
163
+ rows.push(offerForceFlag
164
+ ? ["--force", "start it anyway, this once"]
165
+ : [`${OVERRIDE_ENV}=1 ${binary}`, "start it anyway, this once"]);
166
+ rows.push(["agent-switch guard warn", "warn instead of refusing, from now on"]);
167
+ rows.push(["agent-switch guard off", "turn the guard off entirely"]);
168
+ lines.push("", "Ways forward:", ...actionTable(rows));
169
+ }
170
+ return lines.join("\n");
171
+ }
package/dist/tooling.js CHANGED
@@ -19,6 +19,7 @@ import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { readBinaryPath } from "./profiles.js";
21
21
  import { npmSearchPath } from "./updates.js";
22
+ import { releaseNoteBlocks, releaseNotesText } from "./release-notes.js";
22
23
  export const TOOL_IDS = ["agent-config", "rtk", "claude", "codex", "agy"];
23
24
  /** Which provider a probed binary belongs to (for `providers link` hints and
24
25
  * the user-linked path lookup). */
@@ -322,3 +323,75 @@ export function doctorToolingLine(t) {
322
323
  }
323
324
  return `\`${t.id}\`: ${t.hint}`;
324
325
  }
326
+ // ---------- release notes (`tooling notes <tool>`) --------------------------------
327
+ /**
328
+ * Repos whose GitHub Releases are a tool's release-note source. Only tools with
329
+ * a source this repo has actually verified appear here — inventing a plausible
330
+ * repo for the rest would be worse than the refusal `toolNotesRefusal` prints.
331
+ * Same two sources the GUI uses (agent-config's release check, rtk's update
332
+ * check), so the CLI and the GUI can never describe a release differently.
333
+ */
334
+ export const TOOL_RELEASE_REPOS = {
335
+ "agent-config": "event4u-app/agent-config",
336
+ rtk: "rtk-ai/rtk",
337
+ };
338
+ /** The release-notes source for a tool, or null when it has none. */
339
+ export function toolReleaseRepo(id) {
340
+ return TOOL_RELEASE_REPOS[id] ?? null;
341
+ }
342
+ /** Why a tool has no readable release notes here — named, never guessed. */
343
+ export function toolNotesRefusal(id) {
344
+ if (id === "claude" || id === "codex") {
345
+ return (`\`${id}\` is distributed on npm and this repo has no verified GitHub release source for it, ` +
346
+ `so there are no notes to show rather than a guessed one.\n` +
347
+ ` Its installed version is in \`agent-switch tooling\`; upgrade with \`agent-switch tooling upgrade ${id}\`.`);
348
+ }
349
+ return (`\`${id}\` has no known release source — the Antigravity CLI ships with the Antigravity app and publishes no releases here.\n` +
350
+ ` Its installed version is in \`agent-switch tooling\`.`);
351
+ }
352
+ /** The header line: what is installed, what is published, and whether anything
353
+ * is waiting. Pure — every branch is unit-tested. */
354
+ export function toolNotesHeader(view) {
355
+ const { id, installed, latest } = view;
356
+ if (!installed) {
357
+ return `${id} — not installed · ${latest ? `latest ${latest}` : "latest unknown"}`;
358
+ }
359
+ if (!latest) {
360
+ return `${id} — installed ${installed} · latest unknown (offline or rate-limited)`;
361
+ }
362
+ const pending = view.releases.filter((r) => r.tag !== installed).length;
363
+ if (installed === latest || pending === 0)
364
+ return `${id} — installed ${installed} · up to date`;
365
+ return `${id} — installed ${installed} · latest ${latest} · ${pending} update${pending === 1 ? "" : "s"} to read`;
366
+ }
367
+ /**
368
+ * The full `tooling notes` rendering: header, then each release as a dated
369
+ * block of normalised notes plus its URL. A release whose body normalises to
370
+ * nothing is listed with its URL and an explicit "no notes in this release" —
371
+ * unlike the GUI panel, a terminal command the user typed must answer rather
372
+ * than print nothing. `maxLinesPerRelease` caps a long body and says how much
373
+ * was left out. Pure.
374
+ */
375
+ export function formatToolNotes(view, maxLinesPerRelease = 40) {
376
+ const out = [toolNotesHeader(view)];
377
+ if (view.releases.length === 0) {
378
+ out.push("", "No release notes available.");
379
+ return out;
380
+ }
381
+ for (const r of view.releases) {
382
+ const date = /^\d{4}-\d{2}-\d{2}/.test(r.publishedAt) ? r.publishedAt.slice(0, 10) : null;
383
+ out.push("", `${r.name || r.tag}${date ? ` — ${date}` : ""}`);
384
+ const { lines, omitted } = releaseNotesText(releaseNoteBlocks(r.notes), {
385
+ maxLines: maxLinesPerRelease,
386
+ indent: " ",
387
+ });
388
+ if (lines.length === 0)
389
+ out.push(" (no notes in this release)");
390
+ else
391
+ out.push(...lines);
392
+ if (omitted > 0)
393
+ out.push(` … ${omitted} more line${omitted === 1 ? "" : "s"}`);
394
+ out.push(` ${r.url}`);
395
+ }
396
+ return out;
397
+ }
package/dist/updates.js CHANGED
@@ -47,8 +47,10 @@ export function isNewer(latest, current) {
47
47
  return compareVersions(latest, current) > 0;
48
48
  }
49
49
  /** Reshape a raw GitHub `releases/latest` payload into {@link ReleaseInfo}, or
50
- * null for a draft/prerelease or a payload without a tag. Pure — no network. */
51
- export function parseRelease(raw) {
50
+ * null for a draft/prerelease or a payload without a tag. Pure — no network.
51
+ * `repo` only supplies the fallback URL for a payload missing `html_url`; it
52
+ * defaults to this app's own repo so existing callers are unaffected. */
53
+ export function parseRelease(raw, repo = UPDATE_REPO) {
52
54
  if (!raw || typeof raw !== "object")
53
55
  return null;
54
56
  const r = raw;
@@ -60,11 +62,43 @@ export function parseRelease(raw) {
60
62
  return {
61
63
  tag,
62
64
  name: typeof r.name === "string" && r.name ? r.name : tag,
63
- url: typeof r.html_url === "string" ? r.html_url : `https://github.com/${UPDATE_REPO}/releases`,
65
+ url: typeof r.html_url === "string" ? r.html_url : `https://github.com/${repo}/releases`,
64
66
  notes: typeof r.body === "string" ? r.body : "",
65
67
  publishedAt: typeof r.published_at === "string" ? r.published_at : "",
66
68
  };
67
69
  }
70
+ /** Reshape a raw GitHub `/releases` array (drafts and prereleases dropped by
71
+ * {@link parseRelease}'s existing rule) and order it newest version first, so
72
+ * the ordering is the version math rather than GitHub's creation order. Pure. */
73
+ export function parseReleases(raw, repo = UPDATE_REPO) {
74
+ if (!Array.isArray(raw))
75
+ return [];
76
+ return raw
77
+ .map((r) => parseRelease(r, repo))
78
+ .filter((r) => r !== null)
79
+ .sort((a, b) => compareVersions(b.tag, a.tag));
80
+ }
81
+ /** The releases strictly newer than `current`, newest first — i.e. everything
82
+ * the user has not seen yet, which is what "what changed" actually means for
83
+ * someone several versions behind. Pure. */
84
+ export function releasesNewerThan(releases, current) {
85
+ return releases.filter((r) => isNewer(r.tag, current)).sort((a, b) => compareVersions(b.tag, a.tag));
86
+ }
87
+ /** Recent releases for a repo, newest first. `[]` when the repo has none (404)
88
+ * — a normal empty state. Throws on any other network/HTTP failure so the
89
+ * caller can fall back to {@link fetchLatestRelease} rather than silently
90
+ * reporting "nothing new". One request; the unauthenticated endpoint is
91
+ * rate-limited (60/h per IP), ample for an hourly check. */
92
+ export async function fetchReleases(repo = UPDATE_REPO, perPage = 20) {
93
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=${perPage}`, {
94
+ headers: { Accept: "application/vnd.github+json", "User-Agent": `${PACKAGE_NAME} cli-updater` },
95
+ });
96
+ if (res.status === 404)
97
+ return [];
98
+ if (!res.ok)
99
+ throw new Error(`GitHub API returned ${res.status}`);
100
+ return parseReleases(await res.json(), repo);
101
+ }
68
102
  /** The running CLI's version, from the package's own package.json (one level up
69
103
  * from the compiled dist/). "0.0.0" if unreadable so callers never crash. */
70
104
  export function currentVersion() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-switch",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Switch between multiple Claude Code accounts on macOS, Linux, and Windows via isolated CLAUDE_CONFIG_DIR profiles",
5
5
  "repository": {
6
6
  "type": "git",