@event4u/agent-switch 1.7.0 → 2.0.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 +3 -1
- package/dist/api.js +5 -4
- package/dist/codex-reset.js +2 -2
- package/dist/config-transfer.js +184 -0
- package/dist/credentials.js +6 -4
- package/dist/daemon.js +68 -34
- package/dist/gui-launch.js +45 -3
- package/dist/history.js +23 -0
- package/dist/hooks.js +5 -2
- package/dist/index.js +233 -51
- package/dist/keychain.js +14 -0
- package/dist/notifications.js +7 -2
- package/dist/profiles.js +26 -14
- package/dist/rebind-dialog.js +65 -0
- package/dist/rebind.js +715 -0
- package/dist/token-freshen.js +154 -0
- package/dist/usage.js +8 -7
- package/package.json +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep a Claude profile's OAuth access token usable for the read-only usage /
|
|
3
|
+
* profile endpoints — WITHOUT agent-switch ever touching the credential store.
|
|
4
|
+
*
|
|
5
|
+
* The problem: a stored access token lives ~8h, the refresh token ~3 weeks.
|
|
6
|
+
* agent-switch reads the credential and calls the OAuth endpoints with whatever
|
|
7
|
+
* access token is in the store. Once that token expires the endpoints 401 and
|
|
8
|
+
* usage silently freezes on the last cached numbers — for exactly the profiles
|
|
9
|
+
* the operator is NOT actively using, which is the whole point of the readout.
|
|
10
|
+
* Starting Claude Code on the profile "fixes" it, because Claude Code refreshes.
|
|
11
|
+
*
|
|
12
|
+
* The mechanism: let Claude Code do the refresh. `claude doctor` is a LOCAL
|
|
13
|
+
* health check — it runs no completion and consumes no quota — but it goes
|
|
14
|
+
* through Claude Code's auth path, so an expired token is refreshed first,
|
|
15
|
+
* under Claude Code's own lock, into Claude Code's own store. Verified against
|
|
16
|
+
* CC 2.1.x (2026-07-25): with `expiresAt` in the past, `claude doctor` rotates
|
|
17
|
+
* BOTH the access and the refresh token and moves `expiresAt` ~8h out.
|
|
18
|
+
*
|
|
19
|
+
* Why not the refresh grant ourselves: that rotation is precisely the hazard the
|
|
20
|
+
* Phase-4 lock recorded — an out-of-band grant invalidates the refresh token a
|
|
21
|
+
* live session still holds. Delegating honours that lock instead of reopening
|
|
22
|
+
* it: agent-switch stays read-only outside `rebind` (ADR-003), and the one
|
|
23
|
+
* process allowed to rotate the token family is the one that owns it. Full
|
|
24
|
+
* rationale, evidence, and the rejected alternatives: ADR-004.
|
|
25
|
+
*/
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
27
|
+
import * as crypto from "node:crypto";
|
|
28
|
+
import * as fs from "node:fs";
|
|
29
|
+
import * as os from "node:os";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import { accessTokenOf, readProfileCredential } from "./api.js";
|
|
32
|
+
import { ROOT } from "./profiles.js";
|
|
33
|
+
import { resolveBinary } from "./providers.js";
|
|
34
|
+
/** Treat a token as spent this close to `expiresAt` — mirrors Claude Code's own
|
|
35
|
+
* 5-minute refresh buffer, so we never race it into a just-expired window. */
|
|
36
|
+
export const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
37
|
+
/** Minimum gap between two freshen attempts for one profile. A truly dead login
|
|
38
|
+
* (refresh token expired / revoked) cannot be healed by retrying, and the GUI
|
|
39
|
+
* re-reads usage every few seconds — without this floor a dead profile would
|
|
40
|
+
* spawn `claude doctor` on every cycle. */
|
|
41
|
+
export const FRESHEN_COOLDOWN_MS = 10 * 60 * 1000;
|
|
42
|
+
/** How long to let Claude Code's health check run before giving up on it. The
|
|
43
|
+
* observed run is a few seconds; this is a stuck-process guard, and it bounds
|
|
44
|
+
* how long a daemon poll cycle can stall on one profile. */
|
|
45
|
+
const FRESHEN_TIMEOUT_MS = 30_000;
|
|
46
|
+
/** `expiresAt` (epoch ms) out of a credential blob; null when absent/unparseable. */
|
|
47
|
+
export function tokenExpiresAt(credentials) {
|
|
48
|
+
if (!credentials)
|
|
49
|
+
return null;
|
|
50
|
+
try {
|
|
51
|
+
const v = JSON.parse(credentials)?.claudeAiOauth?.expiresAt;
|
|
52
|
+
return typeof v === "number" ? v : null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Is this credential's access token spent (or about to be)? Pure — the testable
|
|
60
|
+
* core. A credential we cannot read or whose `expiresAt` we cannot parse is NOT
|
|
61
|
+
* reported as expired: we have no evidence it is, and spawning Claude Code on a
|
|
62
|
+
* guess is worse than one failed read.
|
|
63
|
+
*/
|
|
64
|
+
export function needsFreshen(credentials, now, bufferMs = EXPIRY_BUFFER_MS) {
|
|
65
|
+
const exp = tokenExpiresAt(credentials);
|
|
66
|
+
if (exp === null)
|
|
67
|
+
return false;
|
|
68
|
+
return exp - now <= bufferMs;
|
|
69
|
+
}
|
|
70
|
+
/** The attempt-stamp file for a config dir. Keyed by hash so the path stays
|
|
71
|
+
* flat and filesystem-safe, and lives in agent-switch's own root — never
|
|
72
|
+
* inside Claude Code's config dir. */
|
|
73
|
+
function attemptStampFile(configDir) {
|
|
74
|
+
const digest = crypto.createHash("sha256").update(configDir.normalize("NFC"), "utf8").digest("hex").slice(0, 12);
|
|
75
|
+
return path.join(ROOT, "freshen", `${digest}`);
|
|
76
|
+
}
|
|
77
|
+
/** Cooldown state is a FILE, not a module variable: the GUI reads usage by
|
|
78
|
+
* spawning a fresh `agent-switch status --json` per profile, so an in-process
|
|
79
|
+
* map would reset on every read and defeat the floor entirely. */
|
|
80
|
+
function readLastAttemptFile(configDir) {
|
|
81
|
+
try {
|
|
82
|
+
const n = Number(fs.readFileSync(attemptStampFile(configDir), "utf8").trim());
|
|
83
|
+
return Number.isFinite(n) ? n : 0;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function recordAttemptFile(configDir, at) {
|
|
90
|
+
const file = attemptStampFile(configDir);
|
|
91
|
+
try {
|
|
92
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
93
|
+
fs.writeFileSync(file, String(at));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* best-effort: a missing stamp only costs an extra attempt */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* `claude doctor` against one profile's config dir. stdout/stderr are dropped —
|
|
101
|
+
* this is a side-effect call, not a readout. Any completed run returns true;
|
|
102
|
+
* whether the token actually got refreshed is decided by re-reading the store.
|
|
103
|
+
*/
|
|
104
|
+
function runClaudeDoctor(configDir, linkedBinary) {
|
|
105
|
+
const res = spawnSync(resolveBinary("claude", linkedBinary), ["doctor"], {
|
|
106
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
|
|
107
|
+
// Claude Code reads settings from the CWD; run it somewhere neutral so a
|
|
108
|
+
// project's config can never influence a background health check.
|
|
109
|
+
cwd: os.tmpdir(),
|
|
110
|
+
stdio: "ignore",
|
|
111
|
+
timeout: FRESHEN_TIMEOUT_MS,
|
|
112
|
+
});
|
|
113
|
+
if (res.error)
|
|
114
|
+
return false;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
export function defaultFreshenDeps(linkedBinary = null) {
|
|
118
|
+
return {
|
|
119
|
+
now: () => Date.now(),
|
|
120
|
+
readCredential: readProfileCredential,
|
|
121
|
+
readLastAttempt: readLastAttemptFile,
|
|
122
|
+
recordAttempt: recordAttemptFile,
|
|
123
|
+
runHealthCheck: (configDir) => runClaudeDoctor(configDir, linkedBinary),
|
|
124
|
+
cooldownMs: FRESHEN_COOLDOWN_MS,
|
|
125
|
+
bufferMs: EXPIRY_BUFFER_MS,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Make sure the store behind `configDir` holds a usable access token, letting
|
|
130
|
+
* Claude Code refresh it when it does not. Idempotent, cooldown-guarded, and a
|
|
131
|
+
* no-op when the token is still good.
|
|
132
|
+
*/
|
|
133
|
+
export function freshenToken(configDir, deps = defaultFreshenDeps()) {
|
|
134
|
+
const creds = deps.readCredential(configDir);
|
|
135
|
+
if (!needsFreshen(creds, deps.now(), deps.bufferMs))
|
|
136
|
+
return "not-needed";
|
|
137
|
+
if (deps.now() - deps.readLastAttempt(configDir) < deps.cooldownMs)
|
|
138
|
+
return "cooling-down";
|
|
139
|
+
deps.recordAttempt(configDir, deps.now());
|
|
140
|
+
if (!deps.runHealthCheck(configDir))
|
|
141
|
+
return "unavailable";
|
|
142
|
+
return needsFreshen(deps.readCredential(configDir), deps.now(), deps.bufferMs) ? "still-expired" : "refreshed";
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The access token to call the OAuth endpoints with — freshened first when it
|
|
146
|
+
* has expired. Returns whatever the store holds afterwards (null when there is
|
|
147
|
+
* no readable credential at all); a freshen that could not heal the login just
|
|
148
|
+
* means the caller's read fails as it did before.
|
|
149
|
+
*/
|
|
150
|
+
export function freshAccessToken(configDir, deps = defaultFreshenDeps()) {
|
|
151
|
+
freshenToken(configDir, deps);
|
|
152
|
+
const creds = deps.readCredential(configDir);
|
|
153
|
+
return creds ? accessTokenOf(creds) : null;
|
|
154
|
+
}
|
package/dist/usage.js
CHANGED
|
@@ -151,13 +151,14 @@ export function maxUtilization(s) {
|
|
|
151
151
|
return vals.length ? Math.max(...vals) : null;
|
|
152
152
|
}
|
|
153
153
|
/**
|
|
154
|
-
* Opt-in
|
|
155
|
-
* same-provider candidates with their usage, return the profile to
|
|
156
|
-
* or null.
|
|
157
|
-
* window AND another candidate has strictly more headroom
|
|
158
|
-
* utilization) while itself staying below `threshold`. Ties keep the
|
|
159
|
-
* candidate. This
|
|
160
|
-
*
|
|
154
|
+
* Opt-in switch SUGGESTION (pure, no I/O). Given the active profile and the
|
|
155
|
+
* same-provider candidates with their usage, return the profile to suggest
|
|
156
|
+
* switching to, or null. Suggests only when the active profile has hit
|
|
157
|
+
* `threshold`% on some window AND another candidate has strictly more headroom
|
|
158
|
+
* (lower max utilization) while itself staying below `threshold`. Ties keep the
|
|
159
|
+
* first candidate. This computes the suggestion the daemon notifies with and the
|
|
160
|
+
* user-clicked switch modal pre-selects — it never performs the switch itself,
|
|
161
|
+
* and is never a display ranking.
|
|
161
162
|
*/
|
|
162
163
|
export function pickSwitchTarget(active, candidates, threshold, isEligible = () => true) {
|
|
163
164
|
const activeSnap = candidates.find((c) => c.name === active)?.snapshot;
|
package/package.json
CHANGED