@event4u/agent-switch 1.7.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/dist/index.js CHANGED
@@ -13,26 +13,33 @@
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, readGuard, setGuard, 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
- import { TOOL_IDS, checkTooling, formatToolingLines, runToolAction } from "./tooling.js";
27
- import { readHistory } from "./history.js";
30
+ import { TOOL_IDS, checkTooling, formatToolNotes, formatToolingLines, runToolAction, toolNotesRefusal, toolReleaseRepo, } from "./tooling.js";
31
+ import { releaseNoteBlocks } from "./release-notes.js";
32
+ import { readHistory, recordHistorySample } from "./history.js";
28
33
  import { launchGui } from "./gui-launch.js";
29
- 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";
30
36
  import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, } from "./mappings.js";
31
- import { accessTokenOf, checkAuth, fetchProfile, fetchUsage, liveSessionPids, readProfileCredential, } from "./api.js";
37
+ import { checkAuth, fetchProfile, fetchUsage, liveSessionPids, } from "./api.js";
38
+ import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
32
39
  import { formatSnapshot, parseUsage } from "./usage.js";
33
40
  import { readCodexUsage } from "./codex-usage.js";
34
41
  import { redeemResetCredit } from "./codex-reset.js";
35
- 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";
36
43
  import { isFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
37
44
  import { cmdService, serviceUninstall } from "./service.js";
38
45
  import { readContext, turnInFlight } from "./telemetry.js";
@@ -47,6 +54,46 @@ import { currentManagedSession, currentTmuxSessionName, newSessionArgs, readTmux
47
54
  const credentials = credentialStore();
48
55
  const CLAUDE_HOME = provider("claude").defaultConfigDir(); // ~/.claude
49
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
+ }
50
97
  // ---------- launcher ---------------------------------------------------------
51
98
  function launch(p, name, args) {
52
99
  const home = configDir(p.id, name);
@@ -160,6 +207,14 @@ function cmdUse(providerId, name) {
160
207
  const id = identity(providerId, n);
161
208
  console.log(`Active ${providerId} profile: ${n}${id ? ` (${id})` : ""}`);
162
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
+ }
163
218
  }
164
219
  function cmdDeactivate(providerId) {
165
220
  const active = activeFor(providerId);
@@ -174,9 +229,18 @@ function cmdDeactivate(providerId) {
174
229
  function cmdRun(providerId, name, args) {
175
230
  const n = requireProfile(providerId, name, "run");
176
231
  const p = provider(providerId);
177
- // `--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.
178
234
  const wantTmux = args.includes("--tmux");
179
- 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);
180
244
  if (wantTmux) {
181
245
  if (!tmuxAvailable()) {
182
246
  console.log("(--tmux unavailable — tmux not found or Windows; running normally.)");
@@ -199,8 +263,12 @@ function printProfileLine(providerId, name, showLive) {
199
263
  let live = "";
200
264
  if (showLive && providerId === "claude") {
201
265
  const pids = liveSessionPids(configDir("claude", name));
202
- if (pids.length > 0)
203
- 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]";
204
272
  }
205
273
  console.log(`${mark} ${name.padEnd(16)} ${id}${live}`);
206
274
  }
@@ -272,8 +340,7 @@ async function claudeSnapshot(name) {
272
340
  if (cached)
273
341
  return cached;
274
342
  }
275
- const creds = readProfileCredential(configDir("claude", name));
276
- const token = creds ? accessTokenOf(creds) : null;
343
+ const token = freshAccessToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")));
277
344
  if (!token)
278
345
  return null;
279
346
  const raw = await fetchUsage(token);
@@ -295,6 +362,12 @@ async function cmdStatus(providerId, name, json = false) {
295
362
  : pid === "codex"
296
363
  ? await readCodexUsage(configDir("codex", active))
297
364
  : null;
365
+ // Feed the 30-day usage history from the GUI's own fetches: the GUI polls
366
+ // every displayed profile on each refresh, so recording the fresh snapshot
367
+ // here (throttled to ~hourly) fills the chart for all profiles without
368
+ // depending on the background daemon or an "active" profile being set.
369
+ if (usage)
370
+ recordHistorySample(path.join(profileDir(pid, active), "usage-history.json"), usage);
298
371
  const wc = worstLiveContext(pid, active);
299
372
  const context = wc
300
373
  ? { sessionId: wc.sessionId, pct: wc.pct, contextTokens: wc.contextTokens, windowTokens: wc.windowTokens, model: wc.model, confidence: wc.confidence }
@@ -328,8 +401,7 @@ async function cmdStatus(providerId, name, json = false) {
328
401
  console.log(" (no usage readout for this provider — shows own usage only where available)");
329
402
  continue;
330
403
  }
331
- const creds = readProfileCredential(configDir("claude", n));
332
- const token = creds ? accessTokenOf(creds) : null;
404
+ const token = freshAccessToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")));
333
405
  if (!token) {
334
406
  console.log(" (credential not readable — profile may not have run yet)");
335
407
  continue;
@@ -353,17 +425,37 @@ async function cmdStatus(providerId, name, json = false) {
353
425
  }
354
426
  }
355
427
  }
356
- function cmdDir(providerId) {
357
- // 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) {
358
440
  const mapped = resolveMapping(process.cwd(), providerId);
359
- if (mapped && profileExists(providerId, mapped.name)) {
360
- 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)
361
448
  return;
362
- }
363
- const active = activeFor(providerId);
364
- if (active && profileExists(providerId, active))
365
- console.log(configDir(providerId, active));
366
- // 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);
367
459
  }
368
460
  function cmdMap(providerId, name, dir) {
369
461
  const n = requireProfile(providerId, name, `map [--provider ${providerId}] <profile> [dir]`);
@@ -451,6 +543,55 @@ function cmdShare(mode, flags = []) {
451
543
  die("usage: agent-switch share on|sync|off|status [--history] [--source <profile|default>]");
452
544
  }
453
545
  }
546
+ /**
547
+ * `agent-switch config export|import <profile> [--full]` — move a Claude
548
+ * profile's SHARED config (settings.json, keybindings.json, CLAUDE.md, skills/,
549
+ * commands/, agents/) between machines as a JSON bundle. Config-ONLY — it never
550
+ * touches credentials. Claude-scoped (config lives under the Claude layout).
551
+ *
552
+ * export → the bundle goes to STDOUT (pipe it: `| gpg -c > backup.gpg`), all
553
+ * status on STDERR so stdout stays a clean bundle.
554
+ * import → reads a bundle from STDIN into the profile's config dir.
555
+ * --full → REFUSED (would bundle live OAuth tokens; account-takeover vector).
556
+ */
557
+ function cmdConfig(sub, name, flags = {}) {
558
+ try {
559
+ assertNotFull(!!flags.full);
560
+ }
561
+ catch (e) {
562
+ die(e.message);
563
+ }
564
+ if (sub === "export") {
565
+ const n = requireProfile("claude", name, "config export");
566
+ const bundle = exportConfig(configDir("claude", n));
567
+ // Status on stderr — stdout is the bundle (so `... | gpg -c > backup.gpg` works).
568
+ console.error(`Exported config-only bundle for claude/${n} (SHARED_ITEMS allowlist; no credentials).`);
569
+ process.stdout.write(bundle + "\n");
570
+ return;
571
+ }
572
+ if (sub === "import") {
573
+ const n = requireProfile("claude", name, "config import");
574
+ let input;
575
+ try {
576
+ input = fs.readFileSync(0, "utf8"); // stdin
577
+ }
578
+ catch {
579
+ die("config import reads a bundle from stdin — pipe one in (e.g. `gpg -d backup.gpg | agent-switch config import <profile>`).");
580
+ }
581
+ let written;
582
+ try {
583
+ written = importConfig(configDir("claude", n), input);
584
+ }
585
+ catch (e) {
586
+ die(e.message);
587
+ }
588
+ console.error(`Imported ${written.length} config file(s) into claude/${n} (config-only; no credentials):`);
589
+ for (const w of written)
590
+ console.error(` ${w}`);
591
+ return;
592
+ }
593
+ die("usage: agent-switch config export|import <profile> (config-only; pipe export | gpg -c > backup.gpg)");
594
+ }
454
595
  // ---------- session inventory + takeover (Claude; Codex lands per G0.3) ------
455
596
  /** Relative age like "3m" / "2h" / "5d" for the sessions table. */
456
597
  function ageOf(mtimeMs) {
@@ -660,6 +801,16 @@ function cmdHookEvent() {
660
801
  at: new Date().toISOString(),
661
802
  };
662
803
  appendEvent(eventFile(ROOT, who.provider, who.profile), rec);
804
+ // Session awareness: surface "Claude needs you / finished" as a notification
805
+ // the GUI routes to the pet. dedupe=false — each turn is a distinct moment,
806
+ // so a solo session's repeated turns are never collapsed. Best-effort; a hook
807
+ // must never throw.
808
+ if (rec.event === "Notification") {
809
+ appendNotification({ kind: "warning", title: "Claude is waiting", message: `${who.provider}/${who.profile} needs your input.` }, undefined, undefined, false);
810
+ }
811
+ else if (rec.event === "Stop") {
812
+ appendNotification({ kind: "info", title: "Claude finished", message: `${who.provider}/${who.profile} finished its turn.` }, undefined, undefined, false);
813
+ }
663
814
  }
664
815
  catch {
665
816
  // never throw from a hook
@@ -1212,7 +1363,8 @@ function cmdLabel(providerId, name, label) {
1212
1363
  setLabel(providerId, name, label);
1213
1364
  console.log(`Labeled ${providerId}/${name} as ${label}.`);
1214
1365
  }
1215
- /** Enable/disable opt-in auto-switch, or show the current setting. */
1366
+ /** Enable/disable opt-in near-limit notifications (notify + suggest, never an
1367
+ * automatic switch), or show the current setting. */
1216
1368
  /** Rename a profile (move its config dir + carry active/label/mappings). */
1217
1369
  function cmdRename(providerId, from, to) {
1218
1370
  const n = requireProfile(providerId, from, "rename");
@@ -1256,23 +1408,7 @@ async function cmdReset(providerId, name) {
1256
1408
  die(`reset failed: ${r.reason ?? "unknown"}`);
1257
1409
  }
1258
1410
  }
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
- }
1411
+ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}) {
1276
1412
  const thresholdFlag = flags.threshold;
1277
1413
  const threshold = typeof thresholdFlag === "string" ? Number(thresholdFlag) : undefined;
1278
1414
  if (threshold !== undefined && (!Number.isFinite(threshold) || threshold < 1 || threshold > 100)) {
@@ -1301,22 +1437,12 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
1301
1437
  ...(tag !== undefined ? { tag } : {}),
1302
1438
  });
1303
1439
  const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
1304
- console.log(`Auto-switch for ${providerId} ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
1440
+ console.log(`Near-limit notifications for ${providerId} ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
1305
1441
  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.");
1442
+ console.log(`Once the active ${providerId} profile hits the threshold, the daemon NOTIFIES you and SUGGESTS the\n` +
1443
+ "account with the most headroom — it never switches automatically; switching stays a manual action.\n" +
1444
+ "On Codex it redeems a banked reset first (and stays on the account) before suggesting a switch.\n" +
1445
+ "Run `agent-switch service start` so the daemon is watching.");
1320
1446
  }
1321
1447
  return;
1322
1448
  }
