@event4u/agent-switch 1.7.0 → 2.1.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 +36 -1
- package/dist/api.js +5 -4
- package/dist/codex-reset.js +2 -2
- package/dist/config-transfer.js +194 -0
- package/dist/credentials.js +6 -4
- package/dist/daemon.js +68 -34
- package/dist/doctor.js +23 -2
- package/dist/gui-launch.js +45 -3
- package/dist/history.js +23 -0
- package/dist/hooks.js +5 -2
- package/dist/index.js +475 -72
- package/dist/keychain.js +14 -0
- package/dist/notifications.js +7 -2
- package/dist/profiles.js +42 -14
- package/dist/providers.js +3 -0
- package/dist/rebind-dialog.js +65 -0
- package/dist/rebind.js +715 -0
- package/dist/release-notes.js +154 -0
- package/dist/sessions.js +19 -0
- package/dist/shellenv.js +38 -4
- package/dist/single-load.js +171 -0
- package/dist/token-freshen.js +154 -0
- package/dist/tooling.js +73 -0
- package/dist/updates.js +37 -3
- package/dist/usage.js +8 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,39 @@ Most naive switchers snapshot the macOS Keychain entry (`Claude Code-credentials
|
|
|
29
29
|
|
|
30
30
|
The same restraint governs the whole tool: no proxy, no traffic interception, no fingerprinting, no prompt rewriting — see [NON-GOALS.md](NON-GOALS.md) for the explicit list and the alternatives this ecosystem offers instead.
|
|
31
31
|
|
|
32
|
+
## One profile, one session
|
|
33
|
+
|
|
34
|
+
Because `agent-switch use` sets a **global** pointer that every shell re-reads,
|
|
35
|
+
nothing used to stop two terminals from starting `claude` on the same account —
|
|
36
|
+
sharing one set of rate limits and both refreshing one rotating OAuth token,
|
|
37
|
+
while each terminal looked like a separate account.
|
|
38
|
+
|
|
39
|
+
The single-load guard refuses that second start and says which session already
|
|
40
|
+
holds the profile and which accounts are free:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
$ claude
|
|
44
|
+
agent-switch: profile "work" is already loaded by a running claude session.
|
|
45
|
+
|
|
46
|
+
pid 41234 started 12m ago ~/projects/foo
|
|
47
|
+
|
|
48
|
+
Two sessions on one account share its rate limits, and both refresh the
|
|
49
|
+
same rotating OAuth token.
|
|
50
|
+
|
|
51
|
+
Ways forward:
|
|
52
|
+
agent-switch use privat switch to a free account — also free: event4u
|
|
53
|
+
AGENT_SWITCH_ALLOW_DUPLICATE=1 claude start it anyway, this once
|
|
54
|
+
agent-switch guard warn warn instead of refusing, from now on
|
|
55
|
+
agent-switch guard off turn the guard off entirely
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
It never gets in the way where it has no business being: a `claude` started
|
|
59
|
+
*inside* a running session is a child, not a duplicate; a crashed session leaves
|
|
60
|
+
nothing behind that can lock you out; and if `agent-switch` itself is missing or
|
|
61
|
+
broken, the wrapper falls straight through to the real binary. Set the policy
|
|
62
|
+
with `agent-switch guard <block|warn|off>` — see the
|
|
63
|
+
[CLI reference](https://event4u-app.github.io/agent-switch/reference/cli/#single-load-guard).
|
|
64
|
+
|
|
32
65
|
## Install
|
|
33
66
|
|
|
34
67
|
**npm / npx** (any OS — needs Node ≥ 20). The package is published as
|
|
@@ -313,7 +346,9 @@ The CLI is fully usable on its own. Two optional layers add convenience:
|
|
|
313
346
|
- **Tray/menubar GUI** (`gui/`, Tauri) — a small panel to see per-profile usage
|
|
314
347
|
and switch/open sessions. It is a **client of the CLI** (`agent-switch <cmd>
|
|
315
348
|
--json`) and never re-implements profile logic; the CLI core stays
|
|
316
|
-
dependency-free. See [`gui/README.md`](gui/README.md).
|
|
349
|
+
dependency-free. See [`gui/README.md`](gui/README.md). Running `agent-switch`
|
|
350
|
+
**with no command** launches it (same as `agent-switch gui`); if the GUI is
|
|
351
|
+
already running, no second copy starts.
|
|
317
352
|
|
|
318
353
|
Usage readout is per-provider: Claude exposes an OAuth `/usage` endpoint;
|
|
319
354
|
**Codex and Gemini have no usage readout**, so they show identity only (never a
|
package/dist/api.js
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* both with `Authorization: Bearer <accessToken>` and the
|
|
8
8
|
* `anthropic-beta: oauth-2025-04-20` header.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* This module never refreshes a token. An out-of-band refresh grant rotates the
|
|
11
|
+
* refresh token underneath a running session and would strand Claude Code with
|
|
12
|
+
* a dead one (ADR-004 § Evidence — the rotation is confirmed, not theoretical).
|
|
13
|
+
* A spent access token is instead handed to `token-freshen.ts`, which lets
|
|
14
|
+
* Claude Code refresh its own store; this stays the low-level fetch layer.
|
|
14
15
|
*/
|
|
15
16
|
import * as fs from "node:fs";
|
|
16
17
|
import * as path from "node:path";
|
package/dist/codex-reset.js
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* SAFETY: a reset credit is scarce and non-refundable, and redeeming is known to
|
|
15
15
|
* be occasionally buggy upstream. This module only redeems when the caller asks;
|
|
16
|
-
* the daemon's reset-
|
|
17
|
-
* loop can never burn a user's whole balance. The redeem call itself cannot be
|
|
16
|
+
* the daemon's reset-redemption path guards it to at most once per reset cycle so
|
|
17
|
+
* a loop can never burn a user's whole balance. The redeem call itself cannot be
|
|
18
18
|
* exercised in CI (it would consume a real credit) — the request construction is
|
|
19
19
|
* unit-tested and it is validated live the first time a limit is hit.
|
|
20
20
|
*/
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-only cross-machine export/import (SAFE subset).
|
|
3
|
+
*
|
|
4
|
+
* Moves a profile's SHARED config — settings.json, keybindings.json, CLAUDE.md,
|
|
5
|
+
* and the skills/, commands/, agents/ trees — between machines as a single
|
|
6
|
+
* self-describing JSON bundle (base64-encoded file bodies). It handles NO
|
|
7
|
+
* credentials: the allowlist is the exact `SHARED_ITEMS` set from `share.ts`,
|
|
8
|
+
* and the account-scoped deny-set (`.claude.json`, `.credentials.json`,
|
|
9
|
+
* `plugins/`, `sessions/`, `ide/`, `statsig/`) is excluded by construction —
|
|
10
|
+
* we only ever walk allowlisted top-level names, and both export and import
|
|
11
|
+
* additionally assert the top-level segment against the allowlist/deny-set.
|
|
12
|
+
*
|
|
13
|
+
* A `--full` variant that would bundle live OAuth tokens is deliberately NOT
|
|
14
|
+
* implemented (see {@link assertNotFull}) — it is an account-takeover vector.
|
|
15
|
+
*
|
|
16
|
+
* Threat-model controls implemented here (untrusted bundle on import):
|
|
17
|
+
* - path shape guard: no absolute paths, no `..` traversal, no empty/`.`
|
|
18
|
+
* segments, top-level segment must be an allowlisted `SHARED_ITEMS` name.
|
|
19
|
+
* - path-confinement (zip-slip): the resolved real path must stay inside the
|
|
20
|
+
* target config dir.
|
|
21
|
+
* - bounded input: at most {@link MAX_ENTRIES} entries and {@link MAX_FILE_BYTES}
|
|
22
|
+
* per decoded file.
|
|
23
|
+
* - deny-set is never written even if present in the bundle (rejected).
|
|
24
|
+
*/
|
|
25
|
+
import * as fs from "node:fs";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
import { SHARED_ITEMS } from "./share.js";
|
|
28
|
+
/** The single source of the allowlist — the same names `share.ts` links. */
|
|
29
|
+
const ALLOW = new Set(SHARED_ITEMS);
|
|
30
|
+
/** Account-/instance-scoped top-level names that must never be in a bundle.
|
|
31
|
+
* Kept in sync with the deny-set documented in `share.ts`'s header. Disjoint
|
|
32
|
+
* from ALLOW, so `!ALLOW.has(top)` already excludes these — asserted anyway
|
|
33
|
+
* for a clearer error and defense-in-depth. */
|
|
34
|
+
const DENY_SET = new Set([".claude.json", ".credentials.json", "plugins", "sessions", "ide", "statsig"]);
|
|
35
|
+
/** Bound untrusted input: refuse a bundle with more entries than this. */
|
|
36
|
+
export const MAX_ENTRIES = 5000;
|
|
37
|
+
/** Bound untrusted input: refuse a single decoded file larger than this. */
|
|
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. " +
|
|
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.";
|
|
41
|
+
/** Refuse the `--full` (credential-bundling) variant. Throws so callers can
|
|
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. */
|
|
52
|
+
export function assertNotFull(full) {
|
|
53
|
+
if (full)
|
|
54
|
+
throw new Error(FULL_REFUSAL);
|
|
55
|
+
}
|
|
56
|
+
function toPosix(p) {
|
|
57
|
+
return p.split(path.sep).join("/");
|
|
58
|
+
}
|
|
59
|
+
function topSegment(relPosix) {
|
|
60
|
+
return relPosix.split("/")[0];
|
|
61
|
+
}
|
|
62
|
+
/** Add one file's body to the bundle, keyed by its POSIX-relative path. The
|
|
63
|
+
* top-level allowlist/deny-set assertion is redundant (we only walk allowlisted
|
|
64
|
+
* trees) but enforced regardless. */
|
|
65
|
+
function addFile(root, rel, out) {
|
|
66
|
+
const posix = toPosix(rel);
|
|
67
|
+
const top = topSegment(posix);
|
|
68
|
+
if (DENY_SET.has(top) || !ALLOW.has(top))
|
|
69
|
+
return; // excluded by construction — assert anyway
|
|
70
|
+
out[posix] = fs.readFileSync(path.join(root, rel)).toString("base64");
|
|
71
|
+
}
|
|
72
|
+
/** Recursively collect files under an allowlisted directory tree. Follows
|
|
73
|
+
* symlinks to their real target (a `share on` link) so the bundle carries
|
|
74
|
+
* content, not a link we cannot reproduce on another machine; skips dangling
|
|
75
|
+
* links. */
|
|
76
|
+
function walkDir(root, relDir, out) {
|
|
77
|
+
const absDir = path.join(root, relDir);
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const rel = `${relDir}/${entry.name}`;
|
|
87
|
+
const abs = path.join(root, rel);
|
|
88
|
+
let st;
|
|
89
|
+
try {
|
|
90
|
+
st = fs.statSync(abs); // statSync (not lstat) follows a shared-mode link to its target
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue; // dangling / unreadable
|
|
94
|
+
}
|
|
95
|
+
if (st.isDirectory())
|
|
96
|
+
walkDir(root, rel, out);
|
|
97
|
+
else if (st.isFile())
|
|
98
|
+
addFile(root, rel, out);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Walk ONLY the SHARED_ITEMS allowlist (files + directory trees, recursively)
|
|
103
|
+
* under `configDir`, base64-encode each file, and return a JSON bundle string.
|
|
104
|
+
* The deny-set is never read (we only walk allowlisted names). Absent items are
|
|
105
|
+
* skipped.
|
|
106
|
+
*/
|
|
107
|
+
export function exportConfig(configDir) {
|
|
108
|
+
const files = {};
|
|
109
|
+
for (const name of SHARED_ITEMS) {
|
|
110
|
+
const abs = path.join(configDir, name);
|
|
111
|
+
let st;
|
|
112
|
+
try {
|
|
113
|
+
st = fs.statSync(abs); // follow a top-level shared-mode link to its target
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue; // absent / dangling
|
|
117
|
+
}
|
|
118
|
+
if (st.isFile())
|
|
119
|
+
addFile(configDir, name, files);
|
|
120
|
+
else if (st.isDirectory())
|
|
121
|
+
walkDir(configDir, name, files);
|
|
122
|
+
}
|
|
123
|
+
const bundle = { version: 1, files };
|
|
124
|
+
return JSON.stringify(bundle);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Parse a config bundle and write its config-only files under `configDir`.
|
|
128
|
+
* Returns the list of written relative paths. Rejects (throws) on any control
|
|
129
|
+
* violation — an absolute path, `..` traversal, a non-allowlisted or deny-set
|
|
130
|
+
* top-level segment, a path escaping the config dir, or an over-limit bundle.
|
|
131
|
+
* Never writes a credential/deny-set path even if present.
|
|
132
|
+
*/
|
|
133
|
+
export function importConfig(configDir, bundleJson) {
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(bundleJson);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw new Error("invalid config bundle: not valid JSON");
|
|
140
|
+
}
|
|
141
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
142
|
+
throw new Error("invalid config bundle: expected an object");
|
|
143
|
+
}
|
|
144
|
+
const b = parsed;
|
|
145
|
+
if (b.version !== 1)
|
|
146
|
+
throw new Error(`unsupported config bundle version ${String(b.version)} (expected 1)`);
|
|
147
|
+
if (!b.files || typeof b.files !== "object" || Array.isArray(b.files)) {
|
|
148
|
+
throw new Error("invalid config bundle: missing 'files' map");
|
|
149
|
+
}
|
|
150
|
+
const entries = Object.entries(b.files);
|
|
151
|
+
if (entries.length > MAX_ENTRIES) {
|
|
152
|
+
throw new Error(`config bundle has ${entries.length} entries (> ${MAX_ENTRIES}) — refusing`);
|
|
153
|
+
}
|
|
154
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
155
|
+
const root = fs.realpathSync(configDir); // resolve symlinks once for the confinement check
|
|
156
|
+
const coarseB64Limit = 4 * Math.ceil(MAX_FILE_BYTES / 3) + 4;
|
|
157
|
+
const written = [];
|
|
158
|
+
for (const [rawPath, value] of entries) {
|
|
159
|
+
if (typeof value !== "string")
|
|
160
|
+
throw new Error(`invalid bundle entry "${rawPath}": value is not a base64 string`);
|
|
161
|
+
// (a) path shape guards — normalize any backslashes to POSIX for the checks.
|
|
162
|
+
const rel = rawPath.replace(/\\/g, "/");
|
|
163
|
+
if (path.isAbsolute(rawPath) || rel.startsWith("/")) {
|
|
164
|
+
throw new Error(`refusing absolute path in bundle: "${rawPath}"`);
|
|
165
|
+
}
|
|
166
|
+
const segments = rel.split("/");
|
|
167
|
+
if (segments.some((s) => s === ".."))
|
|
168
|
+
throw new Error(`refusing path traversal ('..') in bundle: "${rawPath}"`);
|
|
169
|
+
if (segments.some((s) => s === "" || s === "."))
|
|
170
|
+
throw new Error(`refusing malformed path in bundle: "${rawPath}"`);
|
|
171
|
+
const top = segments[0];
|
|
172
|
+
if (DENY_SET.has(top))
|
|
173
|
+
throw new Error(`refusing deny-set path in bundle: "${rawPath}" (account-scoped, never imported)`);
|
|
174
|
+
if (!ALLOW.has(top))
|
|
175
|
+
throw new Error(`refusing non-allowlisted path in bundle: "${rawPath}"`);
|
|
176
|
+
// bounded input: coarse check on the base64 length before allocating, then
|
|
177
|
+
// a precise check on the decoded size.
|
|
178
|
+
if (value.length > coarseB64Limit)
|
|
179
|
+
throw new Error(`refusing oversized file "${rawPath}" (> ${MAX_FILE_BYTES} bytes)`);
|
|
180
|
+
const buf = Buffer.from(value, "base64");
|
|
181
|
+
if (buf.length > MAX_FILE_BYTES)
|
|
182
|
+
throw new Error(`refusing oversized file "${rawPath}" (> ${MAX_FILE_BYTES} bytes)`);
|
|
183
|
+
// (b) path-confinement / zip-slip: the resolved real path must stay inside root.
|
|
184
|
+
const dest = path.resolve(root, rel);
|
|
185
|
+
if (dest !== root && !dest.startsWith(root + path.sep)) {
|
|
186
|
+
throw new Error(`refusing path escaping the config dir: "${rawPath}"`);
|
|
187
|
+
}
|
|
188
|
+
// (c) write the decoded body (mkdir -p parent).
|
|
189
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
190
|
+
fs.writeFileSync(dest, buf, { mode: 0o600 });
|
|
191
|
+
written.push(rel);
|
|
192
|
+
}
|
|
193
|
+
return written;
|
|
194
|
+
}
|
package/dist/credentials.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* Per-OS credential access — a read-only abstraction over where Claude Code
|
|
3
3
|
* keeps a profile's OAuth credential.
|
|
4
4
|
*
|
|
5
|
-
* By design
|
|
6
|
-
* credential storage
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* By design this store has NO `write()`: agent-switch never writes Claude Code's
|
|
6
|
+
* credential storage **outside the `rebind` path** (ADR-003 — the single
|
|
7
|
+
* sanctioned exception, which writes the macOS Keychain entry directly under
|
|
8
|
+
* Claude Code's lock via `keychain.addPassword`). The only other way to place a
|
|
9
|
+
* credential is seeding a plaintext `.credentials.json` file inside the config
|
|
10
|
+
* dir (see `import`), which Claude Code migrates into its own store on first use.
|
|
9
11
|
*
|
|
10
12
|
* Backends:
|
|
11
13
|
* - darwin → Keychain (hashed service per config dir), then the plaintext
|
package/dist/daemon.js
CHANGED
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Background usage daemon — poller, cache, and (opt-in)
|
|
2
|
+
* Background usage daemon — poller, cache, and (opt-in) threshold notifier.
|
|
3
3
|
*
|
|
4
4
|
* It polls Claude profiles for their OWN usage, appends history, detects
|
|
5
5
|
* active-profile threshold crossings, and writes `daemon-state.json` so
|
|
6
6
|
* `status`/the GUI can read a fresh snapshot without hitting the API. When
|
|
7
|
-
* `autoSwitch.enabled` (default OFF) it also watches every profile and
|
|
8
|
-
* active
|
|
9
|
-
* the
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* `autoSwitch.enabled` (default OFF) it also watches every profile and, once the
|
|
8
|
+
* active one hits the configured threshold, NOTIFIES the operator and SUGGESTS
|
|
9
|
+
* the account with the most headroom — it never switches automatically. The
|
|
10
|
+
* suggestion is a convenience; the actual switch is always a user interaction
|
|
11
|
+
* (see `AutoSwitchConfig`). The daemon never mutates the active profile.
|
|
12
12
|
*
|
|
13
13
|
* The pure helpers (single-instance, poll-target selection, backoff, state
|
|
14
|
-
* freshness,
|
|
14
|
+
* freshness, suggestion decision in `usage.ts`) are exported for unit testing;
|
|
15
15
|
* `runDaemon` is the loop.
|
|
16
16
|
*/
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
18
|
import * as path from "node:path";
|
|
19
|
-
import { ROOT, activeFor, configDir, labelFor, listProfiles, readAutoSwitch,
|
|
19
|
+
import { ROOT, activeFor, configDir, labelFor, listProfiles, readAutoSwitch, readBinaryPath } from "./profiles.js";
|
|
20
20
|
import { profileDir } from "./profiles.js";
|
|
21
|
-
import {
|
|
21
|
+
import { fetchUsage, liveSessionPids } from "./api.js";
|
|
22
|
+
import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
|
|
22
23
|
import { parseUsage, detectCrossings, pickSwitchTarget, maxUtilization } from "./usage.js";
|
|
23
24
|
import { readCodexUsage } from "./codex-usage.js";
|
|
24
25
|
import { redeemResetCredit } from "./codex-reset.js";
|
|
25
|
-
import {
|
|
26
|
+
import { recordHistorySample } from "./history.js";
|
|
26
27
|
import { appendNotification, markOsNotified } from "./notifications.js";
|
|
27
28
|
import { readOsNotifications } from "./profiles.js";
|
|
28
29
|
import { osNotify } from "./os-notify.js";
|
|
@@ -41,8 +42,8 @@ function notify(event) {
|
|
|
41
42
|
markOsNotified(created.id);
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
|
-
// Providers
|
|
45
|
-
// usage readout to base a headroom
|
|
45
|
+
// Providers the daemon may suggest a switch for on threshold (they expose a
|
|
46
|
+
// usage readout to base a headroom suggestion on). Antigravity has none.
|
|
46
47
|
const AUTO_PROVIDERS = ["claude", "codex"];
|
|
47
48
|
export const PIDFILE = path.join(ROOT, "daemon.pid");
|
|
48
49
|
export const STATE_FILE = path.join(ROOT, "daemon-state.json");
|
|
@@ -137,6 +138,26 @@ export function nextIntervalMs(baseMs, consecutiveFailures, capMs = MAX_BACKOFF_
|
|
|
137
138
|
return base;
|
|
138
139
|
return Math.min(base * 2 ** consecutiveFailures, capMs);
|
|
139
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Compliance-locked threshold decision (Council follow-up). Given the active
|
|
143
|
+
* profile and the same-provider candidates polled this cycle, decide whether to
|
|
144
|
+
* NOTIFY and which account to SUGGEST. Pure — no I/O, and crucially NO `setActive`:
|
|
145
|
+
* the daemon never auto-switches. Returns `{ notify: false }` while the active
|
|
146
|
+
* profile still has headroom (or auto-switch is off / there is no active profile).
|
|
147
|
+
* `notify` fires only alongside a concrete suggestion, mirroring the behavior
|
|
148
|
+
* before this decision was extracted from `pollProvider`.
|
|
149
|
+
*/
|
|
150
|
+
export function decideThresholdAction(active, polled, autoSwitch, isEligible = () => true) {
|
|
151
|
+
if (!autoSwitch.enabled || !active)
|
|
152
|
+
return { notify: false, suggestedTarget: null };
|
|
153
|
+
const activeSnap = polled.find((p) => p.name === active)?.snapshot;
|
|
154
|
+
const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
|
|
155
|
+
if (activeMax === null || activeMax < autoSwitch.threshold)
|
|
156
|
+
return { notify: false, suggestedTarget: null };
|
|
157
|
+
const target = pickSwitchTarget(active, polled, autoSwitch.threshold, isEligible);
|
|
158
|
+
const suggestedTarget = target && target !== active ? target : null;
|
|
159
|
+
return { notify: suggestedTarget !== null, suggestedTarget };
|
|
160
|
+
}
|
|
140
161
|
// ---------- context monitoring (own-session; Phase 3) ----------
|
|
141
162
|
let _claudeVer;
|
|
142
163
|
function claudeVer() {
|
|
@@ -223,14 +244,16 @@ export function monitorContext(provider, name, state, log) {
|
|
|
223
244
|
async function snapshotFor(provider, name) {
|
|
224
245
|
if (provider === "codex")
|
|
225
246
|
return readCodexUsage(configDir("codex", name));
|
|
226
|
-
|
|
227
|
-
|
|
247
|
+
// An expired access token is the daemon's most common "usage stopped moving"
|
|
248
|
+
// 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
|
+
const token = freshAccessToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")));
|
|
228
251
|
if (!token)
|
|
229
252
|
return null;
|
|
230
253
|
const raw = await fetchUsage(token);
|
|
231
254
|
return raw ? parseUsage(raw) : null;
|
|
232
255
|
}
|
|
233
|
-
async function pollProvider(provider,
|
|
256
|
+
async function pollProvider(provider, state, thresholds, redeemed, log) {
|
|
234
257
|
const names = listProfiles(provider);
|
|
235
258
|
const active = activeFor(provider);
|
|
236
259
|
const autoSwitch = readAutoSwitch(provider);
|
|
@@ -263,7 +286,7 @@ async function pollProvider(provider, strategy, state, thresholds, redeemed, log
|
|
|
263
286
|
}
|
|
264
287
|
state.profiles[key] = snapshot;
|
|
265
288
|
polled.push({ name, snapshot });
|
|
266
|
-
|
|
289
|
+
recordHistorySample(path.join(profileDir(provider, name), "usage-history.json"), snapshot);
|
|
267
290
|
if (name === active) {
|
|
268
291
|
const prev = thresholds.get(`${provider}/${name}`) ?? {};
|
|
269
292
|
const { crossings, state: next } = detectCrossings(snapshot, prev);
|
|
@@ -288,12 +311,12 @@ async function pollProvider(provider, strategy, state, thresholds, redeemed, log
|
|
|
288
311
|
const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
|
|
289
312
|
if (activeMax === null || activeMax < autoSwitch.threshold)
|
|
290
313
|
return failures; // still has headroom
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
if (
|
|
314
|
+
// Codex only (banked resets are a Codex feature): redeem a reset and STAY, but
|
|
315
|
+
// AT MOST ONCE per reset cycle. We record the window's resets_at BEFORE calling,
|
|
316
|
+
// so a buggy reset that doesn't actually clear usage can never loop and burn the
|
|
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.
|
|
319
|
+
if (provider === "codex" && activeSnap) {
|
|
297
320
|
const stuck = activeSnap.windows.find((w) => typeof w.utilization === "number" && w.utilization >= autoSwitch.threshold);
|
|
298
321
|
const cycle = stuck?.resetsAt ?? "";
|
|
299
322
|
const guardKey = `codex/${active}`;
|
|
@@ -309,29 +332,39 @@ async function pollProvider(provider, strategy, state, thresholds, redeemed, log
|
|
|
309
332
|
});
|
|
310
333
|
return failures;
|
|
311
334
|
}
|
|
312
|
-
log(`auto-switch: codex/${active} reset redeem failed (${r.reason ?? "?"}) → falling back to a
|
|
335
|
+
log(`auto-switch: codex/${active} reset redeem failed (${r.reason ?? "?"}) → falling back to a switch suggestion`);
|
|
313
336
|
}
|
|
314
337
|
}
|
|
315
338
|
// Tag filter: only accounts carrying the configured label are eligible switch
|
|
316
339
|
// targets ("all" = no filter). Lets the operator pool e.g. only Work accounts.
|
|
317
340
|
const eligible = (name) => autoSwitch.tag === "all" || labelFor(provider, name) === autoSwitch.tag;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
341
|
+
// Compliance decision is a pure function (decideThresholdAction): it returns
|
|
342
|
+
// notify + a suggested target and NEVER a switch — see the daemon no-auto-switch
|
|
343
|
+
// regression test. pollProvider only turns that decision into log + notify I/O.
|
|
344
|
+
const action = decideThresholdAction(active, polled, autoSwitch, eligible);
|
|
345
|
+
const target = action.suggestedTarget;
|
|
346
|
+
if (action.notify && target) {
|
|
347
|
+
// Compliance: the daemon NEVER switches automatically. It only names the
|
|
348
|
+
// suggested account with the most headroom; switching stays a user
|
|
349
|
+
// interaction (CLI/GUI). No `setActive` call here.
|
|
350
|
+
log(`threshold: ${provider}/${active} out of headroom (≥${autoSwitch.threshold}%) → suggesting ${provider}/${target} (no automatic switch)`);
|
|
351
|
+
// Name the command that opens the limit dialog (claude only — rebind is
|
|
352
|
+
// claude-scoped). Wording only; the notify/suggest DECISION above is unchanged.
|
|
353
|
+
const how = provider === "claude"
|
|
354
|
+
? "Run `agent-switch rebind` to switch (no automatic switch)."
|
|
355
|
+
: "Switch manually (no automatic switch).";
|
|
322
356
|
notify({
|
|
323
|
-
kind: "
|
|
324
|
-
title: "
|
|
325
|
-
message: `${provider}/${active} hit ≥${autoSwitch.threshold}% —
|
|
357
|
+
kind: "warning",
|
|
358
|
+
title: "Usage limit near",
|
|
359
|
+
message: `${provider}/${active} hit ≥${autoSwitch.threshold}% — suggested profile: ${provider}/${target}. ${how}`,
|
|
326
360
|
});
|
|
327
361
|
}
|
|
328
362
|
return failures;
|
|
329
363
|
}
|
|
330
364
|
async function pollOnce(state, thresholds, redeemed, log) {
|
|
331
|
-
const strategy = readSwitchStrategy();
|
|
332
365
|
let failures = 0;
|
|
333
366
|
for (const provider of AUTO_PROVIDERS) {
|
|
334
|
-
failures += await pollProvider(provider,
|
|
367
|
+
failures += await pollProvider(provider, state, thresholds, redeemed, log);
|
|
335
368
|
}
|
|
336
369
|
state.lastPoll = new Date().toISOString();
|
|
337
370
|
state.lastError = failures > 0 ? `${failures} profile(s) failed to poll` : null;
|
|
@@ -363,8 +396,9 @@ export async function runDaemon(opts = {}) {
|
|
|
363
396
|
// Load persisted usage-window fired state so crossings do NOT re-fire after a
|
|
364
397
|
// daemon restart within the same window cycle (the pre-existing in-memory gap).
|
|
365
398
|
const thresholds = new Map(Object.entries(state.usageThresholds ?? {}));
|
|
366
|
-
// Anti-loop guard for reset
|
|
367
|
-
// we last redeemed for, so we never redeem the same stuck
|
|
399
|
+
// Anti-loop guard for the Codex reset redemption: `<provider>/<name>` → the
|
|
400
|
+
// window resets_at we last redeemed for, so we never redeem the same stuck
|
|
401
|
+
// cycle twice.
|
|
368
402
|
const redeemed = new Map();
|
|
369
403
|
let consecutiveFailures = 0;
|
|
370
404
|
let cycles = 0;
|
package/dist/doctor.js
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
|
-
import { ROOT, activeFor, configDir, listProfiles, profileExists } from "./profiles.js";
|
|
10
|
+
import { ROOT, activeFor, configDir, listProfiles, profileExists, readGuard } from "./profiles.js";
|
|
11
11
|
import { allProviders } from "./providers.js";
|
|
12
12
|
import { credentialStore } from "./credentials.js";
|
|
13
13
|
import { sharedLinkHealth } from "./share.js";
|
|
14
|
-
import { checkAuth } from "./api.js";
|
|
14
|
+
import { checkAuth, liveSessionPids } from "./api.js";
|
|
15
15
|
import { checkTooling, doctorToolingLine } from "./tooling.js";
|
|
16
16
|
const OK = "✅";
|
|
17
17
|
const WARN = "⚠️";
|
|
@@ -113,6 +113,27 @@ export async function runDoctor() {
|
|
|
113
113
|
line(WARN, `claude/${n}: ${parts.join("; ")} — run \`agent-switch share sync\`.`);
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
+
// Single-load guard: the policy, and whether any profile is doubled RIGHT NOW.
|
|
117
|
+
// Reported even when the policy is `warn`/`off` — the point of doctor is to
|
|
118
|
+
// surface the state, and a silently tolerated duplicate is exactly the thing
|
|
119
|
+
// the user came here to find.
|
|
120
|
+
{
|
|
121
|
+
const policy = readGuard();
|
|
122
|
+
line(policy === "off" ? WARN : OK, `single-load guard: ${policy}${policy === "off" ? " — a profile can be loaded twice" : ""}.`);
|
|
123
|
+
const doubled = [];
|
|
124
|
+
for (const p of allProviders()) {
|
|
125
|
+
if (!p.hasLiveSessionSignal)
|
|
126
|
+
continue;
|
|
127
|
+
for (const n of listProfiles(p.id)) {
|
|
128
|
+
const count = liveSessionPids(configDir(p.id, n)).length;
|
|
129
|
+
if (count > 1)
|
|
130
|
+
doubled.push(`${p.id}/${n} (${count} sessions)`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (doubled.length > 0) {
|
|
134
|
+
line(WARN, ` loaded more than once: ${doubled.join(", ")} — they share one account's rate limits.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
116
137
|
// Ecosystem tooling (agent-config + rtk) — same detection module as
|
|
117
138
|
// `agent-switch tooling`, never a second implementation. Both tools are
|
|
118
139
|
// optional, so an unhealthy row is a WARN, never a blocking error.
|
package/dist/gui-launch.js
CHANGED
|
@@ -44,12 +44,49 @@ export function guiAssetSpec(platform = process.platform, arch = process.arch) {
|
|
|
44
44
|
return { match: /-setup\.exe$/i, kind: "win-setup" };
|
|
45
45
|
return null;
|
|
46
46
|
}
|
|
47
|
-
/** Program + args to launch a prepared artifact. Pure.
|
|
47
|
+
/** Program + args to launch a prepared artifact. Pure.
|
|
48
|
+
* macOS uses `open` WITHOUT `-n`: a bare `open` activates the already-running
|
|
49
|
+
* app instead of forcing a second copy, so it is the OS-level half of the
|
|
50
|
+
* single-instance guarantee (`isGuiRunning` is the other half). */
|
|
48
51
|
export function guiLaunchArgv(kind, target) {
|
|
49
52
|
if (kind === "app")
|
|
50
|
-
return { program: "open", args: [
|
|
53
|
+
return { program: "open", args: [target] };
|
|
51
54
|
return { program: target, args: [] }; // AppImage runs directly; win-setup runs the installer
|
|
52
55
|
}
|
|
56
|
+
/** Substring that identifies a RUNNING GUI process for this kind inside a
|
|
57
|
+
* process-list snapshot. Specific enough not to match the Node CLI itself
|
|
58
|
+
* (whose command line is `node …/dist/index.js`). Pure. */
|
|
59
|
+
export function guiProcessSignature(kind) {
|
|
60
|
+
if (kind === "app")
|
|
61
|
+
return "agent-switch.app/Contents/MacOS/agent-switch"; // the running .app binary
|
|
62
|
+
if (kind === "appimage")
|
|
63
|
+
return "agent-switch.AppImage";
|
|
64
|
+
return "agent-switch.exe"; // win: the installed app process (not the -setup installer)
|
|
65
|
+
}
|
|
66
|
+
/** Whether `signature` appears in a process-list snapshot. Pure. */
|
|
67
|
+
export function guiRunningIn(signature, processList) {
|
|
68
|
+
return processList.includes(signature);
|
|
69
|
+
}
|
|
70
|
+
/** Live process-list snapshot for the host (full command line per process). */
|
|
71
|
+
function processListSnapshot(platform = process.platform) {
|
|
72
|
+
try {
|
|
73
|
+
if (platform === "win32") {
|
|
74
|
+
return spawnSync("tasklist", [], { encoding: "utf8" }).stdout ?? "";
|
|
75
|
+
}
|
|
76
|
+
return spawnSync("ps", ["ax", "-o", "command="], { encoding: "utf8" }).stdout ?? "";
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return ""; // ps/tasklist unavailable → treat as "not detectably running"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Whether the desktop GUI is already running on this host. Used to enforce
|
|
83
|
+
* the single-instance rule before spawning a second copy. */
|
|
84
|
+
export function isGuiRunning() {
|
|
85
|
+
const spec = guiAssetSpec();
|
|
86
|
+
if (!spec)
|
|
87
|
+
return false;
|
|
88
|
+
return guiRunningIn(guiProcessSignature(spec.kind), processListSnapshot());
|
|
89
|
+
}
|
|
53
90
|
/** Find a matching GUI asset on the release for `version`, falling back to the
|
|
54
91
|
* latest release. Returns null when neither has one. */
|
|
55
92
|
async function findAsset(version, match) {
|
|
@@ -115,14 +152,19 @@ async function ensureGuiArtifact(spec) {
|
|
|
115
152
|
fs.chmodSync(launchPath, 0o755);
|
|
116
153
|
return launchPath;
|
|
117
154
|
}
|
|
118
|
-
/** Launch the desktop GUI (downloading + caching it on first use).
|
|
155
|
+
/** Launch the desktop GUI (downloading + caching it on first use).
|
|
156
|
+
* Single-instance: if a GUI is already running, no second copy is started —
|
|
157
|
+
* returns "already-running" so the caller can say so. */
|
|
119
158
|
export async function launchGui() {
|
|
120
159
|
const spec = guiAssetSpec();
|
|
121
160
|
if (!spec) {
|
|
122
161
|
throw new Error(`the desktop GUI has no prebuilt artifact for ${process.platform}/${process.arch} — build it from source with \`task gui:build\`.`);
|
|
123
162
|
}
|
|
163
|
+
if (isGuiRunning())
|
|
164
|
+
return "already-running";
|
|
124
165
|
const target = await ensureGuiArtifact(spec);
|
|
125
166
|
const { program, args } = guiLaunchArgv(spec.kind, target);
|
|
126
167
|
const child = spawn(program, args, { detached: true, stdio: "ignore" });
|
|
127
168
|
child.unref();
|
|
169
|
+
return "launched";
|
|
128
170
|
}
|
package/dist/history.js
CHANGED
|
@@ -15,6 +15,29 @@ export function readHistory(file) {
|
|
|
15
15
|
return [];
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
/** Minimum spacing between two recorded samples. The ring holds MAX_SAMPLES
|
|
19
|
+
* (720) and the chart spans ~30 days, so ~one sample/hour fills it. Frequent
|
|
20
|
+
* callers — a GUI refresh every few minutes, the 60s daemon poll — would
|
|
21
|
+
* otherwise pack 720 samples into a few hours and shrink the window to that.
|
|
22
|
+
* 55 min (not a full 60) so a refresh landing slightly early still counts. */
|
|
23
|
+
export const HISTORY_MIN_GAP_MS = 55 * 60 * 1000;
|
|
24
|
+
/**
|
|
25
|
+
* Throttled append: record `snapshot` only when the newest existing sample is
|
|
26
|
+
* at least {@link HISTORY_MIN_GAP_MS} older (or there is none). This is the entry
|
|
27
|
+
* point every recorder uses so no caller's cadence can bloat the ring. Time is
|
|
28
|
+
* the snapshot's own `capturedAt`; an unparseable one always records.
|
|
29
|
+
*/
|
|
30
|
+
export function recordHistorySample(file, snapshot) {
|
|
31
|
+
const samples = readHistory(file);
|
|
32
|
+
const capturedMs = Date.parse(snapshot.capturedAt);
|
|
33
|
+
const last = samples[samples.length - 1];
|
|
34
|
+
if (last && Number.isFinite(capturedMs)) {
|
|
35
|
+
const lastMs = Date.parse(last.at);
|
|
36
|
+
if (Number.isFinite(lastMs) && capturedMs - lastMs < HISTORY_MIN_GAP_MS)
|
|
37
|
+
return samples;
|
|
38
|
+
}
|
|
39
|
+
return appendSample(file, snapshot);
|
|
40
|
+
}
|
|
18
41
|
/** Append a snapshot as one sample, trimming to the newest MAX_SAMPLES. */
|
|
19
42
|
export function appendSample(file, snapshot) {
|
|
20
43
|
const samples = readHistory(file);
|
package/dist/hooks.js
CHANGED
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
import * as fs from "node:fs";
|
|
21
21
|
import * as path from "node:path";
|
|
22
22
|
/** The hook events we install. `source`/matcher values confirmed in spikes/t6
|
|
23
|
-
* (the stdin field is `source`, not `matcher`).
|
|
24
|
-
|
|
23
|
+
* (the stdin field is `source`, not `matcher`). `Stop` fires when Claude ends a
|
|
24
|
+
* turn (finished / now waiting on you); `Notification` fires when it needs your
|
|
25
|
+
* attention (a permission prompt, or a long idle) — the two session-awareness
|
|
26
|
+
* signals the pet surfaces. */
|
|
27
|
+
export const HOOK_EVENTS = ["SessionStart", "SessionEnd", "PreCompact", "PostCompact", "Stop", "Notification"];
|
|
25
28
|
/** Marker stamped on every entry we add, so uninstall touches only our own. */
|
|
26
29
|
export const HOOK_MARKER = "asw_managed";
|
|
27
30
|
export const EVENT_RING_CAP = 500;
|