@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 CHANGED
@@ -1,31 +1,88 @@
1
- <p align="center"><a href="https://event4u.app"><img alt="agent-switch" src=".github/assets/banner.png"></a></p>
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
- # agent-switch
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
- Switch between multiple Claude Code accounts on macOS, Linux, and Windows with a single shell command — no repeated login/logout, no browser round-trips after the initial setup.
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
+ ![Overview — what needs you, what runs, what's left](.github/assets/screenshots/overview.png)
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
+ | ![Accounts — every usage window, next plain start, protection](.github/assets/screenshots/accounts.png) | ![Sessions — every session with its account and why it got it](.github/assets/screenshots/sessions.png) |
34
+ |---|---|
35
+ | ![Tools — agent-config, rtk and the agent CLIs](.github/assets/screenshots/tools.png) | ![Health — readiness and checks with repairs](.github/assets/screenshots/health.png) |
36
+ | ![Session — preview, why this account, explicit move](.github/assets/screenshots/session-sheet.png) | ![Near limit — you decide](.github/assets/screenshots/near-limit.png) |
37
+ | ![Start session — folder, tool, account](.github/assets/screenshots/start-session.png) | ![Settings — protection per account](.github/assets/screenshots/settings-protection.png) |
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
- $ asw work # switch active account
17
- $ claude # runs on the "work" account
18
- $ asw # list all profiles, active one marked *
19
- * work matze@company.example
20
- privat matze@personal.example
21
- event4u dev@event4u.example
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 refuses that second start and says which session already
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 setup
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 — `agent-switch shellenv` auto-detects your shell,
93
- or pass `--shell`:
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` | switch active account (new sessions) |
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 gemini` | enable a provider (or `disable`; `--surface cli\|ui` for one surface) |
144
- | `agent-switch autoswitch on --threshold 90` | opt-in auto-switch on limit (Claude-only; globally off by default; see below) |
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` | per-OS self-check (claude on PATH, config, creds, share links) |
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 conversation history + plans/ (--resume, POSIX only)
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
- (only profiles with a live session + the active one), caches it, and notifies
338
- on the active profile's threshold crossings (75% / 90% by default):
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
- - **Tray/menubar GUI** (`gui/`, Tauri) — a small panel to see per-profile usage
347
- and switch/open sessions. It is a **client of the CLI** (`agent-switch <cmd>
348
- --json`) and never re-implements profile logic; the CLI core stays
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.
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 exposes an OAuth `/usage` endpoint;
354
- **Codex and Gemini have no usage readout**, so they show identity only (never a
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
- ### Automatic account rotation (opt-in, off by default)
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 (the same information the vendors' native `/usage` surfaces
410
- show). It never nags or ranks unless you turn rotation on.
411
-
412
- > **Usage-policy warning.** Quota-triggered rotation across accounts can violate
413
- > the providers' usage policies: it pools separate subscriptions to route around
414
- > per-account rate limits. Enabling it is your decision — but the risk is real,
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 # { "active": "work" }
441
- work/
442
- config/ # CLAUDE_CONFIG_DIR for this account (.claude.json, settings, history)
443
- browser/ # persistent Playwright profile for claude.ai
444
- privat/
445
- config/
446
- event4u/
447
- config/
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
  ```
@@ -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>`, take the name, pass the rest.
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 = args.indexOf("--provider");
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
+ }