@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.
- package/README.md +170 -68
- package/dist/activity.js +97 -0
- package/dist/args.js +23 -3
- package/dist/auth-status.js +149 -0
- package/dist/balance.js +52 -0
- package/dist/browsers.js +90 -0
- package/dist/config-transfer.js +12 -2
- package/dist/daemon.js +89 -38
- package/dist/desktop-sessions.js +115 -0
- package/dist/dir-resolve.js +151 -0
- package/dist/doctor.js +209 -52
- package/dist/gui-settings.js +67 -0
- package/dist/hooks.js +21 -0
- package/dist/index.js +921 -71
- package/dist/mappings.js +48 -9
- package/dist/pools.js +144 -0
- package/dist/profiles.js +84 -5
- package/dist/protection.js +93 -0
- package/dist/providers.js +3 -0
- package/dist/rebind.js +108 -11
- package/dist/release-notes.js +154 -0
- package/dist/service.js +24 -9
- package/dist/sessions.js +101 -13
- package/dist/share.js +92 -13
- package/dist/shellenv.js +131 -18
- package/dist/single-load.js +176 -0
- package/dist/tooling.js +73 -0
- package/dist/updates.js +37 -3
- package/package.json +1 -1
package/dist/balance.js
ADDED
|
@@ -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
|
+
}
|
package/dist/browsers.js
ADDED
|
@@ -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
|
+
}
|
package/dist/config-transfer.js
CHANGED
|
@@ -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
|
|
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
|
|
118
|
-
*
|
|
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. `
|
|
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,
|
|
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
|
-
|
|
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):
|
|
315
|
-
//
|
|
316
|
-
//
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
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,
|
|
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
|
+
}
|