@event4u/agent-switch 2.0.0 → 2.2.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/share.js CHANGED
@@ -15,9 +15,15 @@
15
15
  * re-links (last-sync-wins across profiles). On win32, file symlinks need
16
16
  * Developer Mode/admin, so file links degrade with a message there.
17
17
  *
18
+ * - Prompt history (`history.jsonl`, what ↑ and Ctrl+R search) — always
19
+ * linked: it holds no account state, and Claude Code only ever APPENDS to
20
+ * it (under a lock), so the link never forks. A profile's own file is
21
+ * merged into the shared one (chronological, de-duplicated) and kept as a
22
+ * `history.jsonl.pre-share*` backup instead of blocking the link.
23
+ *
18
24
  * Account-/instance-scoped items (`.claude.json`, `.credentials.json`,
19
25
  * `plugins/`, `sessions/`, `ide/`, `statsig/`) are deliberately never shared.
20
- * History (`projects/`, `history.jsonl`, `plans/`) is a POSIX-only opt-in.
26
+ * Conversations (`projects/`, `plans/`) are a POSIX-only opt-in (`--history`).
21
27
  * (`plans/` defaults to `<CLAUDE_CONFIG_DIR>/plans/` in Claude Code, so plan-mode
22
28
  * files are otherwise isolated per profile — verified against claude-code 2.1.215.)
23
29
  *
@@ -34,18 +40,21 @@ const BASE_ITEMS = [
34
40
  { name: "commands", kind: "dir" },
35
41
  { name: "agents", kind: "dir" },
36
42
  ];
37
- /** Conversation history + plan-mode files; opt-in — shares `claude --resume`
43
+ /** Append-only logs shared by every `share on`, but never part of a config
44
+ * export (`SHARED_ITEMS` is that export's allowlist). */
45
+ const LOG_ITEMS = [{ name: "history.jsonl", kind: "log" }];
46
+ /** Conversations + plan-mode files; opt-in — shares `claude --resume`
38
47
  * and the plan store across accounts. `plans/` is a `dir`, so writes inside it
39
48
  * reach the source (no atomic-rename fork), exactly like `projects/`. */
40
49
  const HISTORY_ITEMS = [
41
50
  { name: "projects", kind: "dir" },
42
- { name: "history.jsonl", kind: "file" },
43
51
  { name: "plans", kind: "dir" },
44
52
  ];
45
53
  /** Names exported for messaging/tests. */
46
54
  export const SHARED_ITEMS = BASE_ITEMS.map((i) => i.name);
47
- export const HISTORY_ITEM_NAMES = HISTORY_ITEMS.map((i) => i.name);
48
- const ITEM_KIND = new Map([...BASE_ITEMS, ...HISTORY_ITEMS].map((i) => [i.name, i.kind]));
55
+ export const PROMPT_HISTORY = LOG_ITEMS[0].name;
56
+ export const HISTORY_ITEM_NAMES = [...HISTORY_ITEMS, ...LOG_ITEMS].map((i) => i.name);
57
+ const ITEM_KIND = new Map([...BASE_ITEMS, ...LOG_ITEMS, ...HISTORY_ITEMS].map((i) => [i.name, i.kind]));
49
58
  const MANIFEST = ".agent-switch-shared.json";
