@event4u/agent-switch 2.2.0 → 2.3.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.
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";
@@ -295,8 +295,9 @@ async function snapshotFor(provider, name) {
295
295
  return readCodexUsage(configDir("codex", name));
296
296
  // An expired access token is the daemon's most common "usage stopped moving"
297
297
  // 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")));
298
+ // refresh it first (cooldown-guarded, no-op while the token is still good) —
299
+ // never for a profile sharing the default install's login.
300
+ const token = usageToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")), sharesDefaultLogin("claude", name));
300
301
  if (!token)
301
302
  return null;
302
303
  const raw = await fetchUsage(token);
@@ -352,6 +353,7 @@ async function pollProvider(provider, state, thresholds, notifiedReset, log) {
352
353
  notify({ kind: "info", title: pNotice.title, message: pNotice.message });
353
354
  }
354
355
  recordHistorySample(path.join(profileDir(provider, name), "usage-history.json"), snapshot);
356
+ writeLatestSnapshot(path.join(profileDir(provider, name), LATEST_FILE), snapshot);
355
357
  if (name === active) {
356
358
  const prev = thresholds.get(`${provider}/${name}`) ?? {};
357
359
  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
+ }