@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 +28 -4
- package/dist/activity.js +2 -0
- package/dist/auth-status.js +10 -0
- package/dist/balance.js +4 -1
- package/dist/daemon.js +7 -5
- package/dist/desktop-settings.js +90 -0
- package/dist/dir-resolve.js +55 -28
- package/dist/doctor.js +35 -1
- package/dist/history.js +20 -0
- package/dist/index.js +193 -11
- package/dist/launchers.js +228 -0
- package/dist/locks.js +61 -0
- package/dist/pools.js +58 -45
- package/dist/profiles.js +132 -81
- package/dist/protection.js +26 -19
- package/dist/rebind.js +7 -1
- package/dist/service.js +16 -0
- package/dist/shellenv.js +25 -3
- package/dist/token-freshen.js +11 -0
- package/package.json +1 -1
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
|
|
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**;
|
|
347
|
-
|
|
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. */
|
package/dist/auth-status.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/dir-resolve.js
CHANGED
|
@@ -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) >
|
|
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
|
|
9
|
-
*
|
|
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
|
|
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
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|