@event4u/agent-switch 2.3.0 → 2.3.2

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/daemon.js CHANGED
@@ -24,11 +24,12 @@
24
24
  * freshness, suggestion decision in `usage.ts`) are exported for unit testing;
25
25
  * `runDaemon` is the loop.
26
26
  */
27
+ import * as crypto from "node:crypto";
27
28
  import * as fs from "node:fs";
28
29
  import * as path from "node:path";
29
30
  import { ROOT, activeFor, configDir, isDisabled, labelFor, listProfiles, readAutoSwitch, readBinaryPath, sharesDefaultLogin } from "./profiles.js";
30
31
  import { profileDir } from "./profiles.js";
31
- import { fetchUsage, liveSessionPids } from "./api.js";
32
+ import { fetchUsage, liveSessionPids, readProfileCredential } from "./api.js";
32
33
  import { defaultFreshenDeps, usageToken } from "./token-freshen.js";
33
34
  import { parseUsage, detectCrossings, pickSwitchTarget, maxUtilization } from "./usage.js";
34
35
  import { readCodexUsage } from "./codex-usage.js";
@@ -46,12 +47,29 @@ import { execFileSync } from "node:child_process";
46
47
  * daemon-side OS notifications, fire a desktop notification too — marking the
47
48
  * record so the GUI does not show it a second time. Deduped events (append
48
49
  * returns null) never OS-notify. */
