@event4u/agent-switch 1.6.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Keep a Claude profile's OAuth access token usable for the read-only usage /
3
+ * profile endpoints — WITHOUT agent-switch ever touching the credential store.
4
+ *
5
+ * The problem: a stored access token lives ~8h, the refresh token ~3 weeks.
6
+ * agent-switch reads the credential and calls the OAuth endpoints with whatever
7
+ * access token is in the store. Once that token expires the endpoints 401 and
8
+ * usage silently freezes on the last cached numbers — for exactly the profiles
9
+ * the operator is NOT actively using, which is the whole point of the readout.
10
+ * Starting Claude Code on the profile "fixes" it, because Claude Code refreshes.
11
+ *
12
+ * The mechanism: let Claude Code do the refresh. `claude doctor` is a LOCAL
13
+ * health check — it runs no completion and consumes no quota — but it goes
14
+ * through Claude Code's auth path, so an expired token is refreshed first,
15
+ * under Claude Code's own lock, into Claude Code's own store. Verified against
16
+ * CC 2.1.x (2026-07-25): with `expiresAt` in the past, `claude doctor` rotates
17
+ * BOTH the access and the refresh token and moves `expiresAt` ~8h out.
18
+ *
19
+ * Why not the refresh grant ourselves: that rotation is precisely the hazard the
20
+ * Phase-4 lock recorded — an out-of-band grant invalidates the refresh token a
21
+ * live session still holds. Delegating honours that lock instead of reopening
22
+ * it: agent-switch stays read-only outside `rebind` (ADR-003), and the one
23
+ * process allowed to rotate the token family is the one that owns it. Full
24
+ * rationale, evidence, and the rejected alternatives: ADR-004.
25
+ */
26
+ import { spawnSync } from "node:child_process";
27
+ import * as crypto from "node:crypto";
28
+ import * as fs from "node:fs";
29
+ import * as os from "node:os";
30
+ import * as path from "node:path";
31
+ import { accessTokenOf, readProfileCredential } from "./api.js";
32
+ import { ROOT } from "./profiles.js";
33
+ import { resolveBinary } from "./providers.js";
34
+ /** Treat a token as spent this close to `expiresAt` — mirrors Claude Code's own
35
+ * 5-minute refresh buffer, so we never race it into a just-expired window. */
36
+ export const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
37
+ /** Minimum gap between two freshen attempts for one profile. A truly dead login
38
+ * (refresh token expired / revoked) cannot be healed by retrying, and the GUI
39
+ * re-reads usage every few seconds — without this floor a dead profile would
40
+ * spawn `claude doctor` on every cycle. */
41
+ export const FRESHEN_COOLDOWN_MS = 10 * 60 * 1000;
42
+ /** How long to let Claude Code's health check run before giving up on it. The
43
+ * observed run is a few seconds; this is a stuck-process guard, and it bounds
44
+ * how long a daemon poll cycle can stall on one profile. */
45
+ const FRESHEN_TIMEOUT_MS = 30_000;
46
+ /** `expiresAt` (epoch ms) out of a credential blob; null when absent/unparseable. */
47
+ export function tokenExpiresAt(credentials) {
48
+ if (!credentials)
49
+ return null;
50
+ try {
51
+ const v = JSON.parse(credentials)?.claudeAiOauth?.expiresAt;
52
+ return typeof v === "number" ? v : null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * Is this credential's access token spent (or about to be)? Pure — the testable
60
+ * core. A credential we cannot read or whose `expiresAt` we cannot parse is NOT
61
+ * reported as expired: we have no evidence it is, and spawning Claude Code on a
62
+ * guess is worse than one failed read.
63
+ */
64
+ export function needsFreshen(credentials, now, bufferMs = EXPIRY_BUFFER_MS) {
65
+ const exp = tokenExpiresAt(credentials);
66
+ if (exp === null)
67
+ return false;
68
+ return exp - now <= bufferMs;
69
+ }
70
+ /** The attempt-stamp file for a config dir. Keyed by hash so the path stays
71
+ * flat and filesystem-safe, and lives in agent-switch's own root — never
72
+ * inside Claude Code's config dir. */
73
+ function attemptStampFile(configDir) {
74
+ const digest = crypto.createHash("sha256").update(configDir.normalize("NFC"), "utf8").digest("hex").slice(0, 12);
75
+ return path.join(ROOT, "freshen", `${digest}`);
76
+ }
77
+ /** Cooldown state is a FILE, not a module variable: the GUI reads usage by
78
+ * spawning a fresh `agent-switch status --json` per profile, so an in-process
79
+ * map would reset on every read and defeat the floor entirely. */
80
+ function readLastAttemptFile(configDir) {
81
+ try {
82
+ const n = Number(fs.readFileSync(attemptStampFile(configDir), "utf8").trim());
83
+ return Number.isFinite(n) ? n : 0;
84
+ }
85
+ catch {
86
+ return 0;
87
+ }
88
+ }
89
+ function recordAttemptFile(configDir, at) {
90
+ const file = attemptStampFile(configDir);
91
+ try {
92
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
93
+ fs.writeFileSync(file, String(at));
94
+ }
95
+ catch {
96
+ /* best-effort: a missing stamp only costs an extra attempt */
97
+ }
98
+ }
99
+ /**
100
+ * `claude doctor` against one profile's config dir. stdout/stderr are dropped —
101
+ * this is a side-effect call, not a readout. Any completed run returns true;
102
+ * whether the token actually got refreshed is decided by re-reading the store.
103
+ */
104
+ function runClaudeDoctor(configDir, linkedBinary) {
105
+ const res = spawnSync(resolveBinary("claude", linkedBinary), ["doctor"], {
106
+ env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
107
+ // Claude Code reads settings from the CWD; run it somewhere neutral so a
108
+ // project's config can never influence a background health check.
109
+ cwd: os.tmpdir(),
110
+ stdio: "ignore",
111
+ timeout: FRESHEN_TIMEOUT_MS,
112
+ });
113
+ if (res.error)
114
+ return false;
115
+ return true;
116
+ }
117
+ export function defaultFreshenDeps(linkedBinary = null) {
118
+ return {
119
+ now: () => Date.now(),
120
+ readCredential: readProfileCredential,
121
+ readLastAttempt: readLastAttemptFile,
122
+ recordAttempt: recordAttemptFile,
123
+ runHealthCheck: (configDir) => runClaudeDoctor(configDir, linkedBinary),
124
+ cooldownMs: FRESHEN_COOLDOWN_MS,
125
+ bufferMs: EXPIRY_BUFFER_MS,
126
+ };
127
+ }
128
+ /**
129
+ * Make sure the store behind `configDir` holds a usable access token, letting
130
+ * Claude Code refresh it when it does not. Idempotent, cooldown-guarded, and a
131
+ * no-op when the token is still good.
132
+ */
133
+ export function freshenToken(configDir, deps = defaultFreshenDeps()) {
134
+ const creds = deps.readCredential(configDir);
135
+ if (!needsFreshen(creds, deps.now(), deps.bufferMs))
136
+ return "not-needed";
137
+ if (deps.now() - deps.readLastAttempt(configDir) < deps.cooldownMs)
138
+ return "cooling-down";
139
+ deps.recordAttempt(configDir, deps.now());
140
+ if (!deps.runHealthCheck(configDir))
141
+ return "unavailable";
142
+ return needsFreshen(deps.readCredential(configDir), deps.now(), deps.bufferMs) ? "still-expired" : "refreshed";
143
+ }
144
+ /**
145
+ * The access token to call the OAuth endpoints with — freshened first when it
146
+ * has expired. Returns whatever the store holds afterwards (null when there is
147
+ * no readable credential at all); a freshen that could not heal the login just
148
+ * means the caller's read fails as it did before.
149
+ */
150
+ export function freshAccessToken(configDir, deps = defaultFreshenDeps()) {
151
+ freshenToken(configDir, deps);
152
+ const creds = deps.readCredential(configDir);
153
+ return creds ? accessTokenOf(creds) : null;
154
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Ecosystem tooling detection — the single source behind `agent-switch tooling`
3
+ * and the agent-config/rtk rows in `agent-switch doctor` (one implementation,
4
+ * two renderers; the GUI consumes `tooling --json` and never shells out).
5
+ *
6
+ * Covers agent-config, rtk, and the provider CLIs (claude, codex, agy).
7
+ * Detection is read-only: it runs local binaries with a short timeout and
8
+ * discards their output after classification; no network calls.
9
+ *
10
+ * rtk is special — upstream documents a hard name collision with an unrelated
11
+ * `rtk` (Rust Type Kit) and names `rtk gain` as the discriminator. Identity is
12
+ * judged on the probe's OUTPUT SIGNATURE, not its exit code (upstream defines
13
+ * no exit-code contract for `rtk gain`): a broken right tool is not the wrong
14
+ * tool, so a timed-out/crashed/ambiguous probe is `unverified`, never
15
+ * `unknown-rtk`.
16
+ */
17
+ import { spawnSync } from "node:child_process";
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { readBinaryPath } from "./profiles.js";
21
+ import { npmSearchPath } from "./updates.js";
22
+ export const TOOL_IDS = ["agent-config", "rtk", "claude", "codex", "agy"];
23
+ /** Which provider a probed binary belongs to (for `providers link` hints and
24
+ * the user-linked path lookup). */
25
+ const BINARY_PROVIDER = {
26
+ claude: "claude",
27
+ codex: "codex",
28
+ agy: "antigravity",
29
+ };
30
+ /** Probe timeouts: `rtk gain` is the identity discriminator and measured at
31
+ * ~666 ms warm (S0.2), so 1500 ms is headroom, not hope; `--version` probes
32
+ * get a little more because a cold Node CLI start can exceed 1.5 s. */
33
+ export const RTK_PROBE_TIMEOUT_MS = 1500;
34
+ const VERSION_PROBE_TIMEOUT_MS = 3000;
35
+ /** spawnSync wrapper. Runs with {@link toolingSearchPath} so probes work when
36
+ * this CLI was spawned from the GUI with a stripped PATH (Finder/menu-bar
37
+ * launches); `shell` on Windows for `.cmd` shims (same as selfUpdate). */
38
+ export function defaultRunner(cmd, args, timeoutMs) {
39
+ const r = spawnSync(cmd, args, {
40
+ encoding: "utf8",
41
+ timeout: timeoutMs,
42
+ env: { ...process.env, PATH: toolingSearchPath() },
43
+ shell: process.platform === "win32",
44
+ windowsHide: true,
45
+ });
46
+ const err = r.error;
47
+ const timedOut = err?.code === "ETIMEDOUT" || r.signal === "SIGTERM";
48
+ return {
49
+ stdout: r.stdout ?? "",
50
+ stderr: r.stderr ?? "",
51
+ status: r.status,
52
+ failedToStart: !!err && !timedOut,
53
+ timedOut,
54
+ };
55
+ }
56
+ // ---------- PATH resolution ---------------------------------------------------
57
+ /** The PATH the tooling sweep searches: {@link npmSearchPath} already covers
58
+ * the stripped-GUI-PATH case (node's own bin dir first, then the inherited
59
+ * PATH, homebrew, /usr/local/bin, ~/.npm-global/bin, and ~/.local/bin — where
60
+ * rtk's upstream install.sh and agy's installer land). */
61
+ export function toolingSearchPath() {
62
+ return npmSearchPath();
63
+ }
64
+ /** Locate a binary on a search PATH without spawning it. Pure fs, injectable
65
+ * platform — on Windows, tries the executable extensions; on POSIX, requires
66
+ * the executable bit so a stray data file named `rtk` doesn't count. */
67
+ export function findOnPath(binary, searchPath = toolingSearchPath(), platform = process.platform) {
68
+ const exts = platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
69
+ for (const dir of searchPath.split(path.delimiter)) {
70
+ if (!dir)
71
+ continue;
72
+ for (const ext of exts) {
73
+ const candidate = path.join(dir, binary + ext);
74
+ try {
75
+ if (!fs.statSync(candidate).isFile())
76
+ continue;
77
+ if (platform !== "win32")
78
+ fs.accessSync(candidate, fs.constants.X_OK);
79
+ return candidate;
80
+ }
81
+ catch {
82
+ /* keep looking */
83
+ }
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ /** First version-looking token in a probe's output (`rtk 1.4.2` → `1.4.2`,
89
+ * `v2.0.1-beta.3` → `2.0.1-beta.3`); null when there is none. */
90
+ export function parseVersionToken(s) {
91
+ return /\bv?(\d+\.\d+\.\d+[-+.\w]*)/.exec(s)?.[1] ?? null;
92
+ }
93
+ // ---------- rtk identity probe ------------------------------------------------
94
+ /** `rtk gain` output header of the real Token Killer (S0.2 measured signature). */
95
+ const RTK_SIGNATURE = /RTK Token Savings|Token Savings/;
96
+ /**
97
+ * The rtk identity probe — THE seam for the planned delegation: when
98
+ * agent-config ships its rtk detection contract (AC-side
99
+ * road-to-rtk-onboarding-correctness Phase 3), delegation replaces this
100
+ * function's body with a mapping over that readout; everything else in this
101
+ * module stays. Today's implementation is the documented fallback probe.
102
+ *
103
+ * Semantics (output signature, never exit code):
104
+ * - stdout carries the Token Savings header → `token-killer` (+ version via
105
+ * `rtk --version`, which is not identity-bearing);
106
+ * - the probe ran and produced other output (unknown-subcommand error etc.)
107
+ * → `unknown-rtk` (the name-collision case);
108
+ * - timeout, crash-on-start, or a silent run → `unverified` (a broken right
109
+ * tool is not the wrong tool).
110
+ */
111
+ export function probeRtkIdentity(rtkCmd, run = defaultRunner) {
112
+ const gain = run(rtkCmd, ["gain"], RTK_PROBE_TIMEOUT_MS);
113
+ if (gain.timedOut || gain.failedToStart)
114
+ return { identity: "unverified", version: null };
115
+ if (RTK_SIGNATURE.test(gain.stdout)) {
116
+ const v = run(rtkCmd, ["--version"], VERSION_PROBE_TIMEOUT_MS);
117
+ return { identity: "token-killer", version: parseVersionToken(v.stdout) };
118
+ }
119
+ if (`${gain.stdout}${gain.stderr}`.trim().length > 0)
120
+ return { identity: "unknown-rtk", version: null };
121
+ return { identity: "unverified", version: null }; // ran silent — ambiguous
122
+ }
123
+ // ---------- hints ---------------------------------------------------------------
124
+ function installHint(id, platform) {
125
+ switch (id) {
126
+ case "agent-config":
127
+ return "not installed — install: `npm install -g @event4u/agent-config`";
128
+ case "rtk":
129
+ if (platform === "darwin")
130
+ return "not installed — install: `brew install rtk`";
131
+ if (platform === "win32")
132
+ return "not installed — install: `winget install rtk-ai.rtk`";
133
+ return "not installed — install: `curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh | sh`";
134
+ default:
135
+ return `not installed — install the ${id} CLI, or link it: \`agent-switch providers link --provider ${BINARY_PROVIDER[id]} --path <path-to-binary>\``;
136
+ }
137
+ }
138
+ function rtkUnhealthyHint(identity, platform) {
139
+ if (identity === "unknown-rtk") {
140
+ // installHint's tail carries the right per-OS install command.
141
+ return `the \`rtk\` on PATH is not Token Killer (name collision) — ${installHint("rtk", platform).replace("not installed — ", "")}`;
142
+ }
143
+ return "could not verify `rtk` (probe timed out or crashed) — run `rtk gain` manually to check";
144
+ }
145
+ /** One status entry for a tool that is not on the search PATH. Absence carries
146
+ * no `identity` — there was no binary to probe, so there is nothing to judge. */
147
+ function absent(id, platform) {
148
+ return { id, present: false, version: null, path: null, healthy: false, hint: installHint(id, platform) };
149
+ }
150
+ /**
151
+ * Detect every requested tool. Absence is encoded as `present: false`;
152
+ * `identity` appears only for rtk; `healthy` for rtk is true only for
153
+ * identity `token-killer`, for everything else when its `--version` probe ran.
154
+ */
155
+ export function checkTooling(opts = {}) {
156
+ const ids = opts.ids ?? TOOL_IDS;
157
+ const run = opts.run ?? defaultRunner;
158
+ const searchPath = opts.searchPath ?? toolingSearchPath();
159
+ const platform = opts.platform ?? process.platform;
160
+ const linked = opts.linked ?? readBinaryPath;
161
+ return ids.map((id) => {
162
+ // Provider binaries honor a user-linked path first (same precedence as
163
+ // resolveBinary: linked > PATH, with ~/.local/bin inside the search PATH).
164
+ const providerId = BINARY_PROVIDER[id];
165
+ const linkedPath = providerId ? linked(providerId) : null;
166
+ const binPath = linkedPath && fs.existsSync(linkedPath) ? linkedPath : findOnPath(id, searchPath, platform);
167
+ if (!binPath)
168
+ return absent(id, platform);
169
+ if (id === "rtk") {
170
+ const { identity, version } = probeRtkIdentity(binPath, run);
171
+ return {
172
+ id,
173
+ present: true,
174
+ version,
175
+ path: binPath,
176
+ healthy: identity === "token-killer",
177
+ identity,
178
+ hint: identity === "token-killer" ? "" : rtkUnhealthyHint(identity, platform),
179
+ };
180
+ }
181
+ const probe = run(binPath, ["--version"], VERSION_PROBE_TIMEOUT_MS);
182
+ const healthy = !probe.failedToStart && !probe.timedOut;
183
+ return {
184
+ id,
185
+ present: true,
186
+ version: healthy ? parseVersionToken(probe.stdout) : null,
187
+ path: binPath,
188
+ healthy,
189
+ hint: healthy ? "" : `\`${id} --version\` ${probe.timedOut ? "timed out" : "failed to run"} — reinstall it (${installHint(id, platform).replace("not installed — ", "")})`,
190
+ };
191
+ });
192
+ }
193
+ /** npm packages behind the npm-installed tools (same commands the hints cite). */
194
+ const NPM_PACKAGES = {
195
+ "agent-config": "@event4u/agent-config",
196
+ claude: "@anthropic-ai/claude-code",
197
+ codex: "@openai/codex",
198
+ };
199
+ /** rtk's upstream installer (linux — used for install AND upgrade there).
200
+ * NEVER `cargo install rtk`: that builds the unrelated Rust Type Kit. */
201
+ const RTK_INSTALL_SH = "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh | sh";
202
+ function npmCommand(pkg, latest) {
203
+ const spec = latest ? `${pkg}@latest` : pkg;
204
+ return { cmd: "npm", args: ["install", "-g", spec], display: `npm install -g ${spec}` };
205
+ }
206
+ /**
207
+ * The exact command for `tooling install|upgrade <id>` — pure, per-platform.
208
+ * A `refusal` result means there is no honest command to run (today: agy —
209
+ * the binary ships with the Antigravity app; this repo documents no standalone
210
+ * installer, and inventing one would be worse than saying so).
211
+ *
212
+ * `claudePath` (resolved linked-or-PATH binary) selects the claude upgrade
213
+ * shape: the CLI's own `claude update` when the binary is present, else the
214
+ * npm `@latest` reinstall.
215
+ */
216
+ export function planToolAction(id, action, opts = {}) {
217
+ const platform = opts.platform ?? process.platform;
218
+ switch (id) {
219
+ case "agent-config":
220
+ return { command: npmCommand(NPM_PACKAGES["agent-config"], action === "upgrade") };
221
+ case "codex":
222
+ return { command: npmCommand(NPM_PACKAGES.codex, action === "upgrade") };
223
+ case "claude":
224
+ if (action === "upgrade" && opts.claudePath) {
225
+ return { command: { cmd: opts.claudePath, args: ["update"], display: "claude update" } };
226
+ }
227
+ return { command: npmCommand(NPM_PACKAGES.claude, action === "upgrade") };
228
+ case "rtk": {
229
+ if (platform === "darwin") {
230
+ const verb = action === "upgrade" ? "upgrade" : "install";
231
+ return { command: { cmd: "brew", args: [verb, "rtk"], display: `brew ${verb} rtk` } };
232
+ }
233
+ if (platform === "win32") {
234
+ return { command: { cmd: "winget", args: [action, "rtk-ai.rtk"], display: `winget ${action} rtk-ai.rtk` } };
235
+ }
236
+ // linux (and anything else POSIX): the upstream installer covers both actions.
237
+ return { command: { cmd: "sh", args: ["-c", RTK_INSTALL_SH], display: RTK_INSTALL_SH } };
238
+ }
239
+ case "agy":
240
+ return {
241
+ refusal: "`agy` has no standalone installer — the Antigravity CLI ships with the Antigravity app.\n" +
242
+ "Install the app, then (if `agy` is not on PATH) link the binary:\n" +
243
+ " agent-switch providers link --provider antigravity --path <path-to-agy>",
244
+ };
245
+ }
246
+ }
247
+ /** The real runner: inherits stdio (the GUI runs this CLI inside its embedded
248
+ * terminal, so the user watches npm/brew output live), searches
249
+ * {@link toolingSearchPath} so a GUI-spawned run finds npm/brew on a stripped
250
+ * PATH, and uses `shell` on Windows for `.cmd` shims (same as selfUpdate). */
251
+ export function defaultInstallRunner(cmd, args) {
252
+ const r = spawnSync(cmd, args, {
253
+ stdio: "inherit",
254
+ env: { ...process.env, PATH: toolingSearchPath() },
255
+ shell: process.platform === "win32",
256
+ });
257
+ if (r.error?.code === "ENOENT") {
258
+ console.error(`❌ \`${cmd}\` not found on PATH — install it first, then retry.`);
259
+ return 1;
260
+ }
261
+ return r.status ?? 1;
262
+ }
263
+ /** The claude binary `tooling upgrade claude` prefers: linked path first (same
264
+ * precedence as resolveBinary), else PATH. Null → npm fallback. */
265
+ function resolveClaudeForUpdate() {
266
+ const linked = readBinaryPath("claude");
267
+ if (linked && fs.existsSync(linked))
268
+ return linked;
269
+ return findOnPath("claude");
270
+ }
271
+ /**
272
+ * `agent-switch tooling install|upgrade <id>` — plan the per-platform command,
273
+ * print it (transparency: the user sees exactly what runs), run it with
274
+ * inherited stdio, and return the child's exit code. A refusal (agy) prints
275
+ * the explanation and returns 1 without running anything.
276
+ */
277
+ export function runToolAction(id, action, opts = {}) {
278
+ const log = opts.log ?? console.log;
279
+ const error = opts.error ?? console.error;
280
+ const claudePath = opts.claudePath !== undefined
281
+ ? opts.claudePath
282
+ : id === "claude" && action === "upgrade"
283
+ ? resolveClaudeForUpdate()
284
+ : null;
285
+ const plan = planToolAction(id, action, { platform: opts.platform, claudePath });
286
+ if ("refusal" in plan) {
287
+ error(plan.refusal);
288
+ return 1;
289
+ }
290
+ log(`→ ${plan.command.display}`);
291
+ const status = (opts.run ?? defaultInstallRunner)(plan.command.cmd, plan.command.args);
292
+ if (status === 0) {
293
+ log(`✅ ${id} ${action === "upgrade" ? "upgraded" : "installed"} — verify with \`agent-switch tooling\`.`);
294
+ }
295
+ else {
296
+ error(`❌ \`${plan.command.display}\` exited with ${status} (its output is above).`);
297
+ }
298
+ return status;
299
+ }
300
+ // ---------- renderers -----------------------------------------------------------
301
+ export function statusGlyph(t) {
302
+ if (t.healthy)
303
+ return "✅";
304
+ return t.present ? "⚠️" : "❌";
305
+ }
306
+ /** Aligned human rows for `agent-switch tooling` (glyph, id, version, detail). */
307
+ export function formatToolingLines(tools) {
308
+ const idW = Math.max(...tools.map((t) => t.id.length));
309
+ const verW = Math.max(...tools.map((t) => (t.version ?? "—").length));
310
+ return tools.map((t) => {
311
+ const detail = t.healthy
312
+ ? `${t.path}${t.identity === "token-killer" ? " (Token Killer verified)" : ""}`
313
+ : t.hint;
314
+ return `${statusGlyph(t)} ${t.id.padEnd(idW)} ${(t.version ?? "—").padEnd(verW)} ${detail}`.trimEnd();
315
+ });
316
+ }
317
+ /** One doctor-style message per tool (doctor prefixes its own OK/WARN mark).
318
+ * Never a hard error — agent-config and rtk are optional ecosystem tools. */
319
+ export function doctorToolingLine(t) {
320
+ if (t.healthy) {
321
+ return `\`${t.id}\`${t.version ? ` ${t.version}` : ""} is installed${t.identity === "token-killer" ? " (Token Killer verified)" : ""}.`;
322
+ }
323
+ return `\`${t.id}\`: ${t.hint}`;
324
+ }
package/dist/usage.js CHANGED
@@ -151,13 +151,14 @@ export function maxUtilization(s) {
151
151
  return vals.length ? Math.max(...vals) : null;
152
152
  }
153
153
  /**
154
- * Opt-in auto-switch decision (pure, no I/O). Given the active profile and the
155
- * same-provider candidates with their usage, return the profile to switch to,
156
- * or null. Switches only when the active profile has hit `threshold`% on some
157
- * window AND another candidate has strictly more headroom (lower max
158
- * utilization) while itself staying below `threshold`. Ties keep the first
159
- * candidate. This is a single switch decision gated behind config — never a
160
- * display ranking.
154
+ * Opt-in switch SUGGESTION (pure, no I/O). Given the active profile and the
155
+ * same-provider candidates with their usage, return the profile to suggest
156
+ * switching to, or null. Suggests only when the active profile has hit
157
+ * `threshold`% on some window AND another candidate has strictly more headroom
158
+ * (lower max utilization) while itself staying below `threshold`. Ties keep the
159
+ * first candidate. This computes the suggestion the daemon notifies with and the
160
+ * user-clicked switch modal pre-selects — it never performs the switch itself,
161
+ * and is never a display ranking.
161
162
  */
162
163
  export function pickSwitchTarget(active, candidates, threshold, isEligible = () => true) {
163
164
  const activeSnap = candidates.find((c) => c.name === active)?.snapshot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-switch",
3
- "version": "1.6.1",
3
+ "version": "2.0.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",