@event4u/agent-switch 2.1.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 +139 -70
- 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/daemon.js +89 -38
- package/dist/desktop-sessions.js +115 -0
- package/dist/dir-resolve.js +151 -0
- package/dist/doctor.js +205 -69
- package/dist/gui-settings.js +67 -0
- package/dist/hooks.js +21 -0
- package/dist/index.js +690 -61
- package/dist/mappings.js +48 -9
- package/dist/pools.js +144 -0
- package/dist/profiles.js +69 -6
- package/dist/protection.js +93 -0
- package/dist/rebind.js +108 -11
- package/dist/service.js +24 -9
- package/dist/sessions.js +82 -13
- package/dist/share.js +92 -13
- package/dist/shellenv.js +100 -21
- package/dist/single-load.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,31 +1,88 @@
|
|
|
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
|
|
|
@@ -36,8 +93,10 @@ nothing used to stop two terminals from starting `claude` on the same account
|
|
|
36
93
|
sharing one set of rate limits and both refreshing one rotating OAuth token,
|
|
37
94
|
while each terminal looked like a separate account.
|
|
38
95
|
|
|
39
|
-
The single-load guard
|
|
40
|
-
holds the profile and which accounts are free
|
|
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:
|
|
41
100
|
|
|
42
101
|
```
|
|
43
102
|
$ claude
|
|
@@ -72,7 +131,7 @@ project):
|
|
|
72
131
|
npm install -g @event4u/agent-switch # puts `agent-switch` on your PATH
|
|
73
132
|
# or run without installing:
|
|
74
133
|
npx @event4u/agent-switch --help
|
|
75
|
-
npx @event4u/agent-switch
|
|
134
|
+
npx @event4u/agent-switch doctor # self-check: CLI, shell integration, logins
|
|
76
135
|
```
|
|
77
136
|
|
|
78
137
|
`npx` is handy for a one-off or first run; for daily use install globally so the
|
|
@@ -89,8 +148,11 @@ npm link # puts `agent-switch` on your PATH (npm creates a .cmd shim
|
|
|
89
148
|
Native package managers (Homebrew / Scoop / winget) are planned; `npm i -g`
|
|
90
149
|
above is the supported install path on every OS in the meantime.
|
|
91
150
|
|
|
92
|
-
Then add the shell integration
|
|
93
|
-
|
|
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`:
|
|
94
156
|
|
|
95
157
|
| Shell | One-time setup |
|
|
96
158
|
|---|---|
|
|
@@ -127,40 +189,48 @@ agent-switch add event4u
|
|
|
127
189
|
|
|
128
190
|
| Command | Effect |
|
|
129
191
|
|---|---|
|
|
130
|
-
| `asw work` |
|
|
192
|
+
| `asw work` | star an account: it takes plain starts when the accounts are level (or nothing can be weighed) |
|
|
131
193
|
| `asw` | list profiles (live sessions shown) |
|
|
132
194
|
| `agent-switch run event4u` | one-shot session on another account **without** switching — all accounts can run in parallel |
|
|
133
195
|
| `agent-switch run work -- --resume` | pass any flags through to `claude` |
|
|
134
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) |
|
|
135
198
|
| `agent-switch map work ~/projects/kunde-a` | `claude` in that repo always uses this account — no switching |
|
|
136
|
-
| `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 |
|
|
137
200
|
| `agent-switch sessions` | recent + live Claude sessions per profile |
|
|
138
201
|
| `agent-switch takeover <id> --to work` | move a session to another account and resume it there (see below) |
|
|
139
202
|
| `agent-switch web work` | claude.ai in a persistent per-profile browser (see below) |
|
|
140
|
-
| `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) |
|
|
141
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) |
|
|
142
207
|
| `agent-switch providers status` | which providers are enabled (default: Claude + Codex; others off) |
|
|
143
|
-
| `agent-switch providers enable --provider
|
|
144
|
-
| `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) |
|
|
145
210
|
| `agent-switch uninstall --force` | remove all agent-switch data, keychain entries, and the daemon |
|
|
146
|
-
| `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) |
|
|
147
216
|
|
|
148
217
|
## Per-repo accounts (directory mappings)
|
|
149
218
|
|
|
150
219
|
```bash
|
|
151
220
|
agent-switch map work ~/projects/firma
|
|
152
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)
|
|
153
223
|
```
|
|
154
224
|
|
|
155
225
|
The `claude` shell wrapper resolves the current directory to the nearest mapped
|
|
156
|
-
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`
|
|
157
227
|
lists, `agent-switch unmap` removes.
|
|
158
228
|
|
|
159
229
|
## Shared settings across accounts
|
|
160
230
|
|
|
161
231
|
```bash
|
|
162
|
-
agent-switch share on # settings.json, CLAUDE.md, skills/, commands/, agents/
|
|
163
|
-
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)
|
|
164
234
|
agent-switch share sync # re-link any file a /config edit forked (see below)
|
|
165
235
|
agent-switch share off # removes only agent-switch-created links
|
|
166
236
|
```
|
|
@@ -334,8 +404,10 @@ Full per-mechanism contract (verified/degraded/broken with sources) lives in
|
|
|
334
404
|
The CLI is fully usable on its own. Two optional layers add convenience:
|
|
335
405
|
|
|
336
406
|
- **Usage daemon** — a background service polls each profile's **own** usage
|
|
337
|
-
(
|
|
338
|
-
|
|
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:
|
|
339
411
|
|
|
340
412
|
```bash
|
|
341
413
|
agent-switch service install # launchd (macOS) / systemd --user (Linux) / Task Scheduler (Windows)
|
|
@@ -343,15 +415,16 @@ The CLI is fully usable on its own. Two optional layers add convenience:
|
|
|
343
415
|
agent-switch service start|stop # manual control (no install)
|
|
344
416
|
```
|
|
345
417
|
|
|
346
|
-
- **
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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.
|
|
352
425
|
|
|
353
|
-
Usage readout is per-provider: Claude
|
|
354
|
-
**
|
|
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
|
|
355
428
|
fabricated number).
|
|
356
429
|
|
|
357
430
|
### Run it all automatically (on login)
|
|
@@ -403,46 +476,42 @@ To remove these prompts entirely, the builds need Apple Developer-ID signing +
|
|
|
403
476
|
notarization (macOS) and an Authenticode certificate (Windows) — wired via
|
|
404
477
|
release secrets when certificates are available.
|
|
405
478
|
|
|
406
|
-
###
|
|
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)
|
|
407
491
|
|
|
408
492
|
By default `agent-switch` only switches when **you** ask, and shows **your own**
|
|
409
|
-
usage per profile
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
> and this notice states it plainly. Plain per-context profile switching
|
|
416
|
-
> (private / work / client) and viewing your own usage are **not** affected;
|
|
417
|
-
> this warning is specific to automated quota-driven rotation.
|
|
418
|
-
|
|
419
|
-
Auto-switch is **globally off by default** and must be explicitly enabled. It is
|
|
420
|
-
available **only for Claude** — the one provider with a usage readout to trigger
|
|
421
|
-
on; Codex and Gemini have none, so they are profile-switch only and never show an
|
|
422
|
-
auto-switch control. When enabled (`agent-switch autoswitch on [--threshold N]`),
|
|
423
|
-
the background daemon moves the active Claude profile to the account with the most
|
|
424
|
-
headroom once the active one hits the threshold. Switching only affects **new**
|
|
425
|
-
sessions; running ones keep their environment.
|
|
426
|
-
|
|
427
|
-
**On the record:** this capability was reviewed and **rejected** — first in the
|
|
428
|
-
v1 adoption analysis, then in a two-round AI-council review that was **unanimous**
|
|
429
|
-
that quota-triggered cross-account rotation crosses the providers' usage policies
|
|
430
|
-
and recommended removal. It ships here anyway, off by default, as a deliberate
|
|
431
|
-
decision that **overrode** that recommendation — not a relitigation that resolved
|
|
432
|
-
it. The full rejection rationale is preserved verbatim in
|
|
433
|
-
[`agents/roadmaps/skipped/road-to-agent-switch-autoswitch-rejected.md`](agents/roadmaps/skipped/road-to-agent-switch-autoswitch-rejected.md);
|
|
434
|
-
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).
|
|
435
499
|
|
|
436
500
|
## Layout
|
|
437
501
|
|
|
438
502
|
```
|
|
439
503
|
~/.agent-switch/
|
|
440
|
-
state.json #
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
448
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
|
+
}
|