49
- function notify(event) {
50
+ function notify(event, opts = {}) {
50
51
  const created = appendNotification(event);
51
- if (created && readOsNotifications() && osNotify(event.title, event.message)) {
52
+ if (created && opts.os !== false && readOsNotifications() && osNotify(event.title, event.message)) {
52
53
  markOsNotified(created.id);
53
54
  }
54
55
  }
56
+ /** How long reads must fail before it counts as an outage. A single failed read
57
+ * is routine — a rate-limited endpoint answers again a minute later — and the
58
+ * last known values keep being served meanwhile. */
59
+ export const FETCH_OUTAGE_MS = 15 * 60_000;
60
+ /** The notice for a profile whose reads have failed since `sinceMs`, or null
61
+ * while it is still a blip. Kept off the desktop: a fetch failure is a
62
+ * diagnostic for the app's log, not something to interrupt anyone for. */
63
+ export function fetchFailureNotice(key, sinceMs, now) {
64
+ if (now - sinceMs < FETCH_OUTAGE_MS)
65
+ return null;
66
+ return {
67
+ kind: "warning",
68
+ title: "Usage fetch failed",
69
+ message: `Could not fetch usage limits for ${key} for ${Math.round((now - sinceMs) / 60_000)} min. Keeping the last known values.`,
70
+ os: false,
71
+ };
72
+ }
55
73
  // Providers the daemon may suggest a switch for on threshold (they expose a
56
74
  // usage readout to base a headroom suggestion on). Antigravity has none.
57
75
  const AUTO_PROVIDERS = ["claude", "codex"];
@@ -72,14 +90,6 @@ export function writeDaemonState(state, file = STATE_FILE) {
72
90
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
73
91
  fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
74
92
  }
75
- /** True when the state's lastPoll is within maxAgeMs of `now` — the CLI/GUI use
76
- * this to decide whether to trust the cache instead of hitting the API. */
77
- export function isFresh(state, maxAgeMs, now = Date.now()) {
78
- if (!state?.lastPoll)
79
- return false;
80
- const t = Date.parse(state.lastPoll);
81
- return !isNaN(t) && now - t <= maxAgeMs;
82
- }
83
93
  // ---------- single instance (pidfile) ----------
84
94
  export function processAlive(pid) {
85
95
  try {
@@ -165,6 +175,54 @@ export function nextIntervalMs(baseMs, consecutiveFailures, capMs = MAX_BACKOFF_
165
175
  return base;
166
176
  return Math.min(base * 2 ** consecutiveFailures, capMs);
167
177
  }
178
+ /** The profile's backoff after a read — undefined once a read succeeds. */
179
+ export function nextProfileBackoff(prev, ok, baseMs, now, stamp) {
180
+ if (ok)
181
+ return undefined;
182
+ const failures = (prev?.failures ?? 0) + 1;
183
+ return { failures, nextAt: now + nextIntervalMs(baseMs, failures), ...(stamp !== undefined ? { stamp } : {}) };
184
+ }
185
+ export function backoffBlocks(b, now, stamp) {
186
+ if (b === undefined)
187
+ return false;
188
+ if (b.stamp !== undefined && stamp !== b.stamp)
189
+ return false;
190
+ return now < b.nextAt;
191
+ }
192
+ /** A fingerprint of a stored login, so a change can be noticed without the
193
+ * service holding on to the credential. */
194
+ export function loginStamp(raw) {
195
+ return raw === null ? null : crypto.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 16);
196
+ }
197
+ /** The targets a cycle reads: those not held back by their own backoff. The
198
+ * login is read only for a profile that has a backoff entry. */
199
+ export function dueTargets(provider, targets, backoff, now, stampOf) {
200
+ return targets.filter((n) => {
201
+ const b = backoff.get(`${provider}/${n}`);
202
+ return !b || !backoffBlocks(b, now, b.stamp !== undefined ? stampOf(n) : undefined);
203
+ });
204
+ }
205
+ /** The loop-wide failure count after a cycle: it grows only when every read
206
+ * failed (offline, API down) — single failures are handled by
207
+ * {@link nextProfileBackoff}. A cycle that read nothing leaves it unchanged. */
208
+ export function nextConsecutiveFailures(prev, failures, attempted) {
209
+ if (attempted === 0)
210
+ return prev;
211
+ return failures >= attempted ? prev + 1 : 0;
212
+ }
213
+ /** How old a cached snapshot may be and still be served instead of a live read:
214
+ * the daemon's slowest cadence (an idle account, or its current interval when
215
+ * that is longer) plus one loop wake. A healthy daemon answers from cache; a
216
+ * profile whose reads keep failing ages out and is read live. */
217
+ export function cacheMaxAgeMs(pollIntervalMs) {
218
+ return Math.max(POOL_POLL_INTERVAL_MS, pollIntervalMs ?? 0) + MIN_INTERVAL_MS;
219
+ }
220
+ export function snapshotFresh(snap, now = Date.now(), maxAgeMs = cacheMaxAgeMs(undefined)) {
221
+ if (!snap)
222
+ return false;
223
+ const t = Date.parse(snap.capturedAt);
224
+ return !isNaN(t) && now - t <= maxAgeMs;
225
+ }
168
226
  /**
169
227
  * Compliance-locked threshold decision (Council follow-up). Given the active
170
228
  * profile and the same-provider candidates polled this cycle, decide whether to
@@ -290,20 +348,38 @@ export function monitorContext(provider, name, state, log) {
290
348
  * the number of failures this cycle (for backoff). */
291
349
  /** One profile's own usage snapshot: Claude via its OAuth endpoint, Codex via
292
350
  * the live `wham/usage` read. null when unreadable (counts as a poll failure). */
293
- async function snapshotFor(provider, name) {
351
+ async function snapshotFor(provider, name, log) {
294
352
  if (provider === "codex")
295
353
  return readCodexUsage(configDir("codex", name));
296
354
  // An expired access token is the daemon's most common "usage stopped moving"
297
355
  // cause — a profile nobody is running never refreshes itself. Let Claude Code
298
356
  // refresh it first (cooldown-guarded, no-op while the token is still good) —
299
357
  // never for a profile sharing the default install's login.
300
- const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name));
358
+ const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name), (outcome) => {
359
+ if (outcome === "unavailable")
360
+ log(`freshen: claude/${name} — could not start \`claude doctor\` (not found on this service's PATH; link it in the app)`);
361
+ else if (outcome === "still-expired")
362
+ log(`freshen: claude/${name} — Claude Code could not refresh the login (sign in again)`);
363
+ });
301
364
  if (!token)
302
365
  return null;
303
366
  const raw = await fetchUsage(token);
304
367
  return raw ? parseUsage(raw) : null;
305
368
  }