@@ -1326,15 +1452,14 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
1326
1452
  console.log(JSON.stringify(readAutoSwitchAll()));
1327
1453
  return;
1328
1454
  }
1329
- console.log(`strategy: ${readSwitchStrategy()}`);
1330
1455
  for (const p of providerExplicit ? [providerId] : PROVIDER_IDS) {
1331
1456
  const cfg = readAutoSwitch(p);
1332
1457
  const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
1333
- console.log(`${p}: auto-switch ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
1458
+ console.log(`${p}: near-limit notifications ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
1334
1459
  }
1335
1460
  return;
1336
1461
  }
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]");
1462
+ die("usage: agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] [--tag all|work|personal|other] [--json]");
1338
1463
  }
1339
1464
  /**
1340
1465
  * Enable/disable a provider's surfaces (cli / ui), or show the current setting.
@@ -1551,8 +1676,8 @@ async function cmdGui() {
1551
1676
  // Launch the tray/menubar GUI — downloads its prebuilt artifact from the
1552
1677
  // matching GitHub Release on first use, then caches it (see gui-launch.ts).
1553
1678
  try {
1554
- await launchGui();
1555
- console.log("Launched the agent-switch GUI.");
1679
+ const result = await launchGui();
1680
+ console.log(result === "already-running" ? "The agent-switch GUI is already running." : "Launched the agent-switch GUI.");
1556
1681
  }
1557
1682
  catch (err) {
1558
1683
  die(String(err?.message ?? err));
@@ -1622,6 +1747,63 @@ function cmdOpen(appId, name) {
1622
1747
  console.log("is saved to this profile. Quit other windows of this app before signing in.");
1623
1748
  }
1624
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
+ }
1625
1807
  function cmdShellenv(shellArg) {
1626
1808
  console.log(shellenvScript(detectShell(shellArg)));
1627
1809
  }
@@ -1637,6 +1819,61 @@ function cmdTooling(json = false) {
1637
1819
  console.log(`agent-switch tooling — platform ${process.platform}\n`);
1638
1820
  for (const l of formatToolingLines(tools))
1639
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.`);
1640
1877
  }
1641
1878
  /** `agent-switch tooling install|upgrade <id>` — run the tool's per-platform
1642
1879
  * install/upgrade command as a visible child process (the GUI runs this inside
@@ -1683,6 +1920,7 @@ function usage() {
1683
1920
  console.log(`agent-switch — switch accounts for Claude Code, Codex, and Antigravity (macOS · Linux · Windows)
1684
1921
 
1685
1922
  Provider defaults to claude; pass --provider codex|antigravity for the others.
1923
+ Run with no command to launch the tray/menubar GUI (single-instance).
1686
1924
 
1687
1925
  agent-switch add [--provider P] <name> create a profile and log it in
1688
1926
  agent-switch import [--provider P] <name> migrate the default install (no re-login)
@@ -1693,39 +1931,191 @@ Provider defaults to claude; pass --provider codex|antigravity for the others.
1693
1931
  agent-switch status [--provider P] [name] [--json] identity (+ Claude usage); --json = active only
1694
1932
  agent-switch current [--provider P] show the active profile(s)
1695
1933
  agent-switch whoami [--provider P] [name] show a profile's account identity
1696
- 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)
1697
1936
  agent-switch map [--provider P] <name> [dir] map a directory to a profile
1698
1937
  agent-switch unmap [--provider P] [dir] remove a directory mapping
1699
1938
  agent-switch mappings list directory mappings
1700
1939
  agent-switch share on|sync|off|status [--history] [--source <profile|default>] [--json] (Claude)
1940
+ 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
1941
  agent-switch sessions [profile] [--recent N] [--json] recent + live Claude sessions per profile (with context %)
1702
1942
  agent-switch hooks install|uninstall|status [profile] manage lifecycle push hooks in Claude settings.json
1703
1943
  agent-switch alerts on|off|status [--threshold 80,95] record context/usage crossings to the notification log (off by default)
1704
1944
  agent-switch compact <profile> [--clear] [--dry-run] [--force] type /compact (or /clear) into the profile's managed tmux pane
1705
1945
  agent-switch takeover <id> --to <profile> [--from <profile>] [--keep-source] [--in-place] [--print-only] [--force] move a session to another profile and resume it
1946
+ 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
1947
  agent-switch web <name> claude.ai in a persistent browser (Claude)
1707
1948
  agent-switch remove [--provider P] <name> [--force] delete a profile
1708
1949
  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)
1950
+ agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] notify + suggest near a usage limit; never switches automatically (default OFF)
1710
1951
  agent-switch reset <profile> --provider codex redeem one banked Codex rate-limit reset
1711
1952
  agent-switch rename <old> <new> [--provider P] rename a profile (name & keep its tag)
1712
1953
  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)
1954
+ agent-switch [gui] launch the tray/menubar GUI (the default with no command; won't start a second if one is running)
1714
1955
  agent-switch check-update [--json] check GitHub Releases for a newer version
1715
1956
  agent-switch update self-update to the latest version (via npm)
1716
1957
  agent-switch apps list launchable GUI apps (macOS)
1717
1958
  agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
1718
1959
  agent-switch shellenv [--shell zsh|bash|fish|powershell] shell integration
1719
1960
  agent-switch service run|start|stop|status|install|uninstall background usage daemon
1720
- agent-switch notifications [clear] [--json] recent notifications (auto-switches, fetch failures)
1961
+ agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
1721
1962
  agent-switch notify --kind K --title T --message M [--json] record a notification event
1722
1963
  agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications (default off)
1723
1964
  agent-switch uninstall [--force] remove all agent-switch data + daemon
1724
1965
  agent-switch usage history [--provider P] [--profile <name>] [--json] daemon-recorded usage samples per profile
1725
1966
  agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
1726
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)
1727
1969
  agent-switch doctor per-OS, per-provider self-check`);
1728
1970
  }
1971
+ /**
1972
+ * The interactive limit dialog (Phase 4) — `agent-switch rebind` with no account
1973
+ * and no `--restore`. Gathers the OTHER claude profiles + their usage, pre-selects
1974
+ * the best-headroom profile as a convenience, and prompts the user to pick. Only
1975
+ * an explicit selection switches (via `rebind`); cancel stays put. The user
1976
+ * interaction IS the compliance line — there is NO automatic switch anywhere in
1977
+ * here. macOS-only for the actual switch; a non-TTY stdin prints the suggested
1978
+ * command instead of hanging on a prompt no one can answer.
1979
+ */
1980
+ async function rebindDialog(running) {
1981
+ const others = listProfiles("claude").filter((n) => n !== running);
1982
+ const candidates = [];
1983
+ for (const n of others) {
1984
+ // Same usage read path `status` uses (daemon cache when fresh, else the
1985
+ // OAuth /usage endpoint); null when unreadable — the row shows no %.
1986
+ candidates.push({ name: n, snapshot: await claudeSnapshot(n), label: labelFor("claude", n) });
1987
+ }
1988
+ const dialog = buildRebindDialog(candidates, running);
1989
+ const rows = selectableRows(dialog);
1990
+ if (rows.length === 0) {
1991
+ die(`no other claude profile to rebind "${running}" to — add one with \`agent-switch add <name>\`.`);
1992
+ }
1993
+ // Non-interactive stdin: never hang. Print the pre-selected profile + the exact
1994
+ // command that performs the switch, then return (no switch — the user has to run it).
1995
+ if (!process.stdin.isTTY) {
1996
+ const sug = dialog.suggestedProfile;
1997
+ if (sug) {
1998
+ console.error(`Suggested profile (most headroom): ${sug}`);
1999
+ console.error(`Switch "${running}" to it with: agent-switch rebind ${sug} --profile ${running}`);
2000
+ }
2001
+ else {
2002
+ console.error(`No usage data to pre-select a profile. Switch with: agent-switch rebind <account> --profile ${running}`);
2003
+ }
2004
+ return;
2005
+ }
2006
+ // The actual switch is macOS-only (the Linux/Windows credential backend is
2007
+ // unproven — spike R0.1). Refuse before prompting rather than after a pick.
2008
+ if (process.platform !== "darwin") {
2009
+ die("rebind is macOS-only for now (the Linux/Windows credential backend is unproven — spike R0.1).");
2010
+ }
2011
+ console.log(`Rebind "${running}" to which account? A running Claude session adopts it on its next message.`);
2012
+ rows.forEach((r, i) => {
2013
+ const marker = r.profile === dialog.suggestedProfile ? " ← suggested (most headroom)" : "";
2014
+ const label = r.label ? ` [${r.label}]` : "";
2015
+ const util = r.maxUtil === null ? "usage n/a" : `${r.maxUtil}% used`;
2016
+ console.log(` ${i + 1}) ${(r.profile + label).padEnd(24)} ${util}${marker}`);
2017
+ });
2018
+ const enterHint = dialog.suggestedProfile ?? "(no suggestion)";
2019
+ console.log(` [enter] = ${enterHint} · q = cancel (stay on "${running}")`);
2020
+ const SIGINT = "\u0003"; // sentinel distinct from empty input (empty = accept the suggestion)
2021
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2022
+ const answer = await new Promise((resolve) => {
2023
+ rl.on("SIGINT", () => resolve(SIGINT)); // Ctrl-C -> cancel, no switch
2024
+ rl.question("> ", (a) => resolve(a));
2025
+ });
2026
+ rl.close();
2027
+ if (answer === SIGINT) {
2028
+ console.log(`\nStayed on "${running}" — no switch.`);
2029
+ return;
2030
+ }
2031
+ const choice = resolveRebindChoice(answer, dialog);
2032
+ if (choice.kind === "cancel") {
2033
+ console.log(`Stayed on "${running}" — no switch.`);
2034
+ return;
2035
+ }
2036
+ if (choice.kind === "invalid") {
2037
+ console.log(`No valid selection — stayed on "${running}".`);
2038
+ return;
2039
+ }
2040
+ try {
2041
+ const r = await rebind({ account: choice.profile, profile: running });
2042
+ console.log(r.uxNote);
2043
+ }
2044
+ catch (e) {
2045
+ die(e.message);
2046
+ }
2047
+ }
2048
+ /**
2049
+ * `agent-switch rebind <account> [--profile <p>]` — point a RUNNING profile's
2050
+ * credential store at another account, so a live Claude session adopts it on its
2051
+ * next message (macOS; the user-interaction switch, ADR-003). `--restore` reverses
2052
+ * it. With NO account and NO `--restore`, opens the interactive limit dialog
2053
+ * ({@link rebindDialog}). Claude only; the target + running profiles must be logged in.
2054
+ */
2055
+ async function cmdRebind(providerId, providerExplicit, account, flags) {
2056
+ if (providerExplicit && providerId !== "claude")
2057
+ die(`rebind supports claude only (not ${providerId})`);
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
+ }
2078
+ if (flags.reset) {
2079
+ // Kill-switch reset (ADR-003): clear `disabled` + the failure counter so a
2080
+ // circuit-breaker trip (or a manual disable) no longer refuses rebind.
2081
+ resetRebindKillSwitch();
2082
+ console.log("rebind re-enabled — circuit-breaker cleared (disabled=false, consecutiveFailures=0).");
2083
+ return;
2084
+ }
2085
+ if (flags.restore) {
2086
+ if (!profile)
2087
+ die("usage: agent-switch rebind --restore [--profile <p>] — no active claude profile to restore");
2088
+ requireProfile("claude", profile, "rebind --restore --profile");
2089
+ try {
2090
+ const r = await restoreRebind({ profile });
2091
+ console.log(`Restored "${r.restoredProfile}" to its own account (was rebound to "${r.wasBoundTo}").`);
2092
+ }
2093
+ catch (e) {
2094
+ die(e.message);
2095
+ }
2096
+ return;
2097
+ }
2098
+ if (!account) {
2099
+ // No account + no --restore → the interactive limit dialog (Phase 4). The
2100
+ // switch is user-interaction-gated: the picker pre-selects the best-headroom
2101
+ // profile as a convenience, but only an explicit pick switches.
2102
+ if (!profile)
2103
+ die("no running claude profile — pass --profile <name> or set one active with `asw <name>`.");
2104
+ requireProfile("claude", profile, "rebind --profile");
2105
+ return rebindDialog(profile);
2106
+ }
2107
+ if (!profile)
2108
+ die("no running claude profile — pass --profile <name> or set one active with `asw <name>`.");
2109
+ requireProfile("claude", account, "rebind");
2110
+ requireProfile("claude", profile, "rebind --profile");
2111
+ try {
2112
+ const r = await rebind({ account, profile });
2113
+ console.log(r.uxNote);
2114
+ }
2115
+ catch (e) {
2116
+ die(e.message);
2117
+ }
2118
+ }
1729
2119
  // ---------- main -------------------------------------------------------------