50
59
  function readManifest(dir) {
51
60
  try {
@@ -72,6 +81,59 @@ function link(src, dst, kind) {
72
81
  fs.symlinkSync(src, dst, process.platform === "win32" ? "file" : undefined);
73
82
  }
74
83
  }
84
+ const stampOf = (l) => {
85
+ try {
86
+ const t = JSON.parse(l)?.timestamp;
87
+ return typeof t === "number" && Number.isFinite(t) ? t : null;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ };
93
+ /**
94
+ * Merge the lines of `extra` into the prompt history at `src`: de-duplicated by
95
+ * exact line, ordered by timestamp (Claude Code reads the file newest-last, so
96
+ * ↑ walks back in time). Unparseable lines keep their place after their
97
+ * predecessor. Written via temp + rename; re-read when the source grew while we
98
+ * merged, so a concurrent append is not lost. Returns the lines added.
99
+ */
100
+ export function mergePromptHistory(src, extra) {
101
+ const linesOf = (f) => fs.existsSync(f) ? fs.readFileSync(f, "utf8").split("\n").filter((l) => l.trim() !== "") : [];
102
+ const incoming = linesOf(extra);
103
+ for (let attempt = 0;; attempt++) {
104
+ const before = fs.existsSync(src) ? fs.statSync(src).size : -1;
105
+ const own = linesOf(src);
106
+ const seen = new Set(own);
107
+ const added = incoming.filter((l) => !seen.has(l) && (seen.add(l), true));
108
+ if (added.length === 0)
109
+ return 0;
110
+ let last = 0;
111
+ const keyed = [...own, ...added].map((l, i) => {
112
+ last = stampOf(l) ?? last;
113
+ return { l, i, t: last };
114
+ });
115
+ keyed.sort((a, b) => a.t - b.t || a.i - b.i);
116
+ const tmp = `${src}.agent-switch-merge`;
117
+ fs.writeFileSync(tmp, keyed.map((k) => k.l).join("\n") + "\n", { mode: 0o600 });
118
+ const after = fs.existsSync(src) ? fs.statSync(src).size : -1;
119
+ if (after !== before && attempt < 3) {
120
+ fs.unlinkSync(tmp);
121
+ continue;
122
+ }
123
+ fs.renameSync(tmp, src);
124
+ return added.length;
125
+ }
126
+ }
127
+ /** Merge a profile's own prompt history into the shared one, keep the original
128
+ * as a backup next to it, and leave the slot free for the link. */
129
+ function adoptLog(src, dst, name) {
130
+ const n = mergePromptHistory(src, dst);
131
+ let backup = `${dst}.pre-share`;
132
+ if (fs.existsSync(backup))
133
+ backup = `${backup}-${Date.now()}`;
134
+ fs.renameSync(dst, backup);
135
+ return `merged ${n} prompt(s) from ${name} (original kept as ${path.basename(backup)})`;
136
+ }
75
137
  /** True when the existing symlink already points at src (junction paths on
76
138
  * win32 may carry a trailing separator / `\\?\` prefix, so compare loosely). */
77
139
  function pointsAt(dst, src) {
@@ -108,14 +170,12 @@ export function settingsSharingRisk(settingsPath) {
108
170
  /** Link items from sourceDir into targetDir. Returns human-readable actions. */
109
171
  export function applySharing(sourceDir, targetDir, withHistory) {
110
172
  const actions = [];
111
- const items = withHistory ? [...BASE_ITEMS, ...HISTORY_ITEMS] : [...BASE_ITEMS];
173
+ const items = withHistory ? [...BASE_ITEMS, ...LOG_ITEMS, ...HISTORY_ITEMS] : [...BASE_ITEMS, ...LOG_ITEMS];
112
174
  const managed = new Set(readManifest(targetDir));
113
175
  fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
114
176
  for (const item of items) {
115
177
  const src = path.join(sourceDir, item.name);
116
178
  const dst = path.join(targetDir, item.name);
117
- if (!fs.existsSync(src))
118
- continue;
119
179
  let st = null;
120
180
  try {
121
181
  st = fs.lstatSync(dst);
@@ -123,6 +183,12 @@ export function applySharing(sourceDir, targetDir, withHistory) {
123
183
  catch {
124
184
  /* absent */
125
185
  }
186
+ // The shared prompt history starts empty when the source never had one.
187
+ if (item.kind === "log" && !fs.existsSync(src) && process.platform !== "win32") {
188
+ fs.writeFileSync(src, "", { mode: 0o600 });
189
+ }
190
+ if (!fs.existsSync(src))
191
+ continue;
126
192
  if (st?.isSymbolicLink()) {
127
193
  if (pointsAt(dst, src)) {
128
194
  managed.add(item.name);
@@ -136,14 +202,19 @@ export function applySharing(sourceDir, targetDir, withHistory) {
136
202
  }
137
203
  else if (st) {
138
204
  // A regular file/dir sits where a link would go.
139
- if (item.kind === "file" && managed.has(item.name)) {
205
+ if (item.kind === "log" && st.isFile() && process.platform !== "win32") {
206
+ actions.push(adoptLog(src, dst, item.name));
207
+ }
208
+ else if (item.kind === "file" && managed.has(item.name)) {
140
209
  // We linked it before; it's now a regular file → an in-profile write
141
210
  // forked it. Don't clobber — `share sync` reconciles it.
142
211
  actions.push(`forked ${item.name}: run 'agent-switch share sync' to re-link`);
143
212
  continue;
144
213
  }
145
- actions.push(`skipped ${item.name}: profile has its own copy (remove it to share)`);
146
- continue;
214
+ else {
215
+ actions.push(`skipped ${item.name}: profile has its own copy (remove it to share)`);
216
+ continue;
217
+ }
147
218
  }
148
219
  if (item.name === "settings.json") {
149
220
  const risk = settingsSharingRisk(src);
@@ -158,7 +229,7 @@ export function applySharing(sourceDir, targetDir, withHistory) {
158
229
  actions.push(`linked ${item.name}`);
159
230
  }
160
231
  catch (err) {
161
- if (process.platform === "win32" && item.kind === "file") {
232
+ if (process.platform === "win32" && item.kind !== "dir") {
162
233
  actions.push(`skipped ${item.name}: Windows file symlinks need Developer Mode or admin ` +
163
234
  `(directories are shared via junction regardless)`);
164
235
  }
@@ -199,7 +270,11 @@ export function syncSharing(sourceDir, targetDir) {
199
270
  }
200
271
  if (st.isSymbolicLink())
201
272
  continue; // intact
202
- if (st.isFile()) {
273
+ if (st.isFile() && kind === "log") {
274
+ actions.push(`${adoptLog(src, dst, name)}, re-linked`);
275
+ link(src, dst, kind);
276
+ }
277
+ else if (st.isFile()) {
203
278
  // Forked file: propagate the profile's edit to the source, then re-link.
204
279
  if (name === "settings.json") {
205
280
  const risk = settingsSharingRisk(dst);
@@ -244,7 +319,11 @@ export function removeSharing(targetDir) {
244
319
  // A symlink (POSIX) or a junction (win32, reported as a symlink by
245
320
  // lstat) is ours to remove; a forked regular file is the user's, leave it.
246
321
  if (st.isSymbolicLink()) {
322
+ const shared = ITEM_KIND.get(name) === "log" && fs.existsSync(dst) ? fs.realpathSync(dst) : null;
247
323
  fs.unlinkSync(dst);
324
+ // Unsharing must not empty a profile's prompt history: it keeps a copy.
325
+ if (shared)
326
+ fs.copyFileSync(shared, dst);
248
327
  actions.push(`unlinked ${name}`);
249
328
  }
250
329
  }
package/dist/shellenv.js CHANGED
@@ -13,8 +13,31 @@
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
  */
37
+ import * as fs from "node:fs";
38
+ import * as path from "node:path";
17
39
  import { allProviders } from "./providers.js";
40
+ import { GUARD_REFUSED_EXIT } from "./single-load.js";
18
41
  export const SHELLS = ["zsh", "bash", "fish", "powershell"];
19
42
  /** Resolve the target shell: explicit request wins, else detect from the
20
43
  * environment / platform. Windows defaults to PowerShell; POSIX reads $SHELL. */
@@ -38,10 +61,58 @@ export function detectShell(requested, env = process.env, platform = process.pla
38
61
  return "zsh";
39
62
  return "zsh"; // sensible POSIX default when $SHELL is unset
40
63
  }
64
+ // The CLI the running app activated (`agent-switch activate` writes its path to
65
+ // <home>/active-cli), so a dev build and an installed app each bring their own;
66
+ // a missing or vanished path falls back to agent-switch on PATH.
67
+ const POSIX_BIN = `__agent_switch_bin() {
68
+ local p
69
+ p="$(cat "\${AGENT_SWITCH_HOME:-$HOME/.agent-switch}/active-cli" 2>/dev/null)"
70
+ if [ -n "$p" ] && [ -x "$p" ]; then printf '%s\\n' "$p"
71
+ elif command -v agent-switch >/dev/null 2>&1; then printf 'agent-switch\\n'
72
+ fi
73
+ }
74
+ __agent_switch() {
75
+ local as
76
+ as="$(__agent_switch_bin)"
77
+ command "\${as:-agent-switch}" "$@"
78
+ }`;
79
+ const FISH_BIN = `function __agent_switch_bin
80
+ set -l home $HOME/.agent-switch
81
+ set -q AGENT_SWITCH_HOME; and set home $AGENT_SWITCH_HOME
82
+ if test -r $home/active-cli
83
+ set -l p (cat $home/active-cli)
84
+ if test -x "$p"
85
+ echo $p
86
+ return
87
+ end
88
+ end
89
+ type -q agent-switch; and echo agent-switch
90
+ end
91
+ function __agent_switch
92
+ set -l as (__agent_switch_bin)
93
+ test -n "$as"; or set as agent-switch
94
+ command $as $argv
95
+ end`;
96
+ const POWERSHELL_BIN = `function __agent_switch_bin {
97
+ $home_ = if ($env:AGENT_SWITCH_HOME) { $env:AGENT_SWITCH_HOME } else { Join-Path $HOME '.agent-switch' }
98
+ $p = Get-Content -Path (Join-Path $home_ 'active-cli') -ErrorAction SilentlyContinue | Select-Object -First 1
99
+ if ($p -and (Test-Path $p)) { return $p }
100
+ if (Get-Command agent-switch -ErrorAction SilentlyContinue) { return 'agent-switch' }
101
+ return $null
102
+ }
103
+ function __agent_switch {
104
+ $as = __agent_switch_bin
105
+ if (-not $as) { $as = 'agent-switch' }
106
+ if ($as -like '*.js') { & node $as @args } else { & $as @args }
107
+ }`;
41
108
  function posixWrapper(binary, envVar, id) {
42
109
  return `${binary}() {
43
- local dir
44
- dir="$(command agent-switch dir --provider ${id} 2>/dev/null)"
110
+ local dir rc as
111
+ as="$(__agent_switch_bin)"
112
+ [ -n "$as" ] || { command ${binary} "$@"; return; }
113
+ dir="$(command "$as" dir --provider ${id} --guard)"
114
+ rc=$?
115
+ if [ $rc -eq ${GUARD_REFUSED_EXIT} ]; then return $rc; fi
45
116
  if [ -n "$dir" ]; then
46
117
  ${envVar}="$dir" command ${binary} "$@"
47
118
  else
@@ -51,7 +122,16 @@ function posixWrapper(binary, envVar, id) {
51
122
  }
52
123
  function fishWrapper(binary, envVar, id) {
53
124
  return `function ${binary}
54
- set -l dir (command agent-switch dir --provider ${id} 2>/dev/null)
125
+ set -l as (__agent_switch_bin)
126
+ if test -z "$as"
127
+ command ${binary} $argv
128
+ return
129
+ end
130
+ set -l dir (command $as dir --provider ${id} --guard)
131
+ set -l rc $status
132
+ if test $rc -eq ${GUARD_REFUSED_EXIT}
133
+ return $rc
134
+ end
55
135
  if test -n "$dir"
56
136
  ${envVar}=$dir command ${binary} $argv
57
137
  else
@@ -63,7 +143,10 @@ function powershellWrapper(binary, envVar, id) {
63
143
  return `function ${binary} {
64
144
  $exe = Get-Command ${binary} -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
65
145
  if (-not $exe) { Write-Error '${binary} not found on PATH.'; return }
66
- $dir = (& agent-switch dir --provider ${id} 2>$null)
146
+ $as = __agent_switch_bin
147
+ if (-not $as) { & $exe.Source @args; return }
148
+ $dir = (__agent_switch dir --provider ${id} --guard)
149
+ if ($LASTEXITCODE -eq ${GUARD_REFUSED_EXIT}) { return }
67
150
  if ($dir) {
68
151
  $prev = Get-Item -Path Env:\\${envVar} -ErrorAction SilentlyContinue
69
152
  $env:${envVar} = $dir
@@ -73,8 +156,8 @@ function powershellWrapper(binary, envVar, id) {
73
156
  }
74
157
  }`;
75
158
  }
76
- function build(header, wrappers, asw) {
77
- return [header, ...wrappers, asw].join("\n");
159
+ function build(header, bin, wrappers, asw) {
160
+ return [header, bin, ...wrappers, asw].join("\n");
78
161
  }
79
162
  export function shellenvScript(shell) {
80
163
  const providers = allProviders();
@@ -83,34 +166,64 @@ export function shellenvScript(shell) {
83
166
  const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
84
167
  function asw
85
168
  if test (count $argv) -eq 0
86
- command agent-switch list
169
+ __agent_switch list
87
170
  else if contains -- $argv[1] claude codex antigravity
88
- command agent-switch use $argv[2] --provider $argv[1]
171
+ __agent_switch use $argv[2] --provider $argv[1]
89
172
  else
90
- command agent-switch use $argv
173
+ __agent_switch use $argv
91
174
  end
92
175
  end`;
93
- return build(header, providers.map((p) => fishWrapper(p.binary, p.envVar, p.id)), asw);
176
+ return build(header, FISH_BIN, providers.map((p) => fishWrapper(p.binary, p.envVar, p.id)), asw);
94
177
  }
95
178
  if (shell === "powershell") {
96
179
  const header = `# agent-switch shell integration — add to $PROFILE: agent-switch shellenv --shell powershell | Out-String | Invoke-Expression`;
97
180
  const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
98
181
  function asw {
99
- if ($args.Count -eq 0) { command agent-switch list; return }
100
- if (@('claude','codex','antigravity') -contains $args[0]) { & agent-switch use $args[1] --provider $args[0] }
101
- else { & agent-switch use @args }
182
+ if ($args.Count -eq 0) { __agent_switch list; return }
183
+ if (@('claude','codex','antigravity') -contains $args[0]) { __agent_switch use $args[1] --provider $args[0] }
184
+ else { __agent_switch use @args }
102
185
  }`;
103
- return build(header, providers.map((p) => powershellWrapper(p.binary, p.envVar, p.id)), asw);
186
+ return build(header, POWERSHELL_BIN, providers.map((p) => powershellWrapper(p.binary, p.envVar, p.id)), asw);
104
187
  }
105
188
  // zsh + bash share the POSIX snippet.
106
189
  const header = `# agent-switch shell integration — add to your rc file: eval "$(agent-switch shellenv)"`;
107
190
  const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
108
191
  asw() {
109
- if [ $# -eq 0 ]; then command agent-switch list; return; fi
192
+ if [ $# -eq 0 ]; then __agent_switch list; return; fi
110
193
  case "$1" in
111
- claude|codex|antigravity) command agent-switch use "$2" --provider "$1" ;;
112
- *) command agent-switch use "$@" ;;
194
+ claude|codex|antigravity) __agent_switch use "$2" --provider "$1" ;;
195
+ *) __agent_switch use "$@" ;;
113
196
  esac
114
197
  }`;
115
- return build(header, providers.map((p) => posixWrapper(p.binary, p.envVar, p.id)), asw);
198
+ return build(header, POSIX_BIN, providers.map((p) => posixWrapper(p.binary, p.envVar, p.id)), asw);
199
+ }
200
+ /** The line that loads the integration, per shell (same as the docs table). */
201
+ export const SHELLENV_LINE = {
202
+ zsh: 'eval "$(agent-switch shellenv)"',
203
+ bash: 'eval "$(agent-switch shellenv)"',
204
+ fish: "agent-switch shellenv --shell fish | source",
205
+ powershell: "agent-switch shellenv --shell powershell | Out-String | Invoke-Expression",
206
+ };
207
+ /** The startup file `shellenv install` writes to. macOS opens login shells,
208
+ * which read ~/.bash_profile rather than ~/.bashrc. */
209
+ export function rcFileFor(shell, home, platform = process.platform) {
210
+ if (shell === "zsh")
211
+ return path.join(home, ".zshrc");
212
+ if (shell === "bash")
213
+ return path.join(home, platform === "darwin" ? ".bash_profile" : ".bashrc");
214
+ if (shell === "fish")
215
+ return path.join(home, ".config", "fish", "config.fish");
216
+ return path.join(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1");
217
+ }
218
+ /** Append the loader line to the shell's startup file, once. `present` says
219
+ * whether any known startup file already loads it (the doctor check). */
220
+ export function installShellIntegration(shell, home, present) {
221
+ const file = rcFileFor(shell, home);
222
+ if (present)
223
+ return { shell, file, alreadyInstalled: true };
224
+ const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
225
+ const sep = before === "" || before.endsWith("\n") ? "" : "\n";
226
+ fs.mkdirSync(path.dirname(file), { recursive: true });
227
+ fs.writeFileSync(file, `${before}${sep}\n# agent-switch shell integration\n${SHELLENV_LINE[shell]}\n`);
228
+ return { shell, file, alreadyInstalled: false };
116
229
  }
@@ -0,0 +1,176 @@
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
+ /** What a state file without a `guard` key meant before 2.2 — kept so an
29
+ * upgrade never changes a user's effective guard. */
30
+ export const DEFAULT_GUARD_POLICY = "block";
31
+ /** A fresh install (no state file and no profile yet) starts on `warn`: a second load is
32
+ * reported, never refused, so nothing changes how a tool starts. */
33
+ export const FRESH_INSTALL_GUARD_POLICY = "warn";
34
+ export function isGuardPolicy(v) {
35
+ return typeof v === "string" && GUARD_POLICIES.includes(v);
36
+ }
37
+ /** Env var that waives the guard for exactly one invocation. An env var, not a
38
+ * flag: the shell wrapper passes every unknown flag THROUGH to the provider
39
+ * binary, so a `--force` there would reach `claude`, not us. */
40
+ export const OVERRIDE_ENV = "AGENT_SWITCH_ALLOW_DUPLICATE";
41
+ /** Exit code the wrapper keys on. Distinct from every generic failure (1, 2,
42
+ * 127 …) so the wrapper blocks on a REFUSAL and never on a missing or older
43
+ * `agent-switch` — fail-open is deliberate: this tool must never be the reason
44
+ * a user cannot start their assistant. */
45
+ export const GUARD_REFUSED_EXIT = 86;
46
+ function pidFileMtime(configDir, pid) {
47
+ try {
48
+ return fs.statSync(path.join(configDir, "sessions", `${pid}.json`)).mtimeMs;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /**
55
+ * Who currently holds `configDir`, and whether we are a child of one of them.
56
+ *
57
+ * `envVar` is the provider's isolation variable (`CLAUDE_CONFIG_DIR`, …). The
58
+ * nested comparison is on the RAW string, deliberately unresolved: that same
59
+ * unresolved string is what Claude Code hashes into its keychain service name
60
+ * (`profiles.ts` § configDir), so resolving it here would compare a different
61
+ * identity than the one the rest of the tool uses.
62
+ */
63
+ export function inspectLoad(configDir, envVar, deps = {}) {
64
+ const livePidsOf = deps.livePidsOf ?? liveSessionPids;
65
+ const cwdOf = deps.cwdOf ?? pidCwd;
66
+ const startedMsOf = deps.startedMsOf ?? pidFileMtime;
67
+ const env = deps.env ?? process.env;
68
+ const nested = env[envVar] === configDir;
69
+ const pids = livePidsOf(configDir);
70
+ // A nested start is allowed unconditionally, so its message is never
71
+ // rendered — and resolving a cwd costs an `lsof` per pid on darwin. Skip the
72
+ // lookup rather than pay it on a path that cannot use the answer.
73
+ const sessions = pids.map((pid) => ({
74
+ pid,
75
+ cwd: nested ? null : cwdOf(pid),
76
+ startedMs: startedMsOf(configDir, pid),
77
+ }));
78
+ return { sessions, loaded: sessions.length > 0, nested };
79
+ }
80
+ /**
81
+ * Pure policy resolution. Order matters and each precedence is load-bearing:
82
+ * an undetectable provider can never refuse (no evidence), a nested start is
83
+ * a deliberate child rather than a duplicate, and an explicit override outranks
84
+ * the policy because the user just stated an intent this turn.
85
+ */
86
+ export function guardVerdict({ policy, inspection, overridden = false, detectable = true }) {
87
+ if (!detectable)
88
+ return "allow";
89
+ if (policy === "off")
90
+ return "allow";
91
+ if (!inspection.loaded)
92
+ return "allow";
93
+ if (inspection.nested)
94
+ return "allow";
95
+ if (overridden)
96
+ return "warn"; // proceed, but still say what is happening
97
+ return policy === "warn" ? "warn" : "refuse";
98
+ }
99
+ /** True when the one-shot override env var is set to a meaningful value. */
100
+ export function overrideFromEnv(env = process.env) {
101
+ const v = env[OVERRIDE_ENV];
102
+ return typeof v === "string" && v !== "" && v !== "0" && v.toLowerCase() !== "false";
103
+ }
104
+ /** Relative age like "3m" / "2h" — mirrors the sessions listing's `ageOf`. */
105
+ function ageOf(startedMs, now) {
106
+ if (startedMs === null)
107
+ return null;
108
+ const s = Math.max(0, Math.round((now - startedMs) / 1000));
109
+ if (s < 60)
110
+ return `${s}s`;
111
+ if (s < 3600)
112
+ return `${Math.round(s / 60)}m`;
113
+ if (s < 86400)
114
+ return `${Math.round(s / 3600)}h`;
115
+ return `${Math.round(s / 86400)}d`;
116
+ }
117
+ /** Render actions as a table whose column width comes from the widest action,
118
+ * so a long override line can never collide with its own explanation. */
119
+ function actionTable(rows) {
120
+ const width = Math.max(...rows.map(([action]) => action.length));
121
+ return rows.map(([action, explanation]) => ` ${action.padEnd(width + 2)}${explanation}`);
122
+ }
123
+ /**
124
+ * The one place the guard speaks. Every enforcement point renders through this
125
+ * so the CLI, the wrapper path, and `use` never drift into three dialects.
126
+ *
127
+ * A refusal that only says "no" hands the problem straight back, so the message
128
+ * names WHO holds the profile (pid, age, directory), WHY that matters, and the
129
+ * concrete ways forward — including the names of the profiles that are actually
130
+ * free, and how to switch the guard off.
131
+ */
132
+ export function renderGuardMessage(input) {
133
+ const { profile, binary, verdict, inspection, offerForceFlag = false, alternatives = [], context = "start", now = Date.now(), } = input;
134
+ const n = inspection.sessions.length;
135
+ const held = `profile "${profile}" is already loaded by ${n === 1 ? "a running" : `${n} running`} ${binary} session${n === 1 ? "" : "s"}`;
136
+ const headline = context === "use"
137
+ ? verdict === "refuse"
138
+ ? `${held} — the next \`${binary}\` started here will be refused.`
139
+ : `${held} — a second session will be allowed (guard: warn).`
140
+ : verdict === "refuse"
141
+ ? `${held}.`
142
+ : `${held} — continuing anyway.`;
143
+ const lines = [headline, ""];
144
+ for (const sess of inspection.sessions) {
145
+ const age = ageOf(sess.startedMs, now);
146
+ const parts = [` pid ${sess.pid}`];
147
+ if (age)
148
+ parts.push(`started ${age} ago`);
149
+ if (sess.cwd)
150
+ parts.push(sess.cwd);
151
+ lines.push(parts.join(" "));
152
+ }
153
+ lines.push("", "Two sessions on one account share its rate limits, and both refresh the", "same rotating OAuth token.");
154
+ if (verdict === "refuse") {
155
+ const rows = [];
156
+ rows.push([
157
+ `agent-switch use ${alternatives.length > 0 ? alternatives[0] : "<other-profile>"}`,
158
+ alternatives.length > 1
159
+ ? `switch to a free account — also free: ${alternatives.slice(1).join(", ")}`
160
+ : alternatives.length === 1
161
+ ? "switch to the account that is free"
162
+ : "point new sessions at another account",
163
+ ]);
164
+ // Both overrides do the same thing, so only one is offered: `--force` where
165
+ // the caller can accept a flag, the env var everywhere else. A shell wrapper
166
+ // passes unknown flags THROUGH to the provider binary, which is why the env
167
+ // var exists at all — offering both would just be two names for one action.
168
+ rows.push(offerForceFlag
169
+ ? ["--force", "start it anyway, this once"]
170
+ : [`${OVERRIDE_ENV}=1 ${binary}`, "start it anyway, this once"]);
171
+ rows.push(["agent-switch guard warn", "warn instead of refusing, from now on"]);
172
+ rows.push(["agent-switch guard off", "turn the guard off entirely"]);
173
+ lines.push("", "Ways forward:", ...actionTable(rows));
174
+ }
175
+ return lines.join("\n");
176
+ }
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
+ }