306
- async function pollProvider(provider, state, thresholds, notifiedReset, log) {
369
+ /** The stored login behind a profile, fingerprinted: Claude's credential
370
+ * store, Codex's auth.json. Read after a failure, so a refresh the failed
371
+ * read already triggered counts as the baseline, not as a change. */
372
+ function loginStampFor(provider, name) {
373
+ if (provider === "claude")
374
+ return loginStamp(readProfileCredential(configDir("claude", name)));
375
+ try {
376
+ return loginStamp(fs.readFileSync(path.join(configDir(provider, name), "auth.json"), "utf8"));
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ }
382
+ async function pollProvider(provider, state, thresholds, notifiedReset, backoff, baseMs, log) {
307
383
  const names = listProfiles(provider).filter((n) => !isDisabled(provider, n));
308
384
  const active = activeFor(provider);
309
385
  const autoSwitch = readAutoSwitch(provider);
@@ -315,28 +391,32 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
315
391
  const t = Date.parse(state.profiles[`${provider}/${n}`]?.capturedAt ?? "");
316
392
  return isNaN(t) ? null : t;
317
393
  };
318
- const targets = selectPollTargets(names, active, isLive, autoSwitch.enabled, pooled, lastCaptured);
394
+ const targets = dueTargets(provider, selectPollTargets(names, active, isLive, autoSwitch.enabled, pooled, lastCaptured), backoff, Date.now(), (n) => loginStampFor(provider, n));
319
395
  let failures = 0;
320
396
  const polled = [];
321
397
  state.notifiedFetchFail = state.notifiedFetchFail ?? {};
398
+ state.fetchFailingSince = state.fetchFailingSince ?? {};
322
399
  for (const name of targets) {
323
400
  const key = `${provider}/${name}`;
324
- const snapshot = await snapshotFor(provider, name);
401
+ const snapshot = await snapshotFor(provider, name, log);
402
+ const nextBackoff = snapshot ? undefined : nextProfileBackoff(backoff.get(key), false, baseMs, Date.now(), loginStampFor(provider, name));
403
+ if (nextBackoff)
404
+ backoff.set(key, nextBackoff);
405
+ else
406
+ backoff.delete(key);
325
407
  if (!snapshot) {
326
408
  failures++;
327
409
  // Fail-safe: the last-known snapshot in state.profiles[key] is left intact
328
- // (never overwritten with null), and we warn ONCE per outage rather than
329
- // every cycle — the loop keeps serving the last good numbers meanwhile.
330
- if (!state.notifiedFetchFail[key]) {
410
+ // (never overwritten with null). Only an outage is reported, once.
411
+ const since = state.fetchFailingSince[key] ?? (state.fetchFailingSince[key] = new Date().toISOString());
412
+ const notice = state.notifiedFetchFail[key] ? null : fetchFailureNotice(key, Date.parse(since), Date.now());
413
+ if (notice) {
331
414
  state.notifiedFetchFail[key] = true;
332
- notify({
333
- kind: "warning",
334
- title: "Usage fetch failed",
335
- message: `Could not fetch usage limits for ${provider}/${name}. Keeping the last known values.`,
336
- });
415
+ notify(notice, { os: notice.os });
337
416
  }
338
417
  continue;
339
418
  }
419
+ delete state.fetchFailingSince[key];
340
420
  if (state.notifiedFetchFail[key]) {
341
421
  delete state.notifiedFetchFail[key];
342
422
  log(`recovered: ${provider}/${name} usage fetch succeeded again`);
@@ -372,12 +452,13 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
372
452
  // profile even when the usage API poll failed (expired token, offline).
373
453
  if (active)
374
454
  monitorContext(provider, active, state, log);
455
+ const result = { failures, attempted: targets.length };
375
456
  if (!autoSwitch.enabled || !active)
376
- return failures;
457
+ return result;
377
458
  const activeSnap = polled.find((p) => p.name === active)?.snapshot;
378
459
  const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
379
460
  if (activeMax === null || activeMax < autoSwitch.threshold)
380
- return failures; // still has headroom
461
+ return result; // still has headroom
381
462
  // Codex only (banked resets are a Codex feature): a reset credit is scarce and
382
463
  // redeeming it is the user's call (D3) — the daemon only says one is there,
383
464
  // once per reset cycle, and never calls the redeem endpoint itself.
@@ -413,16 +494,18 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
413
494
  message: `${provider}/${active} hit ≥${autoSwitch.threshold}% — suggested profile: ${provider}/${target}. ${how}`,
414
495
  });
415
496
  }
416
- return failures;
497
+ return result;
417
498
  }
418
- async function pollOnce(state, thresholds, notifiedReset, log) {
419
- let failures = 0;
499
+ async function pollOnce(state, thresholds, notifiedReset, backoff, baseMs, log) {
500
+ const total = { failures: 0, attempted: 0 };
420
501
  for (const provider of AUTO_PROVIDERS) {
421
- failures += await pollProvider(provider, state, thresholds, notifiedReset, log);
502
+ const r = await pollProvider(provider, state, thresholds, notifiedReset, backoff, baseMs, log);
503
+ total.failures += r.failures;
504
+ total.attempted += r.attempted;
422
505
  }
423
506
  state.lastPoll = new Date().toISOString();
424
- state.lastError = failures > 0 ? `${failures} profile(s) failed to poll` : null;
425
- return failures;
507
+ state.lastError = total.failures > 0 ? `${total.failures} profile(s) failed to poll` : null;
508
+ return total;
426
509
  }
427
510
  /** Run the daemon loop. Single-instance; SIGTERM-clean. */
428
511
  export async function runDaemon(opts = {}) {
@@ -453,13 +536,14 @@ export async function runDaemon(opts = {}) {
453
536
  // `<provider>/<name>` → the window resets_at we last announced a banked reset
454
537
  // for, so the "reset available" notice fires once per cycle, not every poll.
455
538
  const notifiedReset = new Map();
539
+ const backoff = new Map();
456
540
  let consecutiveFailures = 0;
457
541
  let cycles = 0;
458
542
  log(`started (pid ${process.pid}, base interval ${baseInterval}ms).`);
459
543
  while (running) {
460
544
  try {
461
- const failures = await pollOnce(state, thresholds, notifiedReset, log);
462
- consecutiveFailures = failures > 0 ? consecutiveFailures + 1 : 0;
545
+ const { failures, attempted } = await pollOnce(state, thresholds, notifiedReset, backoff, baseInterval, log);
546
+ consecutiveFailures = nextConsecutiveFailures(consecutiveFailures, failures, attempted);
463
547
  }
464
548
  catch (err) {
465
549
  consecutiveFailures++;
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ import { formatSnapshot, parseUsage } from "./usage.js";
47
47
  import { readCodexUsage } from "./codex-usage.js";
48
48
  import { redeemResetCredit } from "./codex-reset.js";
49
49
  import { assertValidSessionId, cleanupForkVehicle, codexSessionCommand, deleteSession, detectRunningClaudeProfile, listSessions, listCodexSessions, locateSession, locateCodexSession, markLive, openQuestion, restoreSession, sharedHistory, sweepTrash, transferSession, transferCodexSession, trashedSessionExists, } from "./sessions.js";
50
- import { isFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
50
+ import { cacheMaxAgeMs, snapshotFresh, readDaemonState, readPid, PIDFILE, processAlive } from "./daemon.js";
51
51
  import { cmdService, serviceAfterUpdate, serviceInstall, serviceInstallFromDisk, serviceTarget, serviceUninstall } from "./service.js";
52
52
  import { fileURLToPath } from "node:url";
53
53
  import { readContext, turnInFlight } from "./telemetry.js";
@@ -456,14 +456,13 @@ function cmdWhoami(providerId, name) {
456
456
  * credential is unreadable or the API shape is unknown. Codex/Antigravity have no
457
457
  * usage readout (verified), so this is Claude-only. */
458
458
  async function claudeSnapshot(name) {
459
- // Prefer the daemon's cache when it's fresh (< its own poll interval) — the
460
- // daemon is a cache, and the CLI works with or without it.
459
+ // Prefer the daemon's cache while THIS profile's snapshot is fresh — the
460
+ // daemon is a cache, and the CLI works with or without it. The age of the
461
+ // last poll says nothing about a profile whose reads have been failing.
461
462
  const state = readDaemonState();
462
- if (state && isFresh(state, state.pollIntervalMs)) {
463
- const cached = state.profiles[`claude/${name}`];
464
- if (cached)
465
- return cached;
466
- }
463
+ const cached = state?.profiles?.[`claude/${name}`];
464
+ if (snapshotFresh(cached, Date.now(), cacheMaxAgeMs(state?.pollIntervalMs)))
465
+ return cached;
467
466
  const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name));
468
467
  if (!token)
469
468
  return null;
@@ -2058,8 +2057,11 @@ function cmdOsNotify(sub, json = false) {
2058
2057
  const enabled = readOsNotifications();
2059
2058
  if (json)
2060
2059
  console.log(JSON.stringify({ enabled }));
2061
- else
2060
+ else {
2062
2061
  console.log(`Daemon OS notifications ${enabled ? "ON" : "OFF"}.`);
2062
+ if (enabled && process.platform === "darwin")
2063
+ console.log("On macOS the daemon shows none itself — the Agent Switch app shows them while it runs.");
2064
+ }
2063
2065
  }
2064
2066
  /** `agent-switch notifications [clear] [--json]` — list the recent
2065
2067
  * notifications (newest first) or clear the log. */
@@ -2521,7 +2523,7 @@ Run with no command to launch the tray/menubar GUI (single-instance).
2521
2523
  agent-switch service run|start|stop|status|install|uninstall background usage daemon
2522
2524
  agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
2523
2525
  agent-switch notify --kind K --title T --message M [--json] record a notification event
2524
- agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications (default off)
2526
+ agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications, Linux/Windows (default off; macOS: the app shows them)
2525
2527
  agent-switch uninstall [--force] remove all agent-switch data + daemon
2526
2528
  agent-switch usage history [--provider P] [--profile <name>] [--json] daemon-recorded usage samples per profile
2527
2529
  agent-switch tooling [--json] ecosystem tool readout (agent-config, rtk, provider CLIs)
package/dist/os-notify.js CHANGED
@@ -2,7 +2,9 @@
2
2
  * Best-effort OS desktop notifications for the background daemon, so an
3
3
  * auto-switch is visible even when the GUI is closed. Per-platform native
4
4
  * command, no external dependency:
5
- * - macOS → `osascript -e 'display notification …'`
5
+ * - macOS → nothing: an `osascript` notification is attributed to Script
6
+ * Editor. The Agent Switch app shows the notice itself when it
7
+ * runs (records not marked OS-notified), otherwise no one does.
6
8
  * - Linux → `notify-send <title> <body>`
7
9
  * - Windows → PowerShell balloon tip via System.Windows.Forms.NotifyIcon
8
10
  *
@@ -10,10 +12,6 @@
10
12
  * testable; `osNotify` wraps it with a fire-and-forget spawn that never throws.
11
13
  */
12
14
  import { spawnSync } from "node:child_process";
13
- /** AppleScript string literal escaping: backslash and double-quote only. */
14
- function escAppleScript(s) {
15
- return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
16
- }
17
15
  /** PowerShell single-quoted string literal escaping: double the single quote. */
18
16
  function escPowerShellSingle(s) {
19
17
  return s.replace(/'/g, "''");
@@ -23,10 +21,6 @@ function escPowerShellSingle(s) {
23
21
  * `platform` matches `process.platform` values (`darwin` / `linux` / `win32`).
24
22
  */
25
23
  export function buildOsNotifyCommand(platform, title, body) {
26
- if (platform === "darwin") {
27
- const script = `display notification "${escAppleScript(body)}" with title "${escAppleScript(title)}"`;
28
- return { program: "osascript", args: ["-e", script] };
29
- }
30
24
  if (platform === "linux") {
31
25
  // notify-send treats the first arg as summary, the second as body.
32
26
  return { program: "notify-send", args: [title, body] };
package/dist/providers.js CHANGED
@@ -139,18 +139,30 @@ export function provider(id) {
139
139
  export function allProviders() {
140
140
  return PROVIDER_IDS.map((id) => PROVIDERS[id]);
141
141
  }
142
- /** Where an installer may drop a binary off the default PATH. Some CLIs (e.g.
143
- * agy, installed via `curl … | bash`) land in ~/.local/bin, which a GUI app's
144
- * minimal PATH frequently omits. */
145
- function localBinary(binary) {
146
- return path.join(os.homedir(), ".local", "bin", binary);
142
+ /**
143
+ * Where an installer may drop a binary off the minimal PATH a GUI app (and the
144
+ * service it starts) runs with on macOS — `/usr/bin:/bin:/usr/sbin:/sbin`.
145
+ * ~/.local/bin (e.g. agy via `curl … | bash`) first, then Homebrew on Apple
146
+ * silicon and Intel. The first existing path, or null.
147
+ */
148
+ export function offPathBinary(binary, home = os.homedir(), exists = fs.existsSync, platform = process.platform) {
149
+ const p = platform === "win32" ? path.win32 : path.posix;
150
+ const dirs = [p.join(home, ".local", "bin")];
151
+ if (platform !== "win32")
152
+ dirs.push("/opt/homebrew/bin", "/usr/local/bin");
153
+ for (const dir of dirs) {
154
+ const file = p.join(dir, binary);
155
+ if (exists(file))
156
+ return file;
157
+ }
158
+ return null;
147
159
  }
148
160
  /**
149
161
  * Resolve a CLI binary to an invocable command: a user-linked path when set and
150
162
  * present (the CLI is not on PATH but the user pointed us at it), otherwise its
151
- * bare name when it is on PATH, otherwise its ~/.local/bin path when the binary
152
- * lives there (GUI apps often run with a minimal PATH that omits it), otherwise
153
- * the bare name so the caller surfaces a clean "not found" error.
163
+ * bare name when it is on PATH, otherwise its path in one of the
164
+ * {@link offPathBinary} dirs, otherwise the bare name so the caller surfaces a
165
+ * clean "not found" error.
154
166
  */
155
167
  export function resolveBinary(binary, linked) {
156
168
  if (linked && fs.existsSync(linked))
@@ -158,14 +170,13 @@ export function resolveBinary(binary, linked) {
158
170
  const probe = spawnSync(binary, ["--version"], { stdio: "ignore", timeout: 5000 });
159
171
  if (probe.error?.code !== "ENOENT")
160
172
  return binary;
161
- const local = localBinary(binary);
162
- return fs.existsSync(local) ? local : binary;
173
+ return offPathBinary(binary) ?? binary;
163
174
  }
164
175
  /**
165
176
  * Best-effort: is this provider's CLI binary usable? True when a user-linked
166
177
  * path is set and present, else when `<binary> --version` resolves on PATH
167
178
  * (only ENOENT counts as not-installed — a non-zero exit or a timeout still
168
- * means the binary exists), else when it lives in ~/.local/bin. Used to gate
179
+ * means the binary exists), else when it lives in an {@link offPathBinary} dir. Used to gate
169
180
  * enabling a provider the user hasn't installed (or hasn't linked).
170
181
  */
171
182
  export function isProviderInstalled(id, linked) {
@@ -175,5 +186,5 @@ export function isProviderInstalled(id, linked) {
175
186
  const probe = spawnSync(bin, ["--version"], { stdio: "ignore", timeout: 5000 });
176
187
  if (probe.error?.code !== "ENOENT")
177
188
  return true;
178
- return fs.existsSync(localBinary(bin));
189
+ return offPathBinary(bin) !== null;
179
190
  }
package/dist/telemetry.js CHANGED
@@ -43,6 +43,10 @@ export const SUPPORTED_CLAUDE = ["2.1"];
43
43
  * tokens, never guess a percentage). User overrides via state.json merge on
44
44
  * top. Codex needs no table — its rollout carries model_context_window. */
45
45
  export const CONTEXT_WINDOWS = {
46
+ "claude-fable-5-1": 1_000_000,
47
+ "claude-opus-5-5": 1_000_000,
48
+ "claude-sonnet-5-5": 1_000_000,
49
+ "claude-haiku-5-5": 200_000,
46
50
  "claude-fable-5": 1_000_000,
47
51
  "claude-opus-4-8": 1_000_000,
48
52
  "claude-opus-4-7": 1_000_000,
@@ -96,14 +96,24 @@ function recordAttemptFile(configDir, at) {
96
96
  /* best-effort: a missing stamp only costs an extra attempt */
97
97
  }
98
98
  }
99
+ /**
100
+ * The PATH `claude doctor` runs with: the resolved binary's dir and this node's
101
+ * dir ahead of the inherited PATH. An npm-installed `claude` is a
102
+ * `#!/usr/bin/env node` script, and the service's minimal PATH has no node.
103
+ */
104
+ export function doctorPath(bin, execPath, inherited) {
105
+ const dirs = [path.isAbsolute(bin) ? path.dirname(bin) : null, path.dirname(execPath), ...(inherited ?? "").split(path.delimiter)];
106
+ return [...new Set(dirs.filter((d) => !!d))].join(path.delimiter);
107
+ }
99
108
  /**
100
109
  * `claude doctor` against one profile's config dir. stdout/stderr are dropped —
101
110
  * this is a side-effect call, not a readout. Any completed run returns true;
102
111
  * whether the token actually got refreshed is decided by re-reading the store.
103
112
  */
104
113
  function runClaudeDoctor(configDir, linkedBinary) {
105
- const res = spawnSync(resolveBinary("claude", linkedBinary), ["doctor"], {
106
- env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
114
+ const bin = resolveBinary("claude", linkedBinary);
115
+ const res = spawnSync(bin, ["doctor"], {
116
+ env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, PATH: doctorPath(bin, process.execPath, process.env.PATH) },
107
117
  // Claude Code reads settings from the CWD; run it somewhere neutral so a
108
118
  // project's config can never influence a background health check.
109
119
  cwd: os.tmpdir(),
@@ -147,8 +157,9 @@ export function freshenToken(configDir, deps = defaultFreshenDeps()) {
147
157
  * no readable credential at all); a freshen that could not heal the login just
148
158
  * means the caller's read fails as it did before.
149
159
  */
150
- export function freshAccessToken(configDir, deps = defaultFreshenDeps()) {
151
- freshenToken(configDir, deps);
160
+ export function freshAccessToken(configDir, deps = defaultFreshenDeps(), onOutcome) {
161
+ const outcome = freshenToken(configDir, deps);
162
+ onOutcome?.(outcome);
152
163
  const creds = deps.readCredential(configDir);
153
164
  return creds ? accessTokenOf(creds) : null;
154
165
  }
@@ -157,9 +168,9 @@ export function freshAccessToken(configDir, deps = defaultFreshenDeps()) {
157
168
  * install (seeded by `import`) is never refreshed: whichever side refreshes
158
169
  * first invalidates the other, so its stored token is used only while valid.
159
170
  */
160
- export function usageToken(configDir, deps, sharesDefaultLogin) {
171
+ export function usageToken(configDir, deps, sharesDefaultLogin, onOutcome) {
161
172
  if (!sharesDefaultLogin)
162
- return freshAccessToken(configDir, deps);
173
+ return freshAccessToken(configDir, deps, onOutcome);
163
174
  const creds = deps.readCredential(configDir);
164
175
  return creds && !needsFreshen(creds, deps.now(), deps.bufferMs) ? accessTokenOf(creds) : null;
165
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-switch",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
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",