1730
2120
  async function main() {
1731
2121
  const argv = process.argv.slice(2);
@@ -1740,12 +2130,16 @@ async function main() {
1740
2130
  // profiles) skip it.
1741
2131
  if (cmd === "__hook-event")
1742
2132
  return cmdHookEvent(); // internal, silent, no migration
1743
- if (cmd && !["help", "--help", "-h", "shellenv"].includes(cmd)) {
2133
+ // A bare `agent-switch` (no command) launches the GUI, which reads profiles —
2134
+ // so it runs the migration too. Only pure help / shellenv skip it.
2135
+ if (cmd === undefined || !["help", "--help", "-h", "shellenv"].includes(cmd)) {
1744
2136
  const moved = migrateLegacyLayout();
1745
2137
  if (moved.length > 0) {
1746
2138
  // stderr, not stdout: `dir` is machine-consumed by the shell wrapper
1747
- // (`dir="$(agent-switch dir 2>/dev/null)"`), so a status line on stdout
1748
- // 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.
1749
2143
  console.error(`Migrated ${moved.length} Claude profile(s) to the new layout: ${moved.join(", ")}.`);
1750
2144
  }
1751
2145
  }
@@ -1755,7 +2149,7 @@ async function main() {
1755
2149
  case "use": return cmdUse(providerId, positional[0]);
1756
2150
  case "deactivate": return cmdDeactivate(providerId);
1757
2151
  case "label": return cmdLabel(providerId, positional[0], positional[1]);
1758
- case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags, positional[1]);
2152
+ case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags);
1759
2153
  case "reset": return cmdReset(providerId, positional[0]);
1760
2154
  case "rename": return cmdRename(providerId, positional[0], positional[1]);
1761
2155
  case "providers": return cmdProviders(providerId, providerExplicit, positional[0], flags);
@@ -1780,11 +2174,13 @@ async function main() {
1780
2174
  case "status": return cmdStatus(providerExplicit ? providerId : undefined, positional[0], !!flags.json);
1781
2175
  case "current": return cmdCurrent(providerExplicit ? providerId : undefined);
1782
2176
  case "whoami": return cmdWhoami(providerId, positional[0]);
1783
- case "dir": return cmdDir(providerId);
2177
+ case "dir": return cmdDir(providerId, !!flags.guard);
2178
+ case "guard": return cmdGuard(positional[0], !!flags.json);
1784
2179
  case "map": return cmdMap(providerId, positional[0], positional[1]);
1785
2180
  case "unmap": return cmdUnmap(providerExplicit ? providerId : undefined, positional[0]);
1786
2181
  case "mappings": return cmdMappings();
1787
2182
  case "share": return cmdShare(positional[0], rest.slice(1));
2183
+ case "config": return cmdConfig(positional[0], positional[1], flags);
1788
2184
  case "sessions":
1789
2185
  if (positional[0] === "rm")
1790
2186
  return cmdSessionsRm(providerId, providerExplicit, positional[1], flags);
@@ -1797,6 +2193,7 @@ async function main() {
1797
2193
  case "alerts": return cmdAlerts(positional[0], flags);
1798
2194
  case "compact": return cmdCompact(providerId, positional[0], flags);
1799
2195
  case "takeover": return cmdTakeover(providerId, providerExplicit, positional[0], flags);
2196
+ case "rebind": return cmdRebind(providerId, providerExplicit, positional[0], flags);
1800
2197
  case "web": return cmdWeb(positional[0]);
1801
2198
  case "remove":
1802
2199
  case "rm": return cmdRemove(providerId, positional[0], !!flags.force);
@@ -1807,17 +2204,23 @@ async function main() {
1807
2204
  if (positional[0] === "install" || positional[0] === "upgrade") {
1808
2205
  return cmdToolingAction(positional[0], positional[1]);
1809
2206
  }
2207
+ if (positional[0] === "notes")
2208
+ return cmdToolingNotes(positional[1], flags);
1810
2209
  if (positional[0] !== undefined) {
1811
- 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]`);
1812
2211
  }
1813
2212
  return cmdTooling(!!flags.json);
1814
2213
  case "doctor": return process.exit(await runDoctor());
1815
2214
  case "help":
1816
2215
  case "--help":
1817
2216
  case "-h": return usage();
2217
+ // A bare `agent-switch` (no command) launches the GUI; an unknown command
2218
+ // still prints usage and exits non-zero.
1818
2219
  default:
2220
+ if (cmd === undefined)
2221
+ return cmdGui();
1819
2222
  usage();
1820
- process.exit(cmd ? 1 : 0);
2223
+ process.exit(1);
1821
2224
  }
1822
2225
  }
1823
2226
  main().catch((err) => die(String(err?.message ?? err)));