@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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Which account a start should use when several could: the one that would
3
+ * lose the most of its rest at its next reset. An account's "need" is the rest
4
+ * of its longest window divided by the hours until that window resets — the
5
+ * pace it would have to be used at to run dry exactly at the reset. Using the
6
+ * highest need first spreads the work so every account is spent by its reset
7
+ * instead of one running out while another resets unused.
8
+ *
9
+ * An account at its limit in any window (at or over its protection threshold)
10
+ * can't take work now and is never picked. Accounts whose needs are level
11
+ * (within {@link LEVEL_TOLERANCE}) are a tie the caller breaks — plain starts
12
+ * prefer the plain-start account, pools a member without a live session.
13
+ */
14
+ const HOUR_MS = 3_600_000;
15
+ /** A window without a reset time is assumed to reset a full week out. */
16
+ const UNKNOWN_RESET_H = 7 * 24;
17
+ /** Needs closer than this (relative) count as level. */
18
+ export const LEVEL_TOLERANCE = 0.05;
19
+ function longest(windows) {
20
+ const known = windows.filter((w) => typeof w.utilization === "number");
21
+ if (!known.length)
22
+ return null;
23
+ const withReset = known.filter((w) => w.resetsAt && !isNaN(Date.parse(w.resetsAt)));
24
+ if (withReset.length)
25
+ return withReset.reduce((a, b) => (Date.parse(b.resetsAt) > Date.parse(a.resetsAt) ? b : a));
26
+ return known.reduce((a, b) => (b.utilization > a.utilization ? b : a));
27
+ }
28
+ export function weigh(snapshot, now, thresholdPct) {
29
+ const capped = snapshot.windows.find((w) => typeof w.utilization === "number" && w.utilization >= thresholdPct);
30
+ if (capped)
31
+ return { usable: false, why: `at ${capped.utilization}% of its ${windowName(capped)} window (limit ${thresholdPct}%)` };
32
+ const w = longest(snapshot.windows);
33
+ if (!w)
34
+ return { usable: false, why: "usage unknown" };
35
+ const rest = Math.max(0, 100 - w.utilization);
36
+ const resetH = w.resetsAt && !isNaN(Date.parse(w.resetsAt)) ? Math.max(1, (Date.parse(w.resetsAt) - now) / HOUR_MS) : null;
37
+ return { usable: true, need: rest / (resetH ?? UNKNOWN_RESET_H), rest, resetH, window: windowName(w), key: w.key };
38
+ }
39
+ const WINDOW_NAME = { five_hour: "5h", seven_day: "7d", monthly: "30d" };
40
+ /** Named by key: a window read back from the usage history carries its key as label. */
41
+ function windowName(w) {
42
+ return WINDOW_NAME[w.key] ?? w.label;
43
+ }
44
+ export function level(a, b) {
45
+ const hi = Math.max(a, b);
46
+ return hi === 0 || Math.abs(a - b) / hi <= LEVEL_TOLERANCE;
47
+ }
48
+ export function describe(name, w) {
49
+ const reset = w.resetH == null ? "reset time unknown" : w.resetH >= 48 ? `resets in ${Math.round(w.resetH / 24)} d` : `resets in ${Math.round(w.resetH)} h`;
50
+ const of = w.key === "seven_day" ? "the week" : `its ${w.window} window`;
51
+ return `${name}: ${Math.round(w.rest)}% of ${of} left, ${reset} (${w.need.toFixed(1)} %/h to use it up)`;
52
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Which browser a provider's sign-in opens in. Claude Code opens its OAuth page
3
+ * with `$BROWSER` when set (verified against CC 2.1.312: it spawns the value as
4
+ * ONE executable with the URL as its only argument — `open -a X` in the
5
+ * variable is not run). So on macOS the choice becomes a tiny wrapper script
6
+ * that hands the URL to that app; on Linux the browser binary itself is the
7
+ * executable. Windows is not offered: nothing here has verified an override.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ const MAC = [
12
+ { id: "safari", name: "Safari", app: "Safari" },
13
+ { id: "chrome", name: "Google Chrome", app: "Google Chrome" },
14
+ { id: "chrome-canary", name: "Google Chrome Canary", app: "Google Chrome Canary" },
15
+ { id: "firefox", name: "Firefox", app: "Firefox" },
16
+ { id: "firefox-dev", name: "Firefox Developer Edition", app: "Firefox Developer Edition" },
17
+ { id: "arc", name: "Arc", app: "Arc" },
18
+ { id: "brave", name: "Brave", app: "Brave Browser" },
19
+ { id: "edge", name: "Microsoft Edge", app: "Microsoft Edge" },
20
+ { id: "vivaldi", name: "Vivaldi", app: "Vivaldi" },
21
+ { id: "opera", name: "Opera", app: "Opera" },
22
+ { id: "chromium", name: "Chromium", app: "Chromium" },
23
+ { id: "zen", name: "Zen", app: "Zen" },
24
+ { id: "orion", name: "Orion", app: "Orion" },
25
+ { id: "dia", name: "Dia", app: "Dia" },
26
+ { id: "librewolf", name: "LibreWolf", app: "LibreWolf" },
27
+ ];
28
+ const LINUX = [
29
+ { id: "chrome", name: "Google Chrome", bins: ["google-chrome", "google-chrome-stable"] },
30
+ { id: "chromium", name: "Chromium", bins: ["chromium", "chromium-browser"] },
31
+ { id: "firefox", name: "Firefox", bins: ["firefox"] },
32
+ { id: "brave", name: "Brave", bins: ["brave-browser", "brave"] },
33
+ { id: "edge", name: "Microsoft Edge", bins: ["microsoft-edge", "microsoft-edge-stable"] },
34
+ { id: "vivaldi", name: "Vivaldi", bins: ["vivaldi", "vivaldi-stable"] },
35
+ { id: "librewolf", name: "LibreWolf", bins: ["librewolf"] },
36
+ ];
37
+ /** The .app bundle (macOS) or binary (Linux) for a browser id; null when it is
38
+ * unknown or not installed. */
39
+ export function locateBrowser(id, deps) {
40
+ if (deps.platform === "darwin") {
41
+ const b = MAC.find((x) => x.id === id);
42
+ if (!b)
43
+ return null;
44
+ // macOS paths are POSIX whatever platform runs this (tests run on Windows too).
45
+ for (const dir of ["/Applications", path.posix.join(deps.home, "Applications")]) {
46
+ const app = path.posix.join(dir, `${b.app}.app`);
47
+ if (deps.exists(app))
48
+ return app;
49
+ }
50
+ return null;
51
+ }
52
+ if (deps.platform === "linux") {
53
+ const b = LINUX.find((x) => x.id === id);
54
+ for (const bin of b?.bins ?? []) {
55
+ const p = deps.which(bin);
56
+ if (p)
57
+ return p;
58
+ }
59
+ return null;
60
+ }
61
+ return null;
62
+ }
63
+ export function listBrowsers(deps) {
64
+ const known = deps.platform === "darwin" ? MAC : deps.platform === "linux" ? LINUX : [];
65
+ return known.filter((b) => locateBrowser(b.id, deps) !== null).map(({ id, name }) => ({ id, name }));
66
+ }
67
+ /** POSIX wrapper for `$BROWSER` that opens its URL argument in one macOS app. */
68
+ export function browserWrapperScript(appPath) {
69
+ const quoted = `'${appPath.replace(/'/g, `'\\''`)}'`;
70
+ return `#!/bin/sh\nexec /usr/bin/open -a ${quoted} "$@"\n`;
71
+ }
72
+ function whichOnPath(binary) {
73
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
74
+ if (!dir)
75
+ continue;
76
+ const p = path.join(dir, binary);
77
+ try {
78
+ fs.accessSync(p, fs.constants.X_OK);
79
+ if (fs.statSync(p).isFile())
80
+ return p;
81
+ }
82
+ catch {
83
+ /* not here */
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ export function defaultBrowserDeps(home) {
89
+ return { platform: process.platform, home, exists: (p) => fs.existsSync(p), which: whichOnPath };
90
+ }
@@ -36,9 +36,19 @@ const DENY_SET = new Set([".claude.json", ".credentials.json", "plugins", "sessi
36
36
  export const MAX_ENTRIES = 5000;
37
37
  /** Bound untrusted input: refuse a single decoded file larger than this. */
38
38
  export const MAX_FILE_BYTES = 25 * 1024 * 1024;
39
- export const FULL_REFUSAL = "--full (bundling live OAuth tokens) is intentionally not implemented — it is an account-takeover vector; export is config-only by design.";
39
+ export const FULL_REFUSAL = "--full (bundling live OAuth tokens) is intentionally not implemented — it is an account-takeover vector; export is config-only by design. " +
40
+ "To move an account to another machine: `agent-switch config export` (config-only) → `config import` on the target → `agent-switch <profile>` and re-run `claude` to log in there — a fresh login is machine-specific by design.";
40
41
  /** Refuse the `--full` (credential-bundling) variant. Throws so callers can
41
- * surface it via `die`; kept here because it is a config-transfer policy. */
42
+ * surface it via `die`; kept here because it is a config-transfer policy.
43
+ *
44
+ * Re-affirmed 2026-07-29: an AI-council threat-modelled review of whether to
45
+ * build `--full` (with mandatory encryption) concluded KEEP IT REFUSED — even
46
+ * AES-256-GCM + Argon2id cannot fix the threat-model shift (an exportable
47
+ * credential bundle is a persistent cloud-sync / malware / phishing exfil
48
+ * vector), the 30-second `config import → auth login` path covers the real
49
+ * need, and industry practice (aws / gh / kubectl) never exports credentials.
50
+ * Reversing this requires the repo owner's explicit sign-off, not agent
51
+ * convergence. */
42
52
  export function assertNotFull(full) {
43
53
  if (full)
44
54
  throw new Error(FULL_REFUSAL);
package/dist/daemon.js CHANGED
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Background usage daemon — poller, cache, and (opt-in) threshold notifier.
3
3
  *
4
- * It polls Claude profiles for their OWN usage, appends history, detects
4
+ * It polls Claude and Codex profiles for their OWN usage — the active one, any
5
+ * with a live session, and the members of pools a folder rule targets (spaced
6
+ * to {@link POOL_POLL_INTERVAL_MS}, so `dir`'s pool pick has fresh numbers) —
7
+ * appends history, detects
5
8
  * active-profile threshold crossings, and writes `daemon-state.json` so
6
9
  * `status`/the GUI can read a fresh snapshot without hitting the API. When
7
10
  * `autoSwitch.enabled` (default OFF) it also watches every profile and, once the
@@ -10,19 +13,25 @@
10
13
  * suggestion is a convenience; the actual switch is always a user interaction
11
14
  * (see `AutoSwitchConfig`). The daemon never mutates the active profile.
12
15
  *
16
+ * Codex banked resets: when the active Codex profile is over the threshold and
17
+ * holds a reset credit, the daemon NOTIFIES that one is available (once per
18
+ * reset cycle). It never redeems one — that is an explicit user action
19
+ * (`agent-switch reset`, or the GUI's confirmed button). It also fires the
20
+ * per-profile protection notice (see `protection.ts`): a warning only, nothing
21
+ * is paused or switched.
22
+ *
13
23
  * The pure helpers (single-instance, poll-target selection, backoff, state
14
24
  * freshness, suggestion decision in `usage.ts`) are exported for unit testing;
15
25
  * `runDaemon` is the loop.
16
26
  */
17
27
  import * as fs from "node:fs";
18
28
  import * as path from "node:path";
19
- import { ROOT, activeFor, configDir, labelFor, listProfiles, readAutoSwitch, readBinaryPath } from "./profiles.js";
29
+ import { ROOT, activeFor, configDir, isDisabled, labelFor, listProfiles, readAutoSwitch, readBinaryPath } from "./profiles.js";
20
30
  import { profileDir } from "./profiles.js";
21
31
  import { fetchUsage, liveSessionPids } from "./api.js";
22
32
  import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
23
33
  import { parseUsage, detectCrossings, pickSwitchTarget, maxUtilization } from "./usage.js";
24
34
  import { readCodexUsage } from "./codex-usage.js";
25
- import { redeemResetCredit } from "./codex-reset.js";
26
35
  import { recordHistorySample } from "./history.js";
27
36
  import { appendNotification, markOsNotified } from "./notifications.js";
28
37
  import { readOsNotifications } from "./profiles.js";
@@ -31,6 +40,7 @@ import { listSessions, listCodexSessions, markLive } from "./sessions.js";
31
40
  import { readContext } from "./telemetry.js";
32
41
  import { readTelemetryConfig, detectContextCrossings, coalesce, } from "./notify.js";
33
42
  import { readEvents, eventFile } from "./hooks.js";
43
+ import { protectionNotice, readProtection } from "./protection.js";
34
44
  import { execFileSync } from "node:child_process";
35
45
  /** Append an event to the shared log and, when the operator opted into
36
46
  * daemon-side OS notifications, fire a desktop notification too — marking the
@@ -113,13 +123,23 @@ export function releaseSingleInstance(myPid, file = PIDFILE) {
113
123
  }
114
124
  }
115
125
  // ---------- poll discipline ----------
126
+ /** How often a profile that is polled ONLY because a pool rule targets it is
127
+ * refreshed. Below {@link FRESH_USAGE_MS} (10 min) so `dir` keeps seeing pool
128
+ * members as fresh, yet five times sparser than the active profile's cadence —
129
+ * idle pool members cost one usage read per profile every 5 minutes. */
130
+ export const POOL_POLL_INTERVAL_MS = 5 * 60_000;
116
131
  /**
117
- * Which Claude profiles to poll this cycle. Normally the active one plus any
118
- * with a live session — never a busy-poll of idle accounts. With `watchAll`
132
+ * Which profiles to poll this cycle. Normally the active one plus any with a
133
+ * live session — never a busy-poll of idle accounts. With `watchAll`
119
134
  * (auto-switch enabled) every profile is polled, since the switch decision
120
- * needs each candidate's headroom. `liveCheck` is injected for testability.
135
+ * needs each candidate's headroom. `pooled` profiles (every enabled account:
136
+ * plain starts and pools are balanced across them) are added too, but only once
137
+ * their last capture is at least
138
+ * {@link POOL_POLL_INTERVAL_MS} old — that is what keeps `dir`'s pool pick on
139
+ * fresh numbers without polling idle accounts every minute. `liveCheck` and
140
+ * `lastCapturedMs` are injected for testability.
121
141
  */
122
- export function selectPollTargets(claudeNames, active, liveCheck, watchAll = false) {
142
+ export function selectPollTargets(claudeNames, active, liveCheck, watchAll = false, pooled = [], lastCapturedMs = () => null, now = Date.now()) {
123
143
  if (watchAll)
124
144
  return [...claudeNames];
125
145
  const targets = new Set();
@@ -128,6 +148,13 @@ export function selectPollTargets(claudeNames, active, liveCheck, watchAll = fal
128
148
  for (const n of claudeNames)
129
149
  if (liveCheck(n))
130
150
  targets.add(n);
151
+ for (const n of pooled) {
152
+ if (!claudeNames.includes(n) || targets.has(n))
153
+ continue;
154
+ const last = lastCapturedMs(n);
155
+ if (last === null || now - last >= POOL_POLL_INTERVAL_MS)
156
+ targets.add(n);
157
+ }
131
158
  return [...targets];
132
159
  }
133
160
  /** Exponential backoff on consecutive failures, floored at the base interval
@@ -158,6 +185,28 @@ export function decideThresholdAction(active, polled, autoSwitch, isEligible = (
158
185
  const suggestedTarget = target && target !== active ? target : null;
159
186
  return { notify: suggestedTarget !== null, suggestedTarget };
160
187
  }
188
+ /**
189
+ * The "a banked Codex reset is available" notice (D3). Pure: returns the notice
190
+ * when the active profile is at/over the threshold, holds a reset credit, and
191
+ * this reset cycle was not announced yet — else null. Redeeming stays an
192
+ * explicit user action (`agent-switch reset`, the GUI's confirmed button).
193
+ */
194
+ export function codexResetNotice(active, snap, threshold, lastNotifiedCycle) {
195
+ if ((snap.resetCredits ?? 0) <= 0)
196
+ return null;
197
+ const stuck = snap.windows.find((w) => typeof w.utilization === "number" && w.utilization >= threshold);
198
+ if (!stuck)
199
+ return null;
200
+ const cycle = stuck.resetsAt ?? "";
201
+ if (lastNotifiedCycle === cycle)
202
+ return null;
203
+ return {
204
+ cycle,
205
+ title: "Codex reset available",
206
+ message: `codex/${active} hit ≥${threshold}% and has ${snap.resetCredits} banked reset(s). ` +
207
+ `Nothing was redeemed — to use one: agent-switch reset ${active} --provider codex`,
208
+ };
209
+ }
161
210
  // ---------- context monitoring (own-session; Phase 3) ----------
162
211
  let _claudeVer;
163
212
  function claudeVer() {
@@ -253,12 +302,19 @@ async function snapshotFor(provider, name) {
253
302
  const raw = await fetchUsage(token);
254
303
  return raw ? parseUsage(raw) : null;
255
304
  }
256
- async function pollProvider(provider, state, thresholds, redeemed, log) {
257
- const names = listProfiles(provider);
305
+ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
306
+ const names = listProfiles(provider).filter((n) => !isDisabled(provider, n));
258
307
  const active = activeFor(provider);
259
308
  const autoSwitch = readAutoSwitch(provider);
260
309
  const isLive = (n) => (provider === "claude" ? liveSessionPids(configDir("claude", n)).length > 0 : false);
261
- const targets = selectPollTargets(names, active, isLive, autoSwitch.enabled);
310
+ // Every start without a folder rule is balanced across all enabled accounts
311
+ // (dir-resolve.ts), so each one needs fresh numbers — at the pool cadence.
312
+ const pooled = names;
313
+ const lastCaptured = (n) => {
314
+ const t = Date.parse(state.profiles[`${provider}/${n}`]?.capturedAt ?? "");
315
+ return isNaN(t) ? null : t;
316
+ };
317
+ const targets = selectPollTargets(names, active, isLive, autoSwitch.enabled, pooled, lastCaptured);
262
318
  let failures = 0;
263
319
  const polled = [];
264
320
  state.notifiedFetchFail = state.notifiedFetchFail ?? {};
@@ -286,6 +342,15 @@ async function pollProvider(provider, state, thresholds, redeemed, log) {
286
342
  }
287
343
  state.profiles[key] = snapshot;
288
344
  polled.push({ name, snapshot });
345
+ // Session protection (per profile, default on at 98 %): a warning only.
346
+ const pNotice = protectionNotice(key, snapshot, readProtection(provider, name), state.protectionNotified?.[key]);
347
+ if (pNotice) {
348
+ state.protectionNotified = { ...(state.protectionNotified ?? {}), [key]: pNotice.cycle };
349
+ log(`protection: ${pNotice.message}`);
350
+ // "info", not "warning": the GUI mutes warnings by default (fetch-failure
351
+ // noise), and a default-on protection notice must actually be seen.
352
+ notify({ kind: "info", title: pNotice.title, message: pNotice.message });
353
+ }
289
354
  recordHistorySample(path.join(profileDir(provider, name), "usage-history.json"), snapshot);
290
355
  if (name === active) {
291
356
  const prev = thresholds.get(`${provider}/${name}`) ?? {};
@@ -311,28 +376,15 @@ async function pollProvider(provider, state, thresholds, redeemed, log) {
311
376
  const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
312
377
  if (activeMax === null || activeMax < autoSwitch.threshold)
313
378
  return failures; // still has headroom
314
- // Codex only (banked resets are a Codex feature): redeem a reset and STAY, but
315
- // AT MOST ONCE per reset cycle. We record the window's resets_at BEFORE calling,
316
- // so a buggy reset that doesn't actually clear usage can never loop and burn the
317
- // whole balance — it falls through to a switch SUGGESTION on the same poll's
318
- // next-cycle. Redeeming still stays on the active profile.
379
+ // Codex only (banked resets are a Codex feature): a reset credit is scarce and
380
+ // redeeming it is the user's call (D3) — the daemon only says one is there,
381
+ // once per reset cycle, and never calls the redeem endpoint itself.
319
382
  if (provider === "codex" && activeSnap) {
320
- const stuck = activeSnap.windows.find((w) => typeof w.utilization === "number" && w.utilization >= autoSwitch.threshold);
321
- const cycle = stuck?.resetsAt ?? "";
322
- const guardKey = `codex/${active}`;
323
- if ((activeSnap.resetCredits ?? 0) > 0 && redeemed.get(guardKey) !== cycle) {
324
- redeemed.set(guardKey, cycle);
325
- const r = await redeemResetCredit(configDir("codex", active));
326
- if (r.ok) {
327
- log(`auto-switch: codex/${active} over ≥${autoSwitch.threshold}% → redeemed a reset (windows_reset=${r.windowsReset ?? "?"}); staying on ${active}`);
328
- notify({
329
- kind: "success",
330
- title: "Codex reset redeemed",
331
- message: `codex/${active} hit ≥${autoSwitch.threshold}% — redeemed a banked reset and stayed on ${active}.`,
332
- });
333
- return failures;
334
- }
335
- log(`auto-switch: codex/${active} reset redeem failed (${r.reason ?? "?"}) → falling back to a switch suggestion`);
383
+ const notice = codexResetNotice(active, activeSnap, autoSwitch.threshold, notifiedReset.get(`codex/${active}`));
384
+ if (notice) {
385
+ notifiedReset.set(`codex/${active}`, notice.cycle);
386
+ log(`threshold: codex/${active} over ≥${autoSwitch.threshold}% with a banked reset available → notifying (no automatic redeem)`);
387
+ notify({ kind: "info", title: notice.title, message: notice.message });
336
388
  }
337
389
  }
338
390
  // Tag filter: only accounts carrying the configured label are eligible switch
@@ -361,10 +413,10 @@ async function pollProvider(provider, state, thresholds, redeemed, log) {
361
413
  }
362
414
  return failures;
363
415
  }
364
- async function pollOnce(state, thresholds, redeemed, log) {
416
+ async function pollOnce(state, thresholds, notifiedReset, log) {
365
417
  let failures = 0;
366
418
  for (const provider of AUTO_PROVIDERS) {
367
- failures += await pollProvider(provider, state, thresholds, redeemed, log);
419
+ failures += await pollProvider(provider, state, thresholds, notifiedReset, log);
368
420
  }
369
421
  state.lastPoll = new Date().toISOString();
370
422
  state.lastError = failures > 0 ? `${failures} profile(s) failed to poll` : null;
@@ -396,16 +448,15 @@ export async function runDaemon(opts = {}) {
396
448
  // Load persisted usage-window fired state so crossings do NOT re-fire after a
397
449
  // daemon restart within the same window cycle (the pre-existing in-memory gap).
398
450
  const thresholds = new Map(Object.entries(state.usageThresholds ?? {}));
399
- // Anti-loop guard for the Codex reset redemption: `<provider>/<name>` → the
400
- // window resets_at we last redeemed for, so we never redeem the same stuck
401
- // cycle twice.
402
- const redeemed = new Map();
451
+ // `<provider>/<name>` → the window resets_at we last announced a banked reset
452
+ // for, so the "reset available" notice fires once per cycle, not every poll.
453
+ const notifiedReset = new Map();
403
454
  let consecutiveFailures = 0;
404
455
  let cycles = 0;
405
456
  log(`started (pid ${process.pid}, base interval ${baseInterval}ms).`);
406
457
  while (running) {
407
458
  try {
408
- const failures = await pollOnce(state, thresholds, redeemed, log);
459
+ const failures = await pollOnce(state, thresholds, notifiedReset, log);
409
460
  consecutiveFailures = failures > 0 ? consecutiveFailures + 1 : 0;
410
461
  }
411
462
  catch (err) {
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Claude Code sessions run from Claude Desktop.
3
+ *
4
+ * Desktop keeps one metadata file per session under its data dir, filed by
5
+ * the signed-in account: `<data>/<kind>/<accountUuid>/<orgUuid>/local_<id>.json`
6
+ * with the `cliSessionId` that names the transcript. Where that transcript
7
+ * lives depends on the kind (measured on Claude Desktop with Claude Code
8
+ * 2.1.295):
9
+ *
10
+ * - `claude-code-sessions` (the Code tab): in the DEFAULT `~/.claude/projects/`
11
+ * — even when the Desktop instance belongs to an agent-switch profile.
12
+ * - `local-agent-mode-sessions`: in the session's own `local_<id>/.claude/`.
13
+ *
14
+ * The transcript itself names no account, so a session is attributed through
15
+ * the account folder it is filed under, matched against each profile's
16
+ * `oauthAccount.accountUuid`. A profile's Desktop instance holding sessions of
17
+ * another account is signed in with that account — the caller warns about it.
18
+ */
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import { readSessionHeader } from "./sessions.js";
22
+ /** Written within this long → shown as running (Desktop leaves no pid to check). */
23
+ export const DESKTOP_LIVE_MS = 2 * 60_000;
24
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
25
+ const KINDS = ["claude-code-sessions", "local-agent-mode-sessions"];
26
+ function dirs(p) {
27
+ try {
28
+ return fs
29
+ .readdirSync(p, { withFileTypes: true })
30
+ .filter((e) => e.isDirectory())
31
+ .map((e) => e.name)
32
+ .sort();
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ function files(p) {
39
+ try {
40
+ return fs.readdirSync(p).filter((f) => f.startsWith("local_") && f.endsWith(".json"));
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ }
46
+ /** `<projects>/<any encoded cwd>/<id>.jsonl`, or null. */
47
+ function findTranscript(projects, id) {
48
+ for (const d of dirs(projects)) {
49
+ const f = path.join(projects, d, `${id}.jsonl`);
50
+ if (fs.existsSync(f))
51
+ return f;
52
+ }
53
+ return null;
54
+ }
55
+ function readMeta(file) {
56
+ try {
57
+ const d = JSON.parse(fs.readFileSync(file, "utf8"));
58
+ return d && typeof d === "object" ? d : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ export function scanDesktop(opts) {
65
+ const rows = [];
66
+ const instances = [];
67
+ for (const inst of opts.instances) {
68
+ const seen = new Map();
69
+ for (const kind of KINDS) {
70
+ const base = path.join(inst.dir, kind);
71
+ for (const account of dirs(base).filter((d) => UUID_RE.test(d))) {
72
+ const profile = opts.profileOf(account);
73
+ seen.set(account, profile);
74
+ for (const org of dirs(path.join(base, account))) {
75
+ const folder = path.join(base, account, org);
76
+ for (const f of files(folder)) {
77
+ const m = readMeta(path.join(folder, f));
78
+ const id = typeof m?.cliSessionId === "string" && UUID_RE.test(m.cliSessionId) ? m.cliSessionId : null;
79
+ if (!m || !id || m.isArchived === true || !profile)
80
+ continue;
81
+ const transcript = kind === "claude-code-sessions"
82
+ ? findTranscript(path.join(opts.claudeHome, "projects"), id)
83
+ : findTranscript(path.join(folder, f.slice(0, -".json".length), ".claude", "projects"), id);
84
+ let st = null;
85
+ try {
86
+ st = transcript ? fs.statSync(transcript) : null;
87
+ }
88
+ catch {
89
+ st = null;
90
+ }
91
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
92
+ const mtimeMs = st?.mtimeMs ?? num(m.lastActivityAt) ?? num(m.createdAt) ?? 0;
93
+ const header = transcript ? readSessionHeader(transcript) : { cwd: null, summary: null };
94
+ rows.push({
95
+ sessionId: id,
96
+ projectDir: transcript ? path.basename(path.dirname(transcript)) : "",
97
+ cwd: typeof m.cwd === "string" ? m.cwd : header.cwd,
98
+ summary: typeof m.title === "string" && m.title ? m.title : header.summary,
99
+ mtimeMs,
100
+ birthtimeMs: num(m.createdAt) ?? st?.birthtimeMs ?? mtimeMs,
101
+ live: !!st && opts.now - st.mtimeMs <= DESKTOP_LIVE_MS,
102
+ ...(transcript ? { file: transcript } : {}),
103
+ profile,
104
+ surface: "desktop-app",
105
+ });
106
+ }
107
+ }
108
+ }
109
+ }
110
+ const accounts = [...seen.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([accountUuid, profile]) => ({ accountUuid, profile }));
111
+ instances.push({ ...inst, accounts, mismatch: inst.profile !== null && accounts.some((a) => a.profile !== inst.profile) });
112
+ }
113
+ rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
114
+ return { rows, instances };
115
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Which profile a new session in a folder gets, and why — the logic behind
3
+ * `agent-switch dir` (the shell wrapper's hot path) and `dir --json`.
4
+ *
5
+ * Order: folder rule (to a profile, or to a pool) > active profile > none.
6
+ * A pool resolves to the member with the most headroom on FRESH cached usage.
7
+ * Unknown usage is never treated as free: a member whose usage is older than
8
+ * {@link FRESH_USAGE_MS}, missing, or who is signed out is skipped, and when no
9
+ * member qualifies the rule falls back to the active profile and says so. No
10
+ * network call happens here — only the cache the daemon / `status` already wrote.
11
+ * The background service (`agent-switch service`) is what keeps that cache
12
+ * fresh for pool members: it polls every member of a pool a folder rule targets.
13
+ *
14
+ * Live sessions: under guard `block` a member that already holds a live
15
+ * session is skipped (starting there would be refused). Otherwise a free member
16
+ * wins a headroom tie over a busy one, and a busy pick is called out.
17
+ */
18
+ import { describe, level, weigh } from "./balance.js";
19
+ import { isPoolRef, poolIdOf, parseMember } from "./pools.js";
20
+ export const FRESH_USAGE_MS = 10 * 60_000;
21
+ export const DEFAULT_LIMIT_PCT = 95;
22
+ export function ago(ms) {
23
+ const s = Math.max(0, Math.round(ms / 1000));
24
+ if (s < 60)
25
+ return "just now";
26
+ const m = Math.round(s / 60);
27
+ if (m < 60)
28
+ return `${m} min ago`;
29
+ const h = Math.round(m / 60);
30
+ return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`;
31
+ }
32
+ function fallbackToActive(i, why) {
33
+ if (i.active && i.exists(i.active)) {
34
+ why.push(`Using the active profile ${i.providerId}/${i.active}`);
35
+ return { name: i.active, why };
36
+ }
37
+ why.push("No active profile — the tool starts with its own default login");
38
+ return { name: null, why };
39
+ }
40
+ export function resolveForDir(i) {
41
+ const shown = (p) => (i.displayPath ? i.displayPath(p) : p);
42
+ const why = [];
43
+ if (i.mapping && isPoolRef(i.mapping.name)) {
44
+ const id = poolIdOf(i.mapping.name);
45
+ why.push(`Rule: folder ${shown(i.mapping.path)} → pool ${id}`);
46
+ const pool = i.pool(id);
47
+ if (!pool) {
48
+ why.push(`Pool ${id} no longer exists`);
49
+ return fallbackToActive(i, why);
50
+ }
51
+ const considered = pool.members.map(parseMember).filter((m) => m && m.provider === i.providerId);
52
+ if (considered.length === 0) {
53
+ why.push(`Pool ${id} has no ${i.providerId} profile`);
54
+ return fallbackToActive(i, why);
55
+ }
56
+ const best = pickBalanced(i, considered.map((m) => m.name), why, null);
57
+ if (!best) {
58
+ why.push(`No member of pool ${id} has fresh usage`);
59
+ why.push("Pool picks use the background service's cached usage — keep it running: agent-switch service install");
60
+ return fallbackToActive(i, why);
61
+ }
62
+ return { name: best, why };
63
+ }
64
+ if (i.mapping && i.exists(i.mapping.name) && i.disabled?.(i.mapping.name)) {
65
+ why.push(`Rule: folder ${shown(i.mapping.path)} → ${i.providerId}/${i.mapping.name}, but that account is disabled`);
66
+ return balancePlain(i, why);
67
+ }
68
+ if (i.mapping && i.exists(i.mapping.name)) {
69
+ why.push(`Rule: folder ${shown(i.mapping.path)} → ${i.providerId}/${i.mapping.name}`);
70
+ return { name: i.mapping.name, why };
71
+ }
72
+ if (i.mapping)
73
+ why.push(`Rule: folder ${shown(i.mapping.path)} → ${i.providerId}/${i.mapping.name}, but that profile is gone`);
74
+ return balancePlain(i, why);
75
+ }
76
+ /** No rule decides: balance across all profiles, or the active one when the
77
+ * caller gave no profile list. */
78
+ function balancePlain(i, why) {
79
+ if (!i.names)
80
+ return fallbackToActive(i, why);
81
+ const n = i.names.length;
82
+ why.push(`${i.mapping ? "Balancing" : "No folder rule — balancing"} across ${n} ${i.providerId} account${n === 1 ? "" : "s"} by what each would lose at its reset`);
83
+ const best = pickBalanced(i, i.names, why, i.active);
84
+ if (best)
85
+ return { name: best, why };
86
+ why.push("No account has fresh usage to weigh — using the plain-start account");
87
+ return fallbackToActive(i, why);
88
+ }
89
+ /**
90
+ * The candidate that would lose the most at its reset (see balance.ts), after
91
+ * dropping every profile that can't take work. A level tie goes to `prefer`
92
+ * (the plain-start account), then to one without a live session.
93
+ */
94
+ function pickBalanced(i, names, why, prefer) {
95
+ const isLive = i.live ?? (() => false);
96
+ const guard = i.guard ?? "off";
97
+ const picks = [];
98
+ for (const name of names) {
99
+ const key = `${i.providerId}/${name}`;
100
+ if (!i.exists(name)) {
101
+ why.push(`Skipped ${key}: profile not found`);
102
+ continue;
103
+ }
104
+ if (i.disabled?.(name)) {
105
+ why.push(`Skipped ${key}: disabled`);
106
+ continue;
107
+ }
108
+ if (!i.signedIn(name)) {
109
+ why.push(`Skipped ${key}: signed out`);
110
+ continue;
111
+ }
112
+ const live = isLive(name);
113
+ if (live && guard === "block") {
114
+ why.push(`Skipped ${key}: already has a live session (guard: block would refuse it)`);
115
+ continue;
116
+ }
117
+ const u = i.usage(name);
118
+ if (u.capturedMs === null || !u.snapshot) {
119
+ why.push(`Skipped ${key}: usage unknown`);
120
+ continue;
121
+ }
122
+ const ageMs = i.now - u.capturedMs;
123
+ if (ageMs > FRESH_USAGE_MS) {
124
+ why.push(`Skipped ${key}: usage checked ${ago(ageMs)} (too old to trust)`);
125
+ continue;
126
+ }
127
+ const w = weigh(u.snapshot, i.now, i.threshold?.(name) ?? DEFAULT_LIMIT_PCT);
128
+ if (!w.usable) {
129
+ why.push(`Skipped ${key}: ${w.why}`);
130
+ continue;
131
+ }
132
+ picks.push({ name, w, live, ageMs });
133
+ }
134
+ if (!picks.length)
135
+ return null;
136
+ const top = picks.reduce((a, b) => (b.w.need > a.w.need ? b : a));
137
+ const tied = picks.filter((p) => level(p.w.need, top.w.need));
138
+ let chosen = top;
139
+ if (tied.length > 1) {
140
+ chosen = tied.find((p) => p.name === prefer) ?? tied.find((p) => !p.live) ?? top;
141
+ const keys = tied.map((p) => `${i.providerId}/${p.name}`).join(", ");
142
+ const by = chosen.name === prefer ? "the plain-start account" : !chosen.live && tied.some((p) => p.live) ? "the one without a live session" : "the first of them";
143
+ why.push(`${keys} are level (${top.w.need.toFixed(1)} %/h) — taking ${by}, ${i.providerId}/${chosen.name}`);
144
+ }
145
+ const runnerUp = picks.filter((p) => p !== chosen).reduce((a, b) => (!a || b.w.need > a.w.need ? b : a), null);
146
+ if (chosen.live)
147
+ why.push(`Note: ${i.providerId}/${chosen.name} already has a live session (guard: ${guard})`);
148
+ why.push(`Picked ${describe(`${i.providerId}/${chosen.name}`, chosen.w)} · usage checked ${ago(chosen.ageMs)}` +
149
+ (runnerUp && tied.length === 1 ? ` — ahead of ${i.providerId}/${runnerUp.name} (${runnerUp.w.need.toFixed(1)} %/h)` : ""));
150
+ return chosen.name;
151
+ }