@event4u/agent-switch 2.3.1 → 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";
@@ -89,14 +90,6 @@ export function writeDaemonState(state, file = STATE_FILE) {
89
90
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
90
91
  fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
91
92
  }
92
- /** True when the state's lastPoll is within maxAgeMs of `now` — the CLI/GUI use
93
- * this to decide whether to trust the cache instead of hitting the API. */
94
- export function isFresh(state, maxAgeMs, now = Date.now()) {
95
- if (!state?.lastPoll)
96
- return false;
97
- const t = Date.parse(state.lastPoll);
98
- return !isNaN(t) && now - t <= maxAgeMs;
99
- }
100
93
  // ---------- single instance (pidfile) ----------
101
94
  export function processAlive(pid) {
102
95
  try {
@@ -182,6 +175,54 @@ export function nextIntervalMs(baseMs, consecutiveFailures, capMs = MAX_BACKOFF_
182
175
  return base;
183
176
  return Math.min(base * 2 ** consecutiveFailures, capMs);
184
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
+ }
185
226
  /**
186
227
  * Compliance-locked threshold decision (Council follow-up). Given the active
187
228
  * profile and the same-provider candidates polled this cycle, decide whether to
@@ -307,20 +348,38 @@ export function monitorContext(provider, name, state, log) {
307
348
  * the number of failures this cycle (for backoff). */
308
349
  /** One profile's own usage snapshot: Claude via its OAuth endpoint, Codex via
309
350
  * the live `wham/usage` read. null when unreadable (counts as a poll failure). */
310
- async function snapshotFor(provider, name) {
351
+ async function snapshotFor(provider, name, log) {
311
352
  if (provider === "codex")
312
353
  return readCodexUsage(configDir("codex", name));
313
354
  // An expired access token is the daemon's most common "usage stopped moving"
314
355
  // cause — a profile nobody is running never refreshes itself. Let Claude Code
315
356
  // refresh it first (cooldown-guarded, no-op while the token is still good) —
316
357
  // never for a profile sharing the default install's login.
317
- 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
+ });
318
364
  if (!token)
319
365
  return null;
320
366
  const raw = await fetchUsage(token);
321
367
  return raw ? parseUsage(raw) : null;
322
368
  }
323
- 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) {
324
383
  const names = listProfiles(provider).filter((n) => !isDisabled(provider, n));
325
384
  const active = activeFor(provider);
326
385
  const autoSwitch = readAutoSwitch(provider);
@@ -332,14 +391,19 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
332
391
  const t = Date.parse(state.profiles[`${provider}/${n}`]?.capturedAt ?? "");
333
392
  return isNaN(t) ? null : t;
334
393
  };
335
- 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));
336
395
  let failures = 0;
337
396
  const polled = [];
338
397
  state.notifiedFetchFail = state.notifiedFetchFail ?? {};
339
398
  state.fetchFailingSince = state.fetchFailingSince ?? {};
340
399
  for (const name of targets) {
341
400
  const key = `${provider}/${name}`;
342
- 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);
343
407
  if (!snapshot) {
344
408
  failures++;
345
409
  // Fail-safe: the last-known snapshot in state.profiles[key] is left intact
@@ -388,12 +452,13 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
388
452
  // profile even when the usage API poll failed (expired token, offline).
389
453
  if (active)
390
454
  monitorContext(provider, active, state, log);
455
+ const result = { failures, attempted: targets.length };
391
456
  if (!autoSwitch.enabled || !active)
392
- return failures;
457
+ return result;
393
458
  const activeSnap = polled.find((p) => p.name === active)?.snapshot;
394
459
  const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
395
460
  if (activeMax === null || activeMax < autoSwitch.threshold)
396
- return failures; // still has headroom
461
+ return result; // still has headroom
397
462
  // Codex only (banked resets are a Codex feature): a reset credit is scarce and
398
463
  // redeeming it is the user's call (D3) — the daemon only says one is there,
399
464
  // once per reset cycle, and never calls the redeem endpoint itself.
@@ -429,16 +494,18 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
429
494
  message: `${provider}/${active} hit ≥${autoSwitch.threshold}% — suggested profile: ${provider}/${target}. ${how}`,
430
495
  });
431
496
  }
432
- return failures;
497
+ return result;
433
498
  }
434
- async function pollOnce(state, thresholds, notifiedReset, log) {
435
- let failures = 0;
499
+ async function pollOnce(state, thresholds, notifiedReset, backoff, baseMs, log) {
500
+ const total = { failures: 0, attempted: 0 };
436
501
  for (const provider of AUTO_PROVIDERS) {
437
- 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;
438
505
  }
439
506
  state.lastPoll = new Date().toISOString();
440
- state.lastError = failures > 0 ? `${failures} profile(s) failed to poll` : null;
441
- return failures;
507
+ state.lastError = total.failures > 0 ? `${total.failures} profile(s) failed to poll` : null;
508
+ return total;
442
509
  }
443
510
  /** Run the daemon loop. Single-instance; SIGTERM-clean. */
444
511
  export async function runDaemon(opts = {}) {
@@ -469,13 +536,14 @@ export async function runDaemon(opts = {}) {
469
536
  // `<provider>/<name>` → the window resets_at we last announced a banked reset
470
537
  // for, so the "reset available" notice fires once per cycle, not every poll.
471
538
  const notifiedReset = new Map();
539
+ const backoff = new Map();
472
540
  let consecutiveFailures = 0;
473
541
  let cycles = 0;
474
542
  log(`started (pid ${process.pid}, base interval ${baseInterval}ms).`);
475
543
  while (running) {
476
544
  try {
477
- const failures = await pollOnce(state, thresholds, notifiedReset, log);
478
- consecutiveFailures = failures > 0 ? consecutiveFailures + 1 : 0;
545
+ const { failures, attempted } = await pollOnce(state, thresholds, notifiedReset, backoff, baseInterval, log);
546
+ consecutiveFailures = nextConsecutiveFailures(consecutiveFailures, failures, attempted);
479
547
  }
480
548
  catch (err) {
481
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;
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.1",
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",