@event4u/agent-switch 2.2.0 → 2.3.1

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/README.md CHANGED
@@ -74,7 +74,7 @@ $ asw # list all profiles, the starred one marked *
74
74
  - **Disable instead of delete** — a disabled account keeps its login and history but is never picked and not polled; refresh it by hand when you want to look.
75
75
  - **One prompt history** — with the shared setup (`agent-switch share on`, the app's default) ↑ and Ctrl+R in Claude Code find your old prompts on every account.
76
76
  - **Explicit switching only** — a running session moves to another account only when you choose it (live switch, take over or hand off). Nothing switches on its own.
77
- - **Balanced starts, pools and project rules** — a plain `claude` with no folder rule is spread across your enabled accounts: it starts on the one that would lose the most at its next reset (its rest divided by the hours until its weekly window resets), so every account gets used up by its reset instead of one running dry while another resets unused. An account at its protection limit is never picked; when the best accounts are level, or nothing can be weighed, the account set for plain starts (the star) is used. A folder can also point at one account, or at a pool that is balanced the same way. `dir --json` tells you why. The weighing uses cached usage only — written by the background service (`agent-switch service install`, which refreshes every enabled account every 5 minutes) or by the desktop app's own refresh; usage older than 10 minutes is not trusted and `dir --json` says so. Under `guard block` an account that already has a live session is skipped.
77
+ - **Balanced starts, pools and project rules** — a plain `claude` with no folder rule is spread across your enabled accounts: it starts on the one that would lose the most at its next reset (its rest divided by the hours until its weekly window resets), so every account gets used up by its reset instead of one running dry while another resets unused. An account at its protection limit is never picked; when the best accounts are level, or nothing can be weighed, the account set for plain starts (the star) is used. A folder can also point at one account, or at a pool that is balanced the same way — a pool start never leaves the pool: without fresh usage it still takes a member, and a pool that is gone or has no usable member refuses the start. `dir --json` tells you why. The weighing uses cached usage only — written by the background service (`agent-switch service install`, which refreshes every enabled account every 5 minutes) or by every refresh the desktop app makes (kept unthrottled in `usage-latest.json`); usage older than 10 minutes is not trusted and `dir --json` says so. Under `guard block` an account that already has a live session is skipped.
78
78
  - **Health, activity, sign-in state** — `doctor --json`, `activity`, `auth-status` — readouts without tokens.
79
79
  - **Switch, the desktop pet** — introduces itself once, on the very first start. Never again.
80
80
 
@@ -327,9 +327,33 @@ profile. Two strategies, picked per app:
327
327
 
328
328
  ```bash
329
329
  agent-switch apps # list launchable GUI apps + installed state
330
- agent-switch open <app> [profile] # launch an app on a profile (active if omitted)
330
+ agent-switch open <app> [profile] # launch an app on a profile (active if omitted);
331
+ # if that profile already runs, its window comes to the front
332
+ agent-switch launcher add <app> <profile> # a Dock/Spotlight app for that account
333
+ agent-switch launcher remove <app> <profile> # remove it again (list / repair as well)
334
+ agent-switch app-settings copy claude-desktop --from default --to <profile>
335
+ # MCP servers, preferences, extensions — never logins
331
336
  ```
332
337
 
338
+ **Launchers.** `launcher add` puts a small app into `~/Applications/Agent Switch/`
339
+ (e.g. `Claude Desktop – Work.app`) that runs `agent-switch open` for that
340
+ account — drag it into the Dock or start it from Spotlight. It is built locally
341
+ with `osacompile` and signed ad hoc, so Gatekeeper does not prompt; the
342
+ original app is never copied, patched or re-signed. The launcher carries the
343
+ app's icon; the open window still shows the app's own icon and name. Removing
344
+ or renaming a profile removes or rebuilds its launchers, `uninstall` removes
345
+ them all, and `launcher repair` rebuilds them after Node or agent-switch moved.
346
+ macOS only: Claude Desktop on Windows is an MSIX package whose executable
347
+ cannot be started with its own arguments.
348
+
349
+ **Copying Claude Desktop settings.** Chats, Projects and memory belong to the
350
+ account on Anthropic's servers and cannot be moved. What lives on the Mac can:
351
+ `app-settings copy` takes the MCP servers, preferences and installed extensions
352
+ from the default install (`default`) or another profile. It never copies
353
+ `config.json` (it holds the login's token cache), cookies, web storage,
354
+ session folders or preferences scoped to one account; replaced files are kept
355
+ as `*.pre-copy`. Quit Claude Desktop on the target profile first.
356
+
333
357
  **Supported now:**
334
358
 
335
359
  - **Claude Desktop** (`agent-switch open claude-desktop <profile>`) —
@@ -343,8 +367,8 @@ agent-switch open <app> [profile] # launch an app on a profile (active if omitte
343
367
  More clients land via their roadmaps in `agents/roadmaps/`.
344
368
 
345
369
  Caveats (why this is experimental):
346
- - **macOS-only for now**; launched via the `open` command, not a Finder/Dock
347
- double-click (a plain launch doesn't carry the flag/env → no isolation).
370
+ - **macOS-only for now**; a plain Finder/Dock start of the app itself doesn't
371
+ carry the flag/env (→ no isolation) — use `agent-switch open` or a launcher.
348
372
  - **Unofficial / version-fragile** — no vendor ships a built-in account
349
373
  switcher; an app update can change launcher behaviour. Re-verify per app.
350
374
  - The profile's data dir is never swapped on a live app (that risks
package/dist/activity.js CHANGED
@@ -27,6 +27,8 @@ export const ACTIVITY_KINDS = [
27
27
  "protection",
28
28
  "pool",
29
29
  "remove",
30
+ "launcher",
31
+ "settings",
30
32
  ];
31
33
  /** Strip anything shaped like a credential: OAuth/API key prefixes, JWTs, and
32
34
  * long opaque runs. Over-redaction is fine here; a leaked token is not. */
@@ -11,6 +11,16 @@
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  export const EXPIRING_MS = 3 * 24 * 60 * 60_000;
14
+ /** Who keeps a Claude login fresh: the background service polls (and so
15
+ * refreshes) every enabled profile — except one sharing the default install's
16
+ * login, which agent-switch never refreshes. */
17
+ export function keptFreshByFor(provider, o, base) {
18
+ if (o.shared)
19
+ return "only you, by using it — it shares its login with the default install";
20
+ if (provider === "claude" && o.serviceInstalled)
21
+ return "the agent-switch service (Claude Code's own refresh)";
22
+ return base;
23
+ }
14
24
  function iso(ms) {
15
25
  return ms !== null && Number.isFinite(ms) ? new Date(ms).toISOString() : null;
16
26
  }
package/dist/balance.js CHANGED
@@ -34,7 +34,10 @@ export function weigh(snapshot, now, thresholdPct) {
34
34
  return { usable: false, why: "usage unknown" };
35
35
  const rest = Math.max(0, 100 - w.utilization);
36
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 };
37
+ // The fullest window breaks a level tie: equal room this week, but one is about
38
+ // to hit its 5h limit.
39
+ const peak = Math.max(...snapshot.windows.map((x) => (typeof x.utilization === "number" ? x.utilization : 0)));
40
+ return { usable: true, need: rest / (resetH ?? UNKNOWN_RESET_H), rest, resetH, window: windowName(w), key: w.key, peak };
38
41
  }
39
42
  const WINDOW_NAME = { five_hour: "5h", seven_day: "7d", monthly: "30d" };
40
43
  /** Named by key: a window read back from the usage history carries its key as label. */
package/dist/daemon.js CHANGED
@@ -26,13 +26,13 @@
26
26
  */
27
27
  import * as fs from "node:fs";
28
28
  import * as path from "node:path";
29
- import { ROOT, activeFor, configDir, isDisabled, labelFor, listProfiles, readAutoSwitch, readBinaryPath } from "./profiles.js";
29
+ import { ROOT, activeFor, configDir, isDisabled, labelFor, listProfiles, readAutoSwitch, readBinaryPath, sharesDefaultLogin } from "./profiles.js";
30
30
  import { profileDir } from "./profiles.js";
31
31
  import { fetchUsage, liveSessionPids } from "./api.js";
32
- import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
32
+ import { defaultFreshenDeps, usageToken } from "./token-freshen.js";
33
33
  import { parseUsage, detectCrossings, pickSwitchTarget, maxUtilization } from "./usage.js";
34
34
  import { readCodexUsage } from "./codex-usage.js";
35
- import { recordHistorySample } from "./history.js";
35
+ import { LATEST_FILE, recordHistorySample, writeLatestSnapshot } from "./history.js";
36
36
  import { appendNotification, markOsNotified } from "./notifications.js";
37
37
  import { readOsNotifications } from "./profiles.js";
38
38
  import { osNotify } from "./os-notify.js";
@@ -46,12 +46,29 @@ import { execFileSync } from "node:child_process";
46
46
  * daemon-side OS notifications, fire a desktop notification too — marking the
47
47
  * record so the GUI does not show it a second time. Deduped events (append
48
48
  * returns null) never OS-notify. */
49
- function notify(event) {
49
+ function notify(event, opts = {}) {
50
50
  const created = appendNotification(event);
51
- if (created && readOsNotifications() && osNotify(event.title, event.message)) {
51
+ if (created && opts.os !== false && readOsNotifications() && osNotify(event.title, event.message)) {
52
52
  markOsNotified(created.id);
53
53
  }
54
54
  }
55
+ /** How long reads must fail before it counts as an outage. A single failed read
56
+ * is routine — a rate-limited endpoint answers again a minute later — and the
57
+ * last known values keep being served meanwhile. */
58
+ export const FETCH_OUTAGE_MS = 15 * 60_000;
59
+ /** The notice for a profile whose reads have failed since `sinceMs`, or null
60
+ * while it is still a blip. Kept off the desktop: a fetch failure is a
61
+ * diagnostic for the app's log, not something to interrupt anyone for. */
62
+ export function fetchFailureNotice(key, sinceMs, now) {
63
+ if (now - sinceMs < FETCH_OUTAGE_MS)
64
+ return null;
65
+ return {
66
+ kind: "warning",
67
+ title: "Usage fetch failed",
68
+ message: `Could not fetch usage limits for ${key} for ${Math.round((now - sinceMs) / 60_000)} min. Keeping the last known values.`,
69
+ os: false,
70
+ };
71
+ }
55
72
  // Providers the daemon may suggest a switch for on threshold (they expose a
56
73
  // usage readout to base a headroom suggestion on). Antigravity has none.
57
74
  const AUTO_PROVIDERS = ["claude", "codex"];
@@ -295,8 +312,9 @@ async function snapshotFor(provider, name) {
295
312
  return readCodexUsage(configDir("codex", name));
296
313
  // An expired access token is the daemon's most common "usage stopped moving"
297
314
  // cause — a profile nobody is running never refreshes itself. Let Claude Code
298
- // refresh it first (cooldown-guarded, no-op while the token is still good).
299
- const token = freshAccessToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")));
315
+ // refresh it first (cooldown-guarded, no-op while the token is still good) —
316
+ // never for a profile sharing the default install's login.
317
+ const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name));
300
318
  if (!token)
301
319
  return null;
302
320
  const raw = await fetchUsage(token);
@@ -318,24 +336,23 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
318
336
  let failures = 0;
319
337
  const polled = [];
320
338
  state.notifiedFetchFail = state.notifiedFetchFail ?? {};
339
+ state.fetchFailingSince = state.fetchFailingSince ?? {};
321
340
  for (const name of targets) {
322
341
  const key = `${provider}/${name}`;
323
342
  const snapshot = await snapshotFor(provider, name);
324
343
  if (!snapshot) {
325
344
  failures++;
326
345
  // Fail-safe: the last-known snapshot in state.profiles[key] is left intact
327
- // (never overwritten with null), and we warn ONCE per outage rather than
328
- // every cycle — the loop keeps serving the last good numbers meanwhile.
329
- if (!state.notifiedFetchFail[key]) {
346
+ // (never overwritten with null). Only an outage is reported, once.
347
+ const since = state.fetchFailingSince[key] ?? (state.fetchFailingSince[key] = new Date().toISOString());
348
+ const notice = state.notifiedFetchFail[key] ? null : fetchFailureNotice(key, Date.parse(since), Date.now());
349
+ if (notice) {
330
350
  state.notifiedFetchFail[key] = true;
331
- notify({
332
- kind: "warning",
333
- title: "Usage fetch failed",
334
- message: `Could not fetch usage limits for ${provider}/${name}. Keeping the last known values.`,
335
- });
351
+ notify(notice, { os: notice.os });
336
352
  }
337
353
  continue;
338
354
  }
355
+ delete state.fetchFailingSince[key];
339
356
  if (state.notifiedFetchFail[key]) {
340
357
  delete state.notifiedFetchFail[key];
341
358
  log(`recovered: ${provider}/${name} usage fetch succeeded again`);
@@ -352,6 +369,7 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
352
369
  notify({ kind: "info", title: pNotice.title, message: pNotice.message });
353
370
  }
354
371
  recordHistorySample(path.join(profileDir(provider, name), "usage-history.json"), snapshot);
372
+ writeLatestSnapshot(path.join(profileDir(provider, name), LATEST_FILE), snapshot);
355
373
  if (name === active) {
356
374
  const prev = thresholds.get(`${provider}/${name}`) ?? {};
357
375
  const { crossings, state: next } = detectCrossings(snapshot, prev);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Copy one desktop profile's local settings into another (Claude Desktop):
3
+ * MCP servers, preferences and installed extensions. Chats, Projects and
4
+ * memory live with the account on the server, so they are not here at all.
5
+ *
6
+ * What is never copied: `config.json` (it holds the OAuth token cache),
7
+ * cookies and web storage (the login), session folders (bound to an account
8
+ * id), and preference keys scoped to one account (`…ByAccount`) or to the
9
+ * target's own files (`coworkUserFilesPath`).
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ const LAYOUTS = {
15
+ "claude-desktop": {
16
+ merged: ["claude_desktop_config.json", "claude_desktop_config.mcp.json"],
17
+ replaced: ["Claude Extensions", "Claude Extensions Settings"],
18
+ },
19
+ };
20
+ /** The app's own data dir outside agent-switch (the plain, unprofiled install). */
21
+ export const DEFAULT_DATA_DIR = {
22
+ "claude-desktop": path.posix.join(os.homedir(), "Library", "Application Support", "Claude"),
23
+ };
24
+ export function hasSettingsLayout(appId) {
25
+ return appId in LAYOUTS;
26
+ }
27
+ function isObject(v) {
28
+ return !!v && typeof v === "object" && !Array.isArray(v);
29
+ }
30
+ export function mergeDesktopConfig(src, dst) {
31
+ const out = { ...dst };
32
+ if (src.mcpServers !== undefined)
33
+ out.mcpServers = src.mcpServers;
34
+ if (isObject(src.preferences)) {
35
+ const prefs = isObject(dst.preferences) ? { ...dst.preferences } : {};
36
+ for (const [k, v] of Object.entries(src.preferences))
37
+ if (!k.endsWith("ByAccount"))
38
+ prefs[k] = v;
39
+ out.preferences = prefs;
40
+ }
41
+ return out;
42
+ }
43
+ function readJson(file) {
44
+ try {
45
+ const v = JSON.parse(fs.readFileSync(file, "utf8"));
46
+ return isObject(v) ? v : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function backup(file) {
53
+ let to = `${file}.pre-copy`;
54
+ if (fs.existsSync(to))
55
+ to = `${file}.pre-copy-${Date.now()}`;
56
+ fs.cpSync(file, to, { recursive: true });
57
+ return to;
58
+ }
59
+ export function copyDesktopSettings(appId, fromDir, toDir) {
60
+ const layout = LAYOUTS[appId];
61
+ if (!layout)
62
+ throw new Error(`${appId} has no settings layout agent-switch knows how to copy.`);
63
+ fs.mkdirSync(toDir, { recursive: true });
64
+ const copied = [];
65
+ const backups = [];
66
+ for (const name of layout.merged) {
67
+ const src = readJson(path.join(fromDir, name));
68
+ if (!src)
69
+ continue;
70
+ const dstFile = path.join(toDir, name);
71
+ const dst = fs.existsSync(dstFile) ? readJson(dstFile) : null;
72
+ if (fs.existsSync(dstFile))
73
+ backups.push(backup(dstFile));
74
+ fs.writeFileSync(dstFile, JSON.stringify(mergeDesktopConfig(src, dst ?? {}), null, 2) + "\n");
75
+ copied.push(name);
76
+ }
77
+ for (const name of layout.replaced) {
78
+ const src = path.join(fromDir, name);
79
+ if (!fs.existsSync(src))
80
+ continue;
81
+ const dst = path.join(toDir, name);
82
+ if (fs.existsSync(dst)) {
83
+ backups.push(backup(dst));
84
+ fs.rmSync(dst, { recursive: true, force: true });
85
+ }
86
+ fs.cpSync(src, dst, { recursive: true });
87
+ copied.push(name);
88
+ }
89
+ return { copied, backups };
90
+ }
@@ -2,11 +2,15 @@
2
2
  * Which profile a new session in a folder gets, and why — the logic behind
3
3
  * `agent-switch dir` (the shell wrapper's hot path) and `dir --json`.
4
4
  *
5
- * Order: folder rule (to a profile, or to a pool) > active profile > none.
5
+ * Order: folder rule (to a profile, or to a pool) > balanced plain start > active
6
+ * profile > none. A rule never resolves outside itself: a disabled or missing
7
+ * profile, like a pool that cannot serve, refuses the start (`blocked`).
6
8
  * A pool resolves to the member with the most headroom on FRESH cached usage.
7
9
  * 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
+ * {@link FRESH_USAGE_MS}, missing, or who is signed out is skipped. A pool rule
11
+ * never leaves its pool: with no fresh member it takes a usable member anyway,
12
+ * and when the pool is gone or has no usable member the start is refused
13
+ * (`blocked`) rather than run on an account outside it. No
10
14
  * network call happens here — only the cache the daemon / `status` already wrote.
11
15
  * The background service (`agent-switch service`) is what keeps that cache
12
16
  * fresh for pool members: it polls every member of a pool a folder rule targets.
@@ -37,6 +41,10 @@ function fallbackToActive(i, why) {
37
41
  why.push("No active profile — the tool starts with its own default login");
38
42
  return { name: null, why };
39
43
  }
44
+ function refuse(why, reason) {
45
+ why.push(`Refused: ${reason}`);
46
+ return { name: null, why, blocked: reason };
47
+ }
40
48
  export function resolveForDir(i) {
41
49
  const shown = (p) => (i.displayPath ? i.displayPath(p) : p);
42
50
  const why = [];
@@ -44,33 +52,40 @@ export function resolveForDir(i) {
44
52
  const id = poolIdOf(i.mapping.name);
45
53
  why.push(`Rule: folder ${shown(i.mapping.path)} → pool ${id}`);
46
54
  const pool = i.pool(id);
47
- if (!pool) {
48
- why.push(`Pool ${id} no longer exists`);
49
- return fallbackToActive(i, why);
50
- }
55
+ if (!pool)
56
+ return refuse(why, `pool ${id} no longer exists — bind the folder anew or remove the rule (agent-switch unmap)`);
51
57
  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);
58
+ if (considered.length === 0)
59
+ return refuse(why, `pool ${id} has no ${i.providerId} profile — add one with agent-switch pool add`);
60
+ const members = considered.map((m) => m.name);
61
+ const best = pickBalanced(i, members, why, null);
62
+ if (best)
63
+ return { name: best, why };
64
+ // No fresh numbers to weigh: still a member — the rule is a boundary first.
65
+ const usable = (n) => i.exists(n) && !i.disabled?.(n) && !i.sharesDefaultLogin?.(n) && i.signedIn(n) && !(i.guard === "block" && (i.live?.(n) ?? false));
66
+ const anyone = members.filter(usable);
67
+ if (!anyone.length)
68
+ return refuse(why, `pool ${id} has no usable ${i.providerId} member (signed out, disabled, busy or sharing the default login)`);
69
+ why.push(`No member of pool ${id} has fresh usage`);
70
+ why.push("Usage comes from the background service or the open desktop app — keep one running (agent-switch service install)");
71
+ if (i.active && anyone.includes(i.active)) {
72
+ why.push(`Using the plain-start account ${i.providerId}/${i.active}, a member of pool ${id}`);
73
+ return { name: i.active, why };
55
74
  }
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 };
75
+ why.push(`Using ${i.providerId}/${anyone[0]}, the first usable member of pool ${id}`);
76
+ return { name: anyone[0], why };
63
77
  }
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}`);
78
+ // A rule to one account is a boundary too: never another account in its place.
79
+ if (i.mapping) {
80
+ const key = `${i.providerId}/${i.mapping.name}`;
81
+ const flag = i.providerId === "claude" ? "" : ` --provider ${i.providerId}`;
82
+ why.push(`Rule: folder ${shown(i.mapping.path)} → ${key}`);
83
+ if (!i.exists(i.mapping.name))
84
+ return refuse(why, `${key} no longer exists — bind the folder anew (agent-switch map) or remove the rule (agent-switch unmap)`);
85
+ if (i.disabled?.(i.mapping.name))
86
+ return refuse(why, `${key} is disabled — turn it back on (agent-switch enable ${i.mapping.name}${flag}) or change the rule`);
70
87
  return { name: i.mapping.name, why };
71
88
  }
72
- if (i.mapping)
73
- why.push(`Rule: folder ${shown(i.mapping.path)} → ${i.providerId}/${i.mapping.name}, but that profile is gone`);
74
89
  return balancePlain(i, why);
75
90
  }
76
91
  /** No rule decides: balance across all profiles, or the active one when the
@@ -79,7 +94,7 @@ function balancePlain(i, why) {
79
94
  if (!i.names)
80
95
  return fallbackToActive(i, why);
81
96
  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`);
97
+ why.push(`No folder rule — balancing across ${n} ${i.providerId} account${n === 1 ? "" : "s"} by what each would lose at its reset`);
83
98
  const best = pickBalanced(i, i.names, why, i.active);
84
99
  if (best)
85
100
  return { name: best, why };
@@ -105,6 +120,10 @@ function pickBalanced(i, names, why, prefer) {
105
120
  why.push(`Skipped ${key}: disabled`);
106
121
  continue;
107
122
  }
123
+ if (i.sharesDefaultLogin?.(name)) {
124
+ why.push(`Skipped ${key}: shares its login with the default install`);
125
+ continue;
126
+ }
108
127
  if (!i.signedIn(name)) {
109
128
  why.push(`Skipped ${key}: signed out`);
110
129
  continue;
@@ -137,9 +156,17 @@ function pickBalanced(i, names, why, prefer) {
137
156
  const tied = picks.filter((p) => level(p.w.need, top.w.need));
138
157
  let chosen = top;
139
158
  if (tied.length > 1) {
140
- chosen = tied.find((p) => p.name === prefer) ?? tied.find((p) => !p.live) ?? top;
159
+ const free = tied.filter((p) => !p.live);
160
+ const roomiest = (ps) => ps.reduce((a, b) => (b.w.peak < a.w.peak ? b : a));
161
+ chosen = tied.find((p) => p.name === prefer) ?? (free.length ? roomiest(free) : roomiest(tied));
141
162
  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";
163
+ const by = chosen.name === prefer
164
+ ? "the plain-start account"
165
+ : !chosen.live && tied.some((p) => p.live)
166
+ ? "the one without a live session"
167
+ : tied.some((p) => p.w.peak !== chosen.w.peak)
168
+ ? "the one with the most room right now"
169
+ : "the first of them";
143
170
  why.push(`${keys} are level (${top.w.need.toFixed(1)} %/h) — taking ${by}, ${i.providerId}/${chosen.name}`);
144
171
  }
145
172
  const runnerUp = picks.filter((p) => p !== chosen).reduce((a, b) => (!a || b.w.need > a.w.need ? b : a), null);
package/dist/doctor.js CHANGED
@@ -10,7 +10,7 @@ import { spawnSync } from "node:child_process";
10
10
  import * as fs from "node:fs";
11
11
  import * as os from "node:os";
12
12
  import * as path from "node:path";
13
- import { ROOT, activeFor, configDir, identity, listProfiles, profileExists, readGuard } from "./profiles.js";
13
+ import { ROOT, activeFor, configDir, identity, listProfiles, profileExists, readGuard, sharesDefaultLogin } from "./profiles.js";
14
14
  import { allProviders } from "./providers.js";
15
15
  import { credentialStore } from "./credentials.js";
16
16
  import { sharedLinkHealth } from "./share.js";
@@ -210,6 +210,38 @@ async function providerChecks(checks) {
210
210
  }
211
211
  }
212
212
  }
213
+ /** Profiles that share their login with the default install: agent-switch
214
+ * never refreshes or balances onto them; a separate sign-in ends that. */
215
+ /** The account the provider's own default install is signed into, where it can
216
+ * be read without touching a credential (Antigravity's needs the keychain). */
217
+ function defaultIdentity(p) {
218
+ if (p.id === "antigravity")
219
+ return null;
220
+ // Claude keeps .claude.json in the home dir, next to (not inside) ~/.claude.
221
+ return p.readIdentity(p.id === "claude" ? os.homedir() : p.defaultConfigDir());
222
+ }
223
+ export function sharedLoginCheck(rows, likely = []) {
224
+ if (!rows.length && !likely.length)
225
+ return { id: "shared-login", status: "ok", title: "No profile shares its login with the default install", detail: "" };
226
+ const key = (r) => `${r.provider}/${r.name}`;
227
+ const flag = (p) => (p === "claude" ? "" : ` --provider ${p}`);
228
+ const parts = [];
229
+ if (rows.length) {
230
+ parts.push(`${rows.map(key).join(", ")} — imported with the default install's credential. agent-switch never refreshes ${rows.length === 1 ? "it" : "them"} (that would sign the other side out) and never starts ${rows.length === 1 ? "it" : "them"} by balancing.`);
231
+ }
232
+ // Imports from before the marker existed: same account as the default install.
233
+ if (likely.length) {
234
+ parts.push(`${likely.map(key).join(", ")} may share ${likely.length === 1 ? "its" : "their"} login with the default install (same account, no mark) — if imported, mark ${likely.length === 1 ? "it" : "them"}; if signed in on ${likely.length === 1 ? "its" : "their"} own, ignore this.`);
235
+ }
236
+ const n = rows.length + likely.length;
237
+ return {
238
+ id: "shared-login",
239
+ status: "warn",
240
+ title: rows.length ? `${n === 1 ? "A profile shares" : `${n} profiles share`} the default install's login` : "A profile may share the default install's login",
241
+ detail: parts.join(" "),
242
+ fix: rows.length ? `agent-switch login ${rows[0].name}${flag(rows[0].provider)}` : `agent-switch mark-shared ${likely[0].name}${flag(likely[0].provider)}`,
243
+ };
244
+ }
213
245
  export async function collectDoctorReport() {
214
246
  const checks = [];
215
247
  checks.push(cliOnPathCheck());
@@ -220,6 +252,8 @@ export async function collectDoctorReport() {
220
252
  await providerChecks(checks);
221
253
  const identities = allProviders().flatMap((p) => listProfiles(p.id).map((n) => ({ provider: p.id, name: n, identity: identity(p.id, n) })));
222
254
  checks.push(signInIsolationCheck(identities));
255
+ const defaults = new Map(allProviders().map((p) => [p.id, defaultIdentity(p)]));
256
+ checks.push(sharedLoginCheck(identities.filter((r) => sharesDefaultLogin(r.provider, r.name)), identities.filter((r) => !sharesDefaultLogin(r.provider, r.name) && r.identity !== null && r.identity === defaults.get(r.provider))));
223
257
  // Share-link health (Claude-config sharing).
224
258
  for (const n of listProfiles("claude")) {
225
259
  const health = sharedLinkHealth(configDir("claude", n));
package/dist/history.js CHANGED
@@ -56,3 +56,23 @@ export function seriesFor(samples, windowKey) {
56
56
  .map((s) => s.windows.find((w) => w.key === windowKey)?.utilization)
57
57
  .filter((u) => typeof u === "number");
58
58
  }
59
+ /** The last snapshot read for a profile, kept unthrottled next to the history:
60
+ * `dir` trusts usage only while it is minutes old, and the history keeps at
61
+ * most one sample an hour. Holds the snapshot only, never a token. */
62
+ export const LATEST_FILE = "usage-latest.json";
63
+ export function writeLatestSnapshot(file, snapshot) {
64
+ fs.mkdirSync(path.dirname(file), { recursive: true });
65
+ const tmp = `${file}.${process.pid}.tmp`;
66
+ fs.writeFileSync(tmp, JSON.stringify(snapshot) + "\n", { mode: 0o600 });
67
+ fs.renameSync(tmp, file);
68
+ }
69
+ export function readLatestSnapshot(file) {
70
+ try {
71
+ const snapshot = JSON.parse(fs.readFileSync(file, "utf8"));
72
+ const capturedMs = Date.parse(snapshot?.capturedAt);
73
+ return Array.isArray(snapshot?.windows) && !isNaN(capturedMs) ? { snapshot, capturedMs } : null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }