@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/README.md
CHANGED
|
@@ -1,34 +1,126 @@
|
|
|
1
|
-
<p align="center"><a href="https://event4u.app"><img alt="
|
|
1
|
+
<p align="center"><a href="https://event4u.app"><img alt="event4u Agent Switch" src=".github/assets/info-banner.png"></a></p>
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# <img src=".github/assets/icon.png" alt="" width="44" align="top"> Agent Switch — the right account for every session
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
+
<a href="https://github.com/event4u-app/agent-switch/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/event4u-app/agent-switch/actions/workflows/ci.yml/badge.svg"></a>
|
|
6
7
|
<a href="https://www.npmjs.com/package/@event4u/agent-switch"><img alt="npm" src="https://img.shields.io/npm/v/@event4u/agent-switch?style=flat-square&color=orange"></a>
|
|
8
|
+
<a href="https://www.npmjs.com/package/@event4u/agent-switch"><img alt="downloads" src="https://img.shields.io/npm/dm/@event4u/agent-switch?style=flat-square&label=downloads%2Fmonth&color=orange"></a>
|
|
7
9
|
<a href="https://event4u-app.github.io/agent-switch/"><img alt="Documentation" src="https://img.shields.io/badge/docs-event4u--app.github.io-orange?style=flat-square"></a>
|
|
8
10
|
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-orange?style=flat-square"></a>
|
|
9
11
|
</p>
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
Keep several **Claude Code, Codex and Antigravity** accounts signed in side by side on
|
|
14
|
+
macOS, Linux and Windows — each in its own folder, each with its own login. Start a
|
|
15
|
+
session on the account you mean, see what every account has left, and get warned
|
|
16
|
+
before one runs out. Your tools keep starting exactly as before.
|
|
12
17
|
|
|
13
|
-
📖 **[Read the documentation →](https://event4u-app.github.io/agent-switch/)**
|
|
18
|
+
📖 **[Read the documentation →](https://event4u-app.github.io/agent-switch/)** · **Try it in 30 seconds:** [quickstart ↓](#try-it-in-30-seconds)
|
|
19
|
+
|
|
20
|
+
### See what needs you, what runs where, and what every account has left
|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
One window for every account and session: which session runs on which account and
|
|
25
|
+
why, what each account has left in its 5-hour and weekly window (with how old that
|
|
26
|
+
number is), and the one thing that needs you next — a session waiting for your
|
|
27
|
+
answer, a sign-in, an account near its limit. Claude Desktop sessions are filed
|
|
28
|
+
under the account that ran them. The menu-bar popover shows the same at a glance
|
|
29
|
+
and opens Claude Desktop (or Codex, Antigravity) as any account in one click.
|
|
30
|
+
|
|
31
|
+
<details><summary><b>More views</b></summary>
|
|
32
|
+
|
|
33
|
+
|  |  |
|
|
34
|
+
|---|---|
|
|
35
|
+
|  |  |
|
|
36
|
+
|  |  |
|
|
37
|
+
|  |  |
|
|
38
|
+
|
|
39
|
+
<p align="center"><img alt="Menu-bar popover" src=".github/assets/screenshots/tray.png" width="360"></p>
|
|
40
|
+
|
|
41
|
+
</details>
|
|
42
|
+
|
|
43
|
+
## Try it in 30 seconds
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm install -g @event4u/agent-switch # Node ≥ 20, macOS / Linux / Windows
|
|
47
|
+
agent-switch shellenv install # once: `claude` now starts on the right account
|
|
48
|
+
agent-switch add work # sign in each account once (opens the browser)
|
|
49
|
+
agent-switch add personal
|
|
50
|
+
agent-switch # open the desktop app
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Open a new terminal and run `claude` as always — Agent Switch picks the account.
|
|
54
|
+
`agent-switch doctor` checks the setup; details in [Install](#install).
|
|
55
|
+
|
|
56
|
+
### Or stay in the terminal
|
|
14
57
|
|
|
15
58
|
```
|
|
16
|
-
$
|
|
17
|
-
$
|
|
18
|
-
$ asw
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
59
|
+
$ claude # starts on the account with the most to lose before its reset
|
|
60
|
+
$ agent-switch run work # this one session on "work", whatever the balance says
|
|
61
|
+
$ asw work # star "work": it takes plain starts when accounts are level
|
|
62
|
+
$ asw # list all profiles, the starred one marked *
|
|
63
|
+
* work work@example.com
|
|
64
|
+
privat me@example.com
|
|
65
|
+
event4u dev@example.com
|
|
22
66
|
```
|
|
23
67
|
|
|
68
|
+
### What it does — and what it deliberately doesn't
|
|
69
|
+
|
|
70
|
+
- **Isolated logins** — every account gets its own config folder and keychain entry; nothing is snapshotted, and a running tool's login is only swapped when you ask for it (`agent-switch rebind`, undone with `rebind --restore`).
|
|
71
|
+
- **Usage per account** — 5-hour and weekly windows for Claude and Codex, always with their age; unknown is shown as unknown, never as free.
|
|
72
|
+
- **Session protection** — on by default at 98 % for every Claude and Codex account; it **warns** at the threshold and does not pause a running session. The warning comes from the background service (`agent-switch service install`), which reads every enabled account at least every 5 minutes. Antigravity has no usage readout and gets no warning.
|
|
73
|
+
- **Sessions that wait for you** — a session asking a question (or waiting on a permission prompt) shows in *Needs you*, and the desktop pet tells you, even while the window is in the background. Claude Desktop sessions (Code tab and agent mode) appear under the account they ran on.
|
|
74
|
+
- **Disable instead of delete** — a disabled account keeps its login and history but is never picked and not polled; refresh it by hand when you want to look.
|
|
75
|
+
- **One prompt history** — with the shared setup (`agent-switch share on`, the app's default) ↑ and Ctrl+R in Claude Code find your old prompts on every account.
|
|
76
|
+
- **Explicit switching only** — a running session moves to another account only when you choose it (live switch, take over or hand off). Nothing switches on its own.
|
|
77
|
+
- **Balanced starts, pools and project rules** — a plain `claude` with no folder rule is spread across your enabled accounts: it starts on the one that would lose the most at its next reset (its rest divided by the hours until its weekly window resets), so every account gets used up by its reset instead of one running dry while another resets unused. An account at its protection limit is never picked; when the best accounts are level, or nothing can be weighed, the account set for plain starts (the star) is used. A folder can also point at one account, or at a pool that is balanced the same way. `dir --json` tells you why. The weighing uses cached usage only — written by the background service (`agent-switch service install`, which refreshes every enabled account every 5 minutes) or by the desktop app's own refresh; usage older than 10 minutes is not trusted and `dir --json` says so. Under `guard block` an account that already has a live session is skipped.
|
|
78
|
+
- **Health, activity, sign-in state** — `doctor --json`, `activity`, `auth-status` — readouts without tokens.
|
|
79
|
+
- **Switch, the desktop pet** — introduces itself once, on the very first start. Never again.
|
|
80
|
+
|
|
24
81
|
## Why not keychain snapshot swapping?
|
|
25
82
|
|
|
26
83
|
Most naive switchers snapshot the macOS Keychain entry (`Claude Code-credentials`) per account and restore it on switch. This breaks: Claude Code's OAuth **refresh tokens rotate on every refresh**, so a stored snapshot goes stale within hours and you're back to browser logins.
|
|
27
84
|
|
|
28
|
-
`agent-switch` uses **`CLAUDE_CONFIG_DIR` isolation** instead. Each profile gets its own config directory under `~/.agent-switch/<name>/config`. Claude Code derives a *separate* keychain entry per config dir, so every account stays logged in **live and independently**. Switching only changes which directory new `claude` invocations point at — nothing is snapshotted, nothing goes stale, and each account logs in via browser exactly once.
|
|
85
|
+
`agent-switch` uses **`CLAUDE_CONFIG_DIR` isolation** instead. Each profile gets its own config directory under `~/.agent-switch/claude/<name>/config`. Claude Code derives a *separate* keychain entry per config dir, so every account stays logged in **live and independently**. Switching only changes which directory new `claude` invocations point at — nothing is snapshotted, nothing goes stale, and each account logs in via browser exactly once.
|
|
29
86
|
|
|
30
87
|
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
88
|
|
|
89
|
+
## One profile, one session
|
|
90
|
+
|
|
91
|
+
Because `agent-switch use` sets a **global** pointer that every shell re-reads,
|
|
92
|
+
nothing used to stop two terminals from starting `claude` on the same account —
|
|
93
|
+
sharing one set of rate limits and both refreshing one rotating OAuth token,
|
|
94
|
+
while each terminal looked like a separate account.
|
|
95
|
+
|
|
96
|
+
The single-load guard notices that second start and says which session already
|
|
97
|
+
holds the profile and which accounts are free. New installs start on `warn`
|
|
98
|
+
(it reports, then lets the session start); with `agent-switch guard block` it
|
|
99
|
+
refuses instead, as shown here:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
$ claude
|
|
103
|
+
agent-switch: profile "work" is already loaded by a running claude session.
|
|
104
|
+
|
|
105
|
+
pid 41234 started 12m ago ~/projects/foo
|
|
106
|
+
|
|
107
|
+
Two sessions on one account share its rate limits, and both refresh the
|
|
108
|
+
same rotating OAuth token.
|
|
109
|
+
|
|
110
|
+
Ways forward:
|
|
111
|
+
agent-switch use privat switch to a free account — also free: event4u
|
|
112
|
+
AGENT_SWITCH_ALLOW_DUPLICATE=1 claude start it anyway, this once
|
|
113
|
+
agent-switch guard warn warn instead of refusing, from now on
|
|
114
|
+
agent-switch guard off turn the guard off entirely
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
It never gets in the way where it has no business being: a `claude` started
|
|
118
|
+
*inside* a running session is a child, not a duplicate; a crashed session leaves
|
|
119
|
+
nothing behind that can lock you out; and if `agent-switch` itself is missing or
|
|
120
|
+
broken, the wrapper falls straight through to the real binary. Set the policy
|
|
121
|
+
with `agent-switch guard <block|warn|off>` — see the
|
|
122
|
+
[CLI reference](https://event4u-app.github.io/agent-switch/reference/cli/#single-load-guard).
|
|
123
|
+
|
|
32
124
|
## Install
|
|
33
125
|
|
|
34
126
|
**npm / npx** (any OS — needs Node ≥ 20). The package is published as
|
|
@@ -39,7 +131,7 @@ project):
|
|
|
39
131
|
npm install -g @event4u/agent-switch # puts `agent-switch` on your PATH
|
|
40
132
|
# or run without installing:
|
|
41
133
|
npx @event4u/agent-switch --help
|
|
42
|
-
npx @event4u/agent-switch
|
|
134
|
+
npx @event4u/agent-switch doctor # self-check: CLI, shell integration, logins
|
|
43
135
|
```
|
|
44
136
|
|
|
45
137
|
`npx` is handy for a one-off or first run; for daily use install globally so the
|
|
@@ -56,8 +148,11 @@ npm link # puts `agent-switch` on your PATH (npm creates a .cmd shim
|
|
|
56
148
|
Native package managers (Homebrew / Scoop / winget) are planned; `npm i -g`
|
|
57
149
|
above is the supported install path on every OS in the meantime.
|
|
58
150
|
|
|
59
|
-
Then add the shell integration
|
|
60
|
-
|
|
151
|
+
Then add the shell integration. `agent-switch shellenv install` does it for you:
|
|
152
|
+
it detects your shell and adds the line below to its startup file (once — a second
|
|
153
|
+
run changes nothing). The desktop app shows a red banner until it is set up and
|
|
154
|
+
offers the same step. To do it by hand — `agent-switch shellenv` auto-detects your
|
|
155
|
+
shell, or pass `--shell`:
|
|
61
156
|
|
|
62
157
|
| Shell | One-time setup |
|
|
63
158
|
|---|---|
|
|
@@ -94,40 +189,48 @@ agent-switch add event4u
|
|
|
94
189
|
|
|
95
190
|
| Command | Effect |
|
|
96
191
|
|---|---|
|
|
97
|
-
| `asw work` |
|
|
192
|
+
| `asw work` | star an account: it takes plain starts when the accounts are level (or nothing can be weighed) |
|
|
98
193
|
| `asw` | list profiles (live sessions shown) |
|
|
99
194
|
| `agent-switch run event4u` | one-shot session on another account **without** switching — all accounts can run in parallel |
|
|
100
195
|
| `agent-switch run work -- --resume` | pass any flags through to `claude` |
|
|
101
196
|
| `agent-switch status` | identity + 5h/7d usage for every profile |
|
|
197
|
+
| `agent-switch login work` | sign a profile in again after a sign-out or an expired login (`--browser arc` opens the Claude sign-in in a specific browser; `agent-switch browsers` lists them) |
|
|
102
198
|
| `agent-switch map work ~/projects/kunde-a` | `claude` in that repo always uses this account — no switching |
|
|
103
|
-
| `agent-switch share on` | one settings/skills/commands/agents tree for all profiles |
|
|
199
|
+
| `agent-switch share on` | one settings/skills/commands/agents tree and one prompt history for all profiles |
|
|
104
200
|
| `agent-switch sessions` | recent + live Claude sessions per profile |
|
|
105
201
|
| `agent-switch takeover <id> --to work` | move a session to another account and resume it there (see below) |
|
|
106
202
|
| `agent-switch web work` | claude.ai in a persistent per-profile browser (see below) |
|
|
107
|
-
| `agent-switch remove old --force` | delete a profile incl. its keychain entry |
|
|
203
|
+
| `agent-switch remove old --force` | delete a profile incl. its keychain entry (asks on a terminal; `--yes` skips) |
|
|
108
204
|
| `agent-switch label work Work` | tag a profile (`Work` / `Personal` / `Other`, or `none` to clear) |
|
|
205
|
+
| `agent-switch disable work` | keep the profile but stop using it (`enable` turns it back on) |
|
|
206
|
+
| `agent-switch color work teal` | pick the profile's colour in the app (`none` clears it) |
|
|
109
207
|
| `agent-switch providers status` | which providers are enabled (default: Claude + Codex; others off) |
|
|
110
|
-
| `agent-switch providers enable --provider
|
|
111
|
-
| `agent-switch autoswitch on --threshold 90` |
|
|
208
|
+
| `agent-switch providers enable --provider antigravity` | enable a provider (or `disable`; `--surface cli\|ui` for one surface) |
|
|
209
|
+
| `agent-switch autoswitch on --threshold 90` | near-limit notification that suggests another profile (off by default; never switches) |
|
|
112
210
|
| `agent-switch uninstall --force` | remove all agent-switch data, keychain entries, and the daemon |
|
|
113
|
-
| `agent-switch doctor` |
|
|
211
|
+
| `agent-switch doctor [--json]` | self-check (CLI on PATH, shell integration, start at login, API-key overrides, logins, share links) |
|
|
212
|
+
| `agent-switch auth-status --json` | per-profile login state from the provider's store — never a token |
|
|
213
|
+
| `agent-switch activity` | what agent-switch itself did, and why |
|
|
214
|
+
| `agent-switch protection set claude/work --threshold 95` | per-profile limit warning (default on, 98 %); warns, never pauses |
|
|
215
|
+
| `agent-switch pool create Work` · `pool add work claude/a` · `map pool:work ~/x` | optional pools (also in the app: Accounts › Projects & pools › New pool): a folder rule starts on the member that would lose the most at its next reset (`dir --json` says why) |
|
|
114
216
|
|
|
115
217
|
## Per-repo accounts (directory mappings)
|
|
116
218
|
|
|
117
219
|
```bash
|
|
118
220
|
agent-switch map work ~/projects/firma
|
|
119
221
|
agent-switch map privat ~/projects/hobby
|
|
222
|
+
agent-switch map --pause ~/projects/hobby # keep the rule, skip it for now (--resume turns it back on)
|
|
120
223
|
```
|
|
121
224
|
|
|
122
225
|
The `claude` shell wrapper resolves the current directory to the nearest mapped
|
|
123
|
-
ancestor. Precedence: **mapping > active profile > default**. `agent-switch mappings`
|
|
226
|
+
ancestor. Precedence: **mapping > balanced pick across your accounts > active profile (the star) > default**. `agent-switch mappings`
|
|
124
227
|
lists, `agent-switch unmap` removes.
|
|
125
228
|
|
|
126
229
|
## Shared settings across accounts
|
|
127
230
|
|
|
128
231
|
```bash
|
|
129
|
-
agent-switch share on # settings.json, CLAUDE.md, skills/, commands/, agents/
|
|
130
|
-
agent-switch share on --history # additionally share
|
|
232
|
+
agent-switch share on # settings.json, CLAUDE.md, skills/, commands/, agents/ + prompt history (history.jsonl)
|
|
233
|
+
agent-switch share on --history # additionally share conversations + plans/ (--resume, POSIX only)
|
|
131
234
|
agent-switch share sync # re-link any file a /config edit forked (see below)
|
|
132
235
|
agent-switch share off # removes only agent-switch-created links
|
|
133
236
|
```
|
|
@@ -301,8 +404,10 @@ Full per-mechanism contract (verified/degraded/broken with sources) lives in
|
|
|
301
404
|
The CLI is fully usable on its own. Two optional layers add convenience:
|
|
302
405
|
|
|
303
406
|
- **Usage daemon** — a background service polls each profile's **own** usage
|
|
304
|
-
(
|
|
305
|
-
|
|
407
|
+
(every enabled account at least every 5 minutes, the active one and accounts
|
|
408
|
+
with a live session more often; disabled accounts never), caches it, and
|
|
409
|
+
notifies on the active profile's threshold crossings (75% / 90% by default).
|
|
410
|
+
Balanced starts (`dir`) and session protection warnings depend on it running:
|
|
306
411
|
|
|
307
412
|
```bash
|
|
308
413
|
agent-switch service install # launchd (macOS) / systemd --user (Linux) / Task Scheduler (Windows)
|
|
@@ -310,15 +415,16 @@ The CLI is fully usable on its own. Two optional layers add convenience:
|
|
|
310
415
|
agent-switch service start|stop # manual control (no install)
|
|
311
416
|
```
|
|
312
417
|
|
|
313
|
-
- **
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
418
|
+
- **Desktop app** (`gui/`, Tauri) — Overview, Sessions, Accounts and Tools in one
|
|
419
|
+
window, Health / Activity / Notifications / Settings on the right of the top bar,
|
|
420
|
+
and a menu-bar popover with the accounts that need you. It is a **client of the
|
|
421
|
+
CLI** (`agent-switch <cmd> --json`) and never re-implements profile logic; the CLI
|
|
422
|
+
core stays dependency-free. See [`gui/README.md`](gui/README.md). Running
|
|
423
|
+
`agent-switch` **with no command** launches it (same as `agent-switch gui`); if
|
|
424
|
+
the app is already running, no second copy starts.
|
|
319
425
|
|
|
320
|
-
Usage readout is per-provider: Claude
|
|
321
|
-
**
|
|
426
|
+
Usage readout is per-provider: Claude and Codex report their own usage windows;
|
|
427
|
+
**Antigravity has no usage readout**, so it shows identity only (never a
|
|
322
428
|
fabricated number).
|
|
323
429
|
|
|
324
430
|
### Run it all automatically (on login)
|
|
@@ -370,46 +476,42 @@ To remove these prompts entirely, the builds need Apple Developer-ID signing +
|
|
|
370
476
|
notarization (macOS) and an Authenticode certificate (Windows) — wired via
|
|
371
477
|
release secrets when certificates are available.
|
|
372
478
|
|
|
373
|
-
###
|
|
479
|
+
### Behaviour changes in this release
|
|
480
|
+
|
|
481
|
+
- **Codex resets are no longer redeemed automatically.** Up to 2.1.0, with
|
|
482
|
+
`autoswitch on` for Codex, the daemon redeemed a banked reset on its own once
|
|
483
|
+
the active profile crossed the threshold. It now only notifies you that a
|
|
484
|
+
reset is available (once per reset cycle); redeeming is your action —
|
|
485
|
+
`agent-switch reset <profile> --provider codex`, or the confirmed button in
|
|
486
|
+
the app.
|
|
487
|
+
- **The single-load guard starts on `warn` for new installs.** Existing installs
|
|
488
|
+
(a `state.json` or any profile) keep `block`.
|
|
489
|
+
|
|
490
|
+
### Near-limit notifications (opt-in, off by default)
|
|
374
491
|
|
|
375
492
|
By default `agent-switch` only switches when **you** ask, and shows **your own**
|
|
376
|
-
usage per profile
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
> and this notice states it plainly. Plain per-context profile switching
|
|
383
|
-
> (private / work / client) and viewing your own usage are **not** affected;
|
|
384
|
-
> this warning is specific to automated quota-driven rotation.
|
|
385
|
-
|
|
386
|
-
Auto-switch is **globally off by default** and must be explicitly enabled. It is
|
|
387
|
-
available **only for Claude** — the one provider with a usage readout to trigger
|
|
388
|
-
on; Codex and Gemini have none, so they are profile-switch only and never show an
|
|
389
|
-
auto-switch control. When enabled (`agent-switch autoswitch on [--threshold N]`),
|
|
390
|
-
the background daemon moves the active Claude profile to the account with the most
|
|
391
|
-
headroom once the active one hits the threshold. Switching only affects **new**
|
|
392
|
-
sessions; running ones keep their environment.
|
|
393
|
-
|
|
394
|
-
**On the record:** this capability was reviewed and **rejected** — first in the
|
|
395
|
-
v1 adoption analysis, then in a two-round AI-council review that was **unanimous**
|
|
396
|
-
that quota-triggered cross-account rotation crosses the providers' usage policies
|
|
397
|
-
and recommended removal. It ships here anyway, off by default, as a deliberate
|
|
398
|
-
decision that **overrode** that recommendation — not a relitigation that resolved
|
|
399
|
-
it. The full rejection rationale is preserved verbatim in
|
|
400
|
-
[`agents/roadmaps/skipped/road-to-agent-switch-autoswitch-rejected.md`](agents/roadmaps/skipped/road-to-agent-switch-autoswitch-rejected.md);
|
|
401
|
-
read it before enabling.
|
|
493
|
+
usage per profile — the same information the vendors' own `/usage` surfaces show.
|
|
494
|
+
`agent-switch autoswitch on [--threshold N]` adds a notification when the active
|
|
495
|
+
profile nears its limit, with a suggested profile to switch to. It **never
|
|
496
|
+
switches by itself**: accepting the suggestion is your action. The earlier
|
|
497
|
+
automatic quota rotation was removed; its review record stays in
|
|
498
|
+
[`agents/roadmaps/skipped/road-to-agent-switch-autoswitch-rejected.md`](agents/roadmaps/skipped/road-to-agent-switch-autoswitch-rejected.md).
|
|
402
499
|
|
|
403
500
|
## Layout
|
|
404
501
|
|
|
405
502
|
```
|
|
406
503
|
~/.agent-switch/
|
|
407
|
-
state.json #
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
504
|
+
state.json # active profile per provider, labels, colours, disabled
|
|
505
|
+
mappings.json # folder rules (and which are paused)
|
|
506
|
+
claude/
|
|
507
|
+
work/
|
|
508
|
+
config/ # CLAUDE_CONFIG_DIR for this account (.claude.json, settings, history)
|
|
509
|
+
browser/ # persistent Playwright profile for claude.ai
|
|
510
|
+
gui/claude-desktop/ # this account's own Claude Desktop data
|
|
511
|
+
usage-history.json # the usage samples behind the history charts
|
|
512
|
+
privat/
|
|
513
|
+
config/
|
|
514
|
+
codex/
|
|
515
|
+
work/
|
|
516
|
+
config/ # CODEX_HOME for this account
|
|
415
517
|
```
|
package/dist/activity.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity log — what agent-switch itself did, and why. Append-only JSONL,
|
|
3
|
+
* size-capped to the newest {@link ACTIVITY_MAX_LINES} entries, read by
|
|
4
|
+
* `agent-switch activity [--json]` and the GUI.
|
|
5
|
+
*
|
|
6
|
+
* Only agent-switch's own actions land here (switch, rebind, login, redeem,
|
|
7
|
+
* autostart, guard, update, protection, pool, remove) — never provider traffic.
|
|
8
|
+
* Every string field passes {@link redactSecrets} on the way in, so a token that
|
|
9
|
+
* leaked into an error message never reaches the file.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { ROOT, ensureRoot } from "./profiles.js";
|
|
14
|
+
export const ACTIVITY_FILE = path.join(ROOT, "activity.jsonl");
|
|
15
|
+
export const ACTIVITY_MAX_LINES = 2000;
|
|
16
|
+
/** Trim only once the file grows past this, so a normal append stays O(1). */
|
|
17
|
+
const TRIM_BYTES = 512 * 1024;
|
|
18
|
+
const MAX_FIELD = 300;
|
|
19
|
+
export const ACTIVITY_KINDS = [
|
|
20
|
+
"switch",
|
|
21
|
+
"rebind",
|
|
22
|
+
"login",
|
|
23
|
+
"redeem",
|
|
24
|
+
"autostart",
|
|
25
|
+
"guard",
|
|
26
|
+
"update",
|
|
27
|
+
"protection",
|
|
28
|
+
"pool",
|
|
29
|
+
"remove",
|
|
30
|
+
];
|
|
31
|
+
/** Strip anything shaped like a credential: OAuth/API key prefixes, JWTs, and
|
|
32
|
+
* long opaque runs. Over-redaction is fine here; a leaked token is not. */
|
|
33
|
+
export function redactSecrets(s) {
|
|
34
|
+
return s
|
|
35
|
+
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[redacted]")
|
|
36
|
+
.replace(/\b(sk|rt|at|sess|ghp|gho|xox[abp])[-_][A-Za-z0-9_-]{8,}/g, "[redacted]")
|
|
37
|
+
.replace(/[A-Za-z0-9_+=-]{40,}/g, "[redacted]");
|
|
38
|
+
}
|
|
39
|
+
function clean(s) {
|
|
40
|
+
const one = redactSecrets(String(s)).replace(/[\r\n\t]+/g, " ");
|
|
41
|
+
return one.length > MAX_FIELD ? one.slice(0, MAX_FIELD - 1) + "…" : one;
|
|
42
|
+
}
|
|
43
|
+
export function appendActivity(e, file = ACTIVITY_FILE) {
|
|
44
|
+
const entry = {
|
|
45
|
+
at: e.at ?? new Date().toISOString(),
|
|
46
|
+
kind: e.kind,
|
|
47
|
+
target: e.target === null ? null : clean(e.target),
|
|
48
|
+
action: clean(e.action),
|
|
49
|
+
reason: clean(e.reason),
|
|
50
|
+
ok: e.ok,
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
if (file === ACTIVITY_FILE)
|
|
54
|
+
ensureRoot();
|
|
55
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n", { mode: 0o600 });
|
|
56
|
+
if (fs.statSync(file).size > TRIM_BYTES)
|
|
57
|
+
trimActivity(file);
|
|
58
|
+
return entry;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null; // the log is a record, never a reason for a command to fail
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function trimActivity(file) {
|
|
65
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
66
|
+
if (lines.length <= ACTIVITY_MAX_LINES)
|
|
67
|
+
return;
|
|
68
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
69
|
+
fs.writeFileSync(tmp, lines.slice(-ACTIVITY_MAX_LINES).join("\n") + "\n", { mode: 0o600 });
|
|
70
|
+
fs.renameSync(tmp, file);
|
|
71
|
+
}
|
|
72
|
+
/** Entries oldest → newest; malformed lines are skipped. `limit` keeps the newest N. */
|
|
73
|
+
export function readActivity(limit, file = ACTIVITY_FILE) {
|
|
74
|
+
let raw = "";
|
|
75
|
+
try {
|
|
76
|
+
raw = fs.readFileSync(file, "utf8");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const line of raw.split("\n")) {
|
|
83
|
+
if (!line)
|
|
84
|
+
continue;
|
|
85
|
+
try {
|
|
86
|
+
const e = JSON.parse(line);
|
|
87
|
+
if (e && typeof e.at === "string" && ACTIVITY_KINDS.includes(e.kind))
|
|
88
|
+
out.push(e);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* skip */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (limit === undefined || limit < 0)
|
|
95
|
+
return out;
|
|
96
|
+
return limit === 0 ? [] : out.slice(-limit);
|
|
97
|
+
}
|
package/dist/args.js
CHANGED
|
@@ -15,6 +15,7 @@ import { PROVIDER_IDS, isProviderId } from "./providers.js";
|
|
|
15
15
|
* (the bug behind `notify`/`providers --surface` silently losing their args). */
|
|
16
16
|
const VALUE_FLAGS = new Set([
|
|
17
17
|
"provider",
|
|
18
|
+
"browser",
|
|
18
19
|
"shell",
|
|
19
20
|
"source",
|
|
20
21
|
"threshold",
|
|
@@ -29,6 +30,9 @@ const VALUE_FLAGS = new Set([
|
|
|
29
30
|
"ack",
|
|
30
31
|
"brief",
|
|
31
32
|
"profile",
|
|
33
|
+
"by",
|
|
34
|
+
"reason",
|
|
35
|
+
"limit",
|
|
32
36
|
]);
|
|
33
37
|
/** Resolve a `--provider` value to a ProviderId; throws (not exits) on invalid
|
|
34
38
|
* so callers can test it. Undefined → claude. */
|
|
@@ -71,16 +75,32 @@ export function parseArgs(argv) {
|
|
|
71
75
|
/**
|
|
72
76
|
* `run` is special: everything after the profile name is passed THROUGH to the
|
|
73
77
|
* provider binary (including its own `--flags`), so it can't go through the
|
|
74
|
-
* general parser. Strip only `--provider <val
|
|
78
|
+
* general parser. Strip only `--provider <val>` and `--cwd <dir>` (before any
|
|
79
|
+
* bare `--`), take the name, pass the rest.
|
|
75
80
|
*/
|
|
76
81
|
export function parseRun(rest) {
|
|
77
82
|
const args = [...rest];
|
|
83
|
+
// Everything after a bare `--` belongs to the provider binary, so our own
|
|
84
|
+
// flags are only looked for before it.
|
|
85
|
+
const own = (flag) => {
|
|
86
|
+
const end = args.indexOf("--");
|
|
87
|
+
const i = args.indexOf(flag);
|
|
88
|
+
return i >= 0 && (end < 0 || i < end) ? i : -1;
|
|
89
|
+
};
|
|
78
90
|
let providerId = "claude";
|
|
79
|
-
const pi =
|
|
91
|
+
const pi = own("--provider");
|
|
80
92
|
if (pi >= 0) {
|
|
81
93
|
providerId = resolveProviderValue(args[pi + 1]);
|
|
82
94
|
args.splice(pi, 2);
|
|
83
95
|
}
|
|
96
|
+
let cwd;
|
|
97
|
+
const ci = own("--cwd");
|
|
98
|
+
if (ci >= 0) {
|
|
99
|
+
cwd = args[ci + 1];
|
|
100
|
+
if (!cwd)
|
|
101
|
+
throw new Error("--cwd needs a folder");
|
|
102
|
+
args.splice(ci, 2);
|
|
103
|
+
}
|
|
84
104
|
const name = args.shift();
|
|
85
|
-
return { providerId, name, args };
|
|
105
|
+
return cwd === undefined ? { providerId, name, args } : { providerId, name, args, cwd };
|
|
86
106
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
function iso(ms) {
|
|
15
|
+
return ms !== null && Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
16
|
+
}
|
|
17
|
+
function windowState(refreshEndsMs, now) {
|
|
18
|
+
if (refreshEndsMs === null)
|
|
19
|
+
return "connected";
|
|
20
|
+
if (refreshEndsMs <= now)
|
|
21
|
+
return "sign-in-needed";
|
|
22
|
+
return refreshEndsMs - now <= EXPIRING_MS ? "expiring" : "connected";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Claude Code credential (`{ claudeAiOauth: { refreshToken, expiresAt,
|
|
26
|
+
* refreshTokenExpiresAt? } }`). `expiresAt` there is the short-lived access
|
|
27
|
+
* token, which Claude Code refreshes itself — not a sign-in deadline, so it is
|
|
28
|
+
* not reported as one. `lastRefreshMs` is the store's mtime when it is a file.
|
|
29
|
+
*/
|
|
30
|
+
export function claudeAuthStatus(name, raw, lastRefreshMs, now) {
|
|
31
|
+
const base = { provider: "claude", name, keptFreshBy: "claude (refreshes the login when it runs)" };
|
|
32
|
+
let oauth = null;
|
|
33
|
+
try {
|
|
34
|
+
oauth = raw ? JSON.parse(raw)?.claudeAiOauth : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
oauth = null;
|
|
38
|
+
}
|
|
39
|
+
if (!raw) {
|
|
40
|
+
return { ...base, state: "sign-in-needed", expiresAt: null, lastRefreshAt: null, detail: "No stored login for this profile." };
|
|
41
|
+
}
|
|
42
|
+
if (oauth && oauth.refreshToken === "" && (oauth.accessToken === "" || oauth.accessToken == null)) {
|
|
43
|
+
return {
|
|
44
|
+
...base,
|
|
45
|
+
state: "sign-in-needed",
|
|
46
|
+
expiresAt: null,
|
|
47
|
+
lastRefreshAt: iso(lastRefreshMs),
|
|
48
|
+
detail: "Claude Code cleared this login (signed out, or the refresh window ended). Sign in again.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (!oauth || typeof oauth.refreshToken !== "string" || !oauth.refreshToken) {
|
|
52
|
+
return { ...base, state: "unknown", expiresAt: null, lastRefreshAt: iso(lastRefreshMs), detail: "The stored login has an unrecognised shape." };
|
|
53
|
+
}
|
|
54
|
+
const refreshEnds = typeof oauth.refreshTokenExpiresAt === "number" ? oauth.refreshTokenExpiresAt : null;
|
|
55
|
+
return {
|
|
56
|
+
...base,
|
|
57
|
+
state: windowState(refreshEnds, now),
|
|
58
|
+
expiresAt: iso(refreshEnds),
|
|
59
|
+
lastRefreshAt: iso(lastRefreshMs),
|
|
60
|
+
detail: refreshEnds === null
|
|
61
|
+
? "A refreshable login is stored; Claude does not record when it ends, so a revoked login shows up only on next use."
|
|
62
|
+
: "A refreshable login is stored.",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Codex `auth.json` (`{ tokens: { refresh_token, … }, last_refresh, OPENAI_API_KEY? }`). */
|
|
66
|
+
/** A JWT's `exp` (epoch ms) from its payload — read without verifying, only to
|
|
67
|
+
* date it; null when it is not a JWT or carries no exp. */
|
|
68
|
+
function jwtExpMs(token) {
|
|
69
|
+
if (typeof token !== "string")
|
|
70
|
+
return null;
|
|
71
|
+
const payload = token.split(".")[1];
|
|
72
|
+
if (!payload)
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
const exp = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))?.exp;
|
|
76
|
+
return typeof exp === "number" ? exp * 1000 : null;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function codexAuthStatus(name, raw, now) {
|
|
83
|
+
const base = { provider: "codex", name, keptFreshBy: "codex (refreshes the login when it runs)" };
|
|
84
|
+
let auth = null;
|
|
85
|
+
try {
|
|
86
|
+
auth = raw ? JSON.parse(raw) : null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
auth = null;
|
|
90
|
+
}
|
|
91
|
+
if (!auth) {
|
|
92
|
+
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." };
|
|
93
|
+
}
|
|
94
|
+
const last = typeof auth.last_refresh === "string" && !isNaN(Date.parse(auth.last_refresh)) ? new Date(auth.last_refresh).toISOString() : null;
|
|
95
|
+
if (typeof auth.OPENAI_API_KEY === "string" && auth.OPENAI_API_KEY) {
|
|
96
|
+
return { ...base, state: "connected", expiresAt: null, lastRefreshAt: last, keptFreshBy: "not needed (API key)", detail: "Signed in with an API key, billed per use." };
|
|
97
|
+
}
|
|
98
|
+
if (typeof auth?.tokens?.refresh_token !== "string" || !auth.tokens.refresh_token) {
|
|
99
|
+
return { ...base, state: "sign-in-needed", expiresAt: null, lastRefreshAt: last, detail: "No refreshable login stored." };
|
|
100
|
+
}
|
|
101
|
+
const accessEnds = jwtExpMs(auth.tokens.access_token);
|
|
102
|
+
if (accessEnds !== null && accessEnds <= now) {
|
|
103
|
+
return {
|
|
104
|
+
...base,
|
|
105
|
+
state: "unknown",
|
|
106
|
+
expiresAt: null,
|
|
107
|
+
lastRefreshAt: last,
|
|
108
|
+
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.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
...base,
|
|
113
|
+
state: windowState(null, now),
|
|
114
|
+
expiresAt: null,
|
|
115
|
+
lastRefreshAt: last,
|
|
116
|
+
detail: "A refreshable login is stored; codex does not record when it ends.",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Antigravity keeps its login in a per-profile keychain agent-switch does not read here. */
|
|
120
|
+
export function antigravityAuthStatus(name, keychainExists) {
|
|
121
|
+
return {
|
|
122
|
+
provider: "antigravity",
|
|
123
|
+
name,
|
|
124
|
+
state: keychainExists ? "unknown" : "sign-in-needed",
|
|
125
|
+
expiresAt: null,
|
|
126
|
+
lastRefreshAt: null,
|
|
127
|
+
keptFreshBy: "agy (refreshes the login when it runs)",
|
|
128
|
+
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.",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function fileMtimeMs(p) {
|
|
132
|
+
try {
|
|
133
|
+
return fs.statSync(p).mtimeMs;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function readText(p) {
|
|
140
|
+
try {
|
|
141
|
+
return fs.readFileSync(p, "utf8");
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export function claudeCredentialFile(cfg) {
|
|
148
|
+
return path.join(cfg, ".credentials.json");
|
|
149
|
+
}
|