@event4u/agent-switch 2.1.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 +166 -73
- package/dist/activity.js +99 -0
- package/dist/args.js +23 -3
- package/dist/auth-status.js +159 -0
- package/dist/balance.js +55 -0
- package/dist/browsers.js +90 -0
- package/dist/daemon.js +95 -42
- package/dist/desktop-sessions.js +115 -0
- package/dist/desktop-settings.js +90 -0
- package/dist/dir-resolve.js +178 -0
- package/dist/doctor.js +239 -69
- package/dist/gui-settings.js +67 -0
- package/dist/history.js +20 -0
- package/dist/hooks.js +21 -0
- package/dist/index.js +879 -68
- package/dist/launchers.js +228 -0
- package/dist/locks.js +61 -0
- package/dist/mappings.js +48 -9
- package/dist/pools.js +157 -0
- package/dist/profiles.js +181 -67
- package/dist/protection.js +100 -0
- package/dist/rebind.js +115 -12
- package/dist/service.js +40 -9
- package/dist/sessions.js +82 -13
- package/dist/share.js +92 -13
- package/dist/shellenv.js +122 -21
- package/dist/single-load.js +5 -0
- package/dist/token-freshen.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-switch auth-status [--json]` — how each profile's login stands, read
|
|
3
|
+
* from the provider's own credential store METADATA. Token values are parsed
|
|
4
|
+
* only to read expiry fields and are never copied into the result.
|
|
5
|
+
*
|
|
6
|
+
* States: `connected` (a refreshable login is stored), `expiring` (the stored
|
|
7
|
+
* login's refresh window ends within {@link EXPIRING_MS}), `sign-in-needed`
|
|
8
|
+
* (nothing usable stored, or the refresh window is over), `unknown` (the store
|
|
9
|
+
* cannot be read from here). Nothing is inferred from usage or network calls.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
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
|
+
}
|
|
24
|
+
function iso(ms) {
|
|
25
|
+
return ms !== null && Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
26
|
+
}
|
|
27
|
+
function windowState(refreshEndsMs, now) {
|
|
28
|
+
if (refreshEndsMs === null)
|
|
29
|
+
return "connected";
|
|
30
|
+
if (refreshEndsMs <= now)
|
|
31
|
+
return "sign-in-needed";
|
|
32
|
+
return refreshEndsMs - now <= EXPIRING_MS ? "expiring" : "connected";
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Claude Code credential (`{ claudeAiOauth: { refreshToken, expiresAt,
|
|
36
|
+
* refreshTokenExpiresAt? } }`). `expiresAt` there is the short-lived access
|
|
37
|
+
* token, which Claude Code refreshes itself — not a sign-in deadline, so it is
|
|
38
|
+
* not reported as one. `lastRefreshMs` is the store's mtime when it is a file.
|
|
39
|
+
*/
|
|
40
|
+
export function claudeAuthStatus(name, raw, lastRefreshMs, now) {
|
|
41
|
+
const base = { provider: "claude", name, keptFreshBy: "claude (refreshes the login when it runs)" };
|
|
42
|
+
let oauth = null;
|
|
43
|
+
try {
|
|
44
|
+
oauth = raw ? JSON.parse(raw)?.claudeAiOauth : null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
oauth = null;
|
|
48
|
+
}
|
|
49
|
+
if (!raw) {
|
|
50
|
+
return { ...base, state: "sign-in-needed", expiresAt: null, lastRefreshAt: null, detail: "No stored login for this profile." };
|
|
51
|
+
}
|
|
52
|
+
if (oauth && oauth.refreshToken === "" && (oauth.accessToken === "" || oauth.accessToken == null)) {
|
|
53
|
+
return {
|
|
54
|
+
...base,
|
|
55
|
+
state: "sign-in-needed",
|
|
56
|
+
expiresAt: null,
|
|
57
|
+
lastRefreshAt: iso(lastRefreshMs),
|
|
58
|
+
detail: "Claude Code cleared this login (signed out, or the refresh window ended). Sign in again.",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (!oauth || typeof oauth.refreshToken !== "string" || !oauth.refreshToken) {
|
|
62
|
+
return { ...base, state: "unknown", expiresAt: null, lastRefreshAt: iso(lastRefreshMs), detail: "The stored login has an unrecognised shape." };
|
|
63
|
+
}
|
|
64
|
+
const refreshEnds = typeof oauth.refreshTokenExpiresAt === "number" ? oauth.refreshTokenExpiresAt : null;
|
|
65
|
+
return {
|
|
66
|
+
...base,
|
|
67
|
+
state: windowState(refreshEnds, now),
|
|
68
|
+
expiresAt: iso(refreshEnds),
|
|
69
|
+
lastRefreshAt: iso(lastRefreshMs),
|
|
70
|
+
detail: refreshEnds === null
|
|
71
|
+
? "A refreshable login is stored; Claude does not record when it ends, so a revoked login shows up only on next use."
|
|
72
|
+
: "A refreshable login is stored.",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Codex `auth.json` (`{ tokens: { refresh_token, … }, last_refresh, OPENAI_API_KEY? }`). */
|
|
76
|
+
/** A JWT's `exp` (epoch ms) from its payload — read without verifying, only to
|
|
77
|
+
* date it; null when it is not a JWT or carries no exp. */
|
|
78
|
+
function jwtExpMs(token) {
|
|
79
|
+
if (typeof token !== "string")
|
|
80
|
+
return null;
|
|
81
|
+
const payload = token.split(".")[1];
|
|
82
|
+
if (!payload)
|
|
83
|
+
return null;
|
|
84
|
+
try {
|
|
85
|
+
const exp = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))?.exp;
|
|
86
|
+
return typeof exp === "number" ? exp * 1000 : null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function codexAuthStatus(name, raw, now) {
|
|
93
|
+
const base = { provider: "codex", name, keptFreshBy: "codex (refreshes the login when it runs)" };
|
|
94
|
+
let auth = null;
|
|
95
|
+
try {
|
|
96
|
+
auth = raw ? JSON.parse(raw) : null;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
auth = null;
|
|
100
|
+
}
|
|
101
|
+
if (!auth) {
|
|
102
|
+
return { ...base, state: raw ? "unknown" : "sign-in-needed", expiresAt: null, lastRefreshAt: null, detail: raw ? "auth.json is not readable JSON." : "No stored login for this profile." };
|
|
103
|
+
}
|
|
104
|
+
const last = typeof auth.last_refresh === "string" && !isNaN(Date.parse(auth.last_refresh)) ? new Date(auth.last_refresh).toISOString() : null;
|
|
105
|
+
if (typeof auth.OPENAI_API_KEY === "string" && auth.OPENAI_API_KEY) {
|
|
106
|
+
return { ...base, state: "connected", expiresAt: null, lastRefreshAt: last, keptFreshBy: "not needed (API key)", detail: "Signed in with an API key, billed per use." };
|
|
107
|
+
}
|
|
108
|
+
if (typeof auth?.tokens?.refresh_token !== "string" || !auth.tokens.refresh_token) {
|
|
109
|
+
return { ...base, state: "sign-in-needed", expiresAt: null, lastRefreshAt: last, detail: "No refreshable login stored." };
|
|
110
|
+
}
|
|
111
|
+
const accessEnds = jwtExpMs(auth.tokens.access_token);
|
|
112
|
+
if (accessEnds !== null && accessEnds <= now) {
|
|
113
|
+
return {
|
|
114
|
+
...base,
|
|
115
|
+
state: "unknown",
|
|
116
|
+
expiresAt: null,
|
|
117
|
+
lastRefreshAt: last,
|
|
118
|
+
detail: `The access token ran out on ${new Date(accessEnds).toISOString().slice(0, 10)}; codex renews it the next time it runs on this account, if the login is still valid. Usage can't be read until then.`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
...base,
|
|
123
|
+
state: windowState(null, now),
|
|
124
|
+
expiresAt: null,
|
|
125
|
+
lastRefreshAt: last,
|
|
126
|
+
detail: "A refreshable login is stored; codex does not record when it ends.",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Antigravity keeps its login in a per-profile keychain agent-switch does not read here. */
|
|
130
|
+
export function antigravityAuthStatus(name, keychainExists) {
|
|
131
|
+
return {
|
|
132
|
+
provider: "antigravity",
|
|
133
|
+
name,
|
|
134
|
+
state: keychainExists ? "unknown" : "sign-in-needed",
|
|
135
|
+
expiresAt: null,
|
|
136
|
+
lastRefreshAt: null,
|
|
137
|
+
keptFreshBy: "agy (refreshes the login when it runs)",
|
|
138
|
+
detail: keychainExists ? "The login lives in the profile's keychain; its expiry is not readable from here." : "No profile keychain yet — sign in once with agy.",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export function fileMtimeMs(p) {
|
|
142
|
+
try {
|
|
143
|
+
return fs.statSync(p).mtimeMs;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function readText(p) {
|
|
150
|
+
try {
|
|
151
|
+
return fs.readFileSync(p, "utf8");
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export function claudeCredentialFile(cfg) {
|
|
158
|
+
return path.join(cfg, ".credentials.json");
|
|
159
|
+
}
|
package/dist/balance.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
// 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 };
|
|
41
|
+
}
|
|
42
|
+
const WINDOW_NAME = { five_hour: "5h", seven_day: "7d", monthly: "30d" };
|
|
43
|
+
/** Named by key: a window read back from the usage history carries its key as label. */
|
|
44
|
+
function windowName(w) {
|
|
45
|
+
return WINDOW_NAME[w.key] ?? w.label;
|
|
46
|
+
}
|
|
47
|
+
export function level(a, b) {
|
|
48
|
+
const hi = Math.max(a, b);
|
|
49
|
+
return hi === 0 || Math.abs(a - b) / hi <= LEVEL_TOLERANCE;
|
|
50
|
+
}
|
|
51
|
+
export function describe(name, w) {
|
|
52
|
+
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`;
|
|
53
|
+
const of = w.key === "seven_day" ? "the week" : `its ${w.window} window`;
|
|
54
|
+
return `${name}: ${Math.round(w.rest)}% of ${of} left, ${reset} (${w.need.toFixed(1)} %/h to use it up)`;
|
|
55
|
+
}
|
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/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,20 +13,26 @@
|
|
|
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, sharesDefaultLogin } from "./profiles.js";
|
|
20
30
|
import { profileDir } from "./profiles.js";
|
|
21
31
|
import { fetchUsage, liveSessionPids } from "./api.js";
|
|
22
|
-
import { defaultFreshenDeps,
|
|
32
|
+
import { defaultFreshenDeps, usageToken } from "./token-freshen.js";
|
|
23
33
|
import { parseUsage, detectCrossings, pickSwitchTarget, maxUtilization } from "./usage.js";
|
|
24
34
|
import { readCodexUsage } from "./codex-usage.js";
|
|
25
|
-
import {
|
|
26
|
-
import { recordHistorySample } from "./history.js";
|
|
35
|
+
import { LATEST_FILE, recordHistorySample, writeLatestSnapshot } from "./history.js";
|
|
27
36
|
import { appendNotification, markOsNotified } from "./notifications.js";
|
|
28
37
|
import { readOsNotifications } from "./profiles.js";
|
|
29
38
|
import { osNotify } from "./os-notify.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() {
|
|
@@ -246,19 +295,27 @@ async function snapshotFor(provider, name) {
|
|
|
246
295
|
return readCodexUsage(configDir("codex", name));
|
|
247
296
|
// An expired access token is the daemon's most common "usage stopped moving"
|
|
248
297
|
// cause — a profile nobody is running never refreshes itself. Let Claude Code
|
|
249
|
-
// refresh it first (cooldown-guarded, no-op while the token is still good)
|
|
250
|
-
|
|
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));
|
|
251
301
|
if (!token)
|
|
252
302
|
return null;
|
|
253
303
|
const raw = await fetchUsage(token);
|
|
254
304
|
return raw ? parseUsage(raw) : null;
|
|
255
305
|
}
|
|
256
|
-
async function pollProvider(provider, state, thresholds,
|
|
257
|
-
const names = listProfiles(provider);
|
|
306
|
+
async function pollProvider(provider, state, thresholds, notifiedReset, log) {
|
|
307
|
+
const names = listProfiles(provider).filter((n) => !isDisabled(provider, n));
|
|
258
308
|
const active = activeFor(provider);
|
|
259
309
|
const autoSwitch = readAutoSwitch(provider);
|
|
260
310
|
const isLive = (n) => (provider === "claude" ? liveSessionPids(configDir("claude", n)).length > 0 : false);
|
|
261
|
-
|
|
311
|
+
// Every start without a folder rule is balanced across all enabled accounts
|
|
312
|
+
// (dir-resolve.ts), so each one needs fresh numbers — at the pool cadence.
|
|
313
|
+
const pooled = names;
|
|
314
|
+
const lastCaptured = (n) => {
|
|
315
|
+
const t = Date.parse(state.profiles[`${provider}/${n}`]?.capturedAt ?? "");
|
|
316
|
+
return isNaN(t) ? null : t;
|
|
317
|
+
};
|
|
318
|
+
const targets = selectPollTargets(names, active, isLive, autoSwitch.enabled, pooled, lastCaptured);
|
|
262
319
|
let failures = 0;
|
|
263
320
|
const polled = [];
|
|
264
321
|
state.notifiedFetchFail = state.notifiedFetchFail ?? {};
|
|
@@ -286,7 +343,17 @@ async function pollProvider(provider, state, thresholds, redeemed, log) {
|
|
|
286
343
|
}
|
|
287
344
|
state.profiles[key] = snapshot;
|
|
288
345
|
polled.push({ name, snapshot });
|
|
346
|
+
// Session protection (per profile, default on at 98 %): a warning only.
|
|
347
|
+
const pNotice = protectionNotice(key, snapshot, readProtection(provider, name), state.protectionNotified?.[key]);
|
|
348
|
+
if (pNotice) {
|
|
349
|
+
state.protectionNotified = { ...(state.protectionNotified ?? {}), [key]: pNotice.cycle };
|
|
350
|
+
log(`protection: ${pNotice.message}`);
|
|
351
|
+
// "info", not "warning": the GUI mutes warnings by default (fetch-failure
|
|
352
|
+
// noise), and a default-on protection notice must actually be seen.
|
|
353
|
+
notify({ kind: "info", title: pNotice.title, message: pNotice.message });
|
|
354
|
+
}
|
|
289
355
|
recordHistorySample(path.join(profileDir(provider, name), "usage-history.json"), snapshot);
|
|
356
|
+
writeLatestSnapshot(path.join(profileDir(provider, name), LATEST_FILE), snapshot);
|
|
290
357
|
if (name === active) {
|
|
291
358
|
const prev = thresholds.get(`${provider}/${name}`) ?? {};
|
|
292
359
|
const { crossings, state: next } = detectCrossings(snapshot, prev);
|
|
@@ -311,28 +378,15 @@ async function pollProvider(provider, state, thresholds, redeemed, log) {
|
|
|
311
378
|
const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
|
|
312
379
|
if (activeMax === null || activeMax < autoSwitch.threshold)
|
|
313
380
|
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.
|
|
381
|
+
// Codex only (banked resets are a Codex feature): a reset credit is scarce and
|
|
382
|
+
// redeeming it is the user's call (D3) — the daemon only says one is there,
|
|
383
|
+
// once per reset cycle, and never calls the redeem endpoint itself.
|
|
319
384
|
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`);
|
|
385
|
+
const notice = codexResetNotice(active, activeSnap, autoSwitch.threshold, notifiedReset.get(`codex/${active}`));
|
|
386
|
+
if (notice) {
|
|
387
|
+
notifiedReset.set(`codex/${active}`, notice.cycle);
|
|
388
|
+
log(`threshold: codex/${active} over ≥${autoSwitch.threshold}% with a banked reset available → notifying (no automatic redeem)`);
|
|
389
|
+
notify({ kind: "info", title: notice.title, message: notice.message });
|
|
336
390
|
}
|
|
337
391
|
}
|
|
338
392
|
// Tag filter: only accounts carrying the configured label are eligible switch
|
|
@@ -361,10 +415,10 @@ async function pollProvider(provider, state, thresholds, redeemed, log) {
|
|
|
361
415
|
}
|
|
362
416
|
return failures;
|
|
363
417
|
}
|
|
364
|
-
async function pollOnce(state, thresholds,
|
|
418
|
+
async function pollOnce(state, thresholds, notifiedReset, log) {
|
|
365
419
|
let failures = 0;
|
|
366
420
|
for (const provider of AUTO_PROVIDERS) {
|
|
367
|
-
failures += await pollProvider(provider, state, thresholds,
|
|
421
|
+
failures += await pollProvider(provider, state, thresholds, notifiedReset, log);
|
|
368
422
|
}
|
|
369
423
|
state.lastPoll = new Date().toISOString();
|
|
370
424
|
state.lastError = failures > 0 ? `${failures} profile(s) failed to poll` : null;
|
|
@@ -396,16 +450,15 @@ export async function runDaemon(opts = {}) {
|
|
|
396
450
|
// Load persisted usage-window fired state so crossings do NOT re-fire after a
|
|
397
451
|
// daemon restart within the same window cycle (the pre-existing in-memory gap).
|
|
398
452
|
const thresholds = new Map(Object.entries(state.usageThresholds ?? {}));
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
const redeemed = new Map();
|
|
453
|
+
// `<provider>/<name>` → the window resets_at we last announced a banked reset
|
|
454
|
+
// for, so the "reset available" notice fires once per cycle, not every poll.
|
|
455
|
+
const notifiedReset = new Map();
|
|
403
456
|
let consecutiveFailures = 0;
|
|
404
457
|
let cycles = 0;
|
|
405
458
|
log(`started (pid ${process.pid}, base interval ${baseInterval}ms).`);
|
|
406
459
|
while (running) {
|
|
407
460
|
try {
|
|
408
|
-
const failures = await pollOnce(state, thresholds,
|
|
461
|
+
const failures = await pollOnce(state, thresholds, notifiedReset, log);
|
|
409
462
|
consecutiveFailures = failures > 0 ? consecutiveFailures + 1 : 0;
|
|
410
463
|
}
|
|
411
464
|
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
|
+
}
|