@modelprofile.com/authswitch 2.3.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/accounts.d.ts +28 -6
  3. package/dist_ts/accounts.js +40 -7
  4. package/dist_ts/classes.accountlist.js +4 -3
  5. package/dist_ts/classes.claudecodeharness.d.ts +4 -4
  6. package/dist_ts/classes.claudecodeharness.js +5 -5
  7. package/dist_ts/classes.claudestatus.js +94 -18
  8. package/dist_ts/classes.cli.d.ts +12 -0
  9. package/dist_ts/classes.cli.js +98 -9
  10. package/dist_ts/classes.credentialstore.d.ts +20 -7
  11. package/dist_ts/classes.credentialstore.js +36 -23
  12. package/dist_ts/classes.fileharness.d.ts +23 -3
  13. package/dist_ts/classes.fileharness.js +102 -16
  14. package/dist_ts/classes.harnessprocesses.d.ts +62 -0
  15. package/dist_ts/classes.harnessprocesses.js +198 -0
  16. package/dist_ts/classes.limits.js +45 -19
  17. package/dist_ts/classes.opencodeharness.d.ts +4 -5
  18. package/dist_ts/classes.opencodeharness.js +5 -5
  19. package/dist_ts/classes.operations.d.ts +9 -1
  20. package/dist_ts/classes.operations.js +4 -2
  21. package/dist_ts/classes.tui.js +51 -5
  22. package/dist_ts/index.d.ts +1 -0
  23. package/dist_ts/index.js +2 -1
  24. package/dist_ts/interfaces.harness.d.ts +79 -8
  25. package/dist_ts/interfaces.list.d.ts +20 -1
  26. package/package.json +2 -2
  27. package/readme.md +137 -31
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/accounts.ts +44 -10
  30. package/ts/classes.accountlist.ts +3 -2
  31. package/ts/classes.claudecodeharness.ts +7 -6
  32. package/ts/classes.claudestatus.ts +102 -14
  33. package/ts/classes.cli.ts +77 -9
  34. package/ts/classes.credentialstore.ts +41 -18
  35. package/ts/classes.fileharness.ts +80 -16
  36. package/ts/classes.harnessprocesses.ts +225 -0
  37. package/ts/classes.limits.ts +49 -20
  38. package/ts/classes.opencodeharness.ts +7 -7
  39. package/ts/classes.operations.ts +10 -1
  40. package/ts/classes.tui.ts +39 -7
  41. package/ts/index.ts +1 -0
  42. package/ts/interfaces.harness.ts +82 -8
  43. package/ts/interfaces.list.ts +21 -1
package/readme.md CHANGED
@@ -58,6 +58,8 @@ authswitch active # which account each provider is using right n
58
58
  authswitch codex stash # save the active credential under its account email
59
59
  authswitch codex list # Codex accounts, including an unsaved active login, with live status
60
60
  authswitch codex use [email] # activate one; prompts when no email is given
61
+ authswitch claude use [email] --stop # stop running Claude Code instances first
62
+ authswitch claude use [email] --keep-running # switch without stopping anything
61
63
  authswitch codex preuse <email> # send the default prompt without activating the account
62
64
  authswitch codex current # print the account currently in use
63
65
  authswitch codex drop <email> # forget a stash
@@ -79,16 +81,53 @@ the management TUI and human/JSON lists. With an AGL installation that supports
79
81
  authswitch coordination, the CLI delegates credential changes to AGL. AGL owns
80
82
  stopping and restarting its OpenCode runtime. Idle changes need no additional
81
83
  restart confirmation; active work requires consent to wait for it to finish.
82
- Unmanaged native processes must still be exited manually: a terminal session
83
- cannot safely be reconstructed from its PID. `stash --keep` is the exception --
84
- it only reads the native credential files and writes to authswitch's own store,
85
- so it works while OpenCode or Claude Code is running, and it is refused only if
86
- those files keep changing while they are read. The operations that replace a
87
- native login -- `stash` without `--keep`, `use`, and backend activation --
88
- still require the harness stopped, because a running one holds the outgoing
89
- token in memory and would write it back over the incoming login at its next
90
- refresh. Status lookups remain read-only and work while harnesses are running.
91
- `preuse` remains a Codex capability.
84
+ Every operation, including `use` and `stash`, now works while OpenCode or Claude
85
+ Code is running: the credential file is written atomically, the outgoing login is
86
+ re-saved from disk first, and a write that races a native rewrite is detected and
87
+ refused rather than half-applied. Status lookups remain read-only. `preuse`
88
+ remains a Codex capability.
89
+
90
+ #### Running instances during a switch
91
+
92
+ A harness that keeps running through a switch still costs something: it holds the
93
+ previous login in memory and can write it back at its next OAuth token refresh.
94
+ So before `use` and before `stash` without `--keep`, authswitch lists that
95
+ harness's own running instances -- pid, start time and command line -- and offers
96
+ to stop them:
97
+
98
+ ```
99
+ 2 Claude Code process(es) are running:
100
+ pid 4821 started 2026-09-16 14:02 UTC claude --session-id 9f2c
101
+ pid 5533 started 2026-09-16 14:02 UTC claude agents
102
+ ? Stop these 2 Claude Code process(es) first? (y/N)
103
+ ```
104
+
105
+ Answering yes sends `SIGTERM` to exactly those pids and waits up to ten seconds;
106
+ an instance that survives is reported by pid and the switch continues. Three flags
107
+ decide the same thing without a prompt:
108
+
109
+ | Flag | Effect |
110
+ | --- | --- |
111
+ | `--stop` | stop the listed instances gracefully, then switch |
112
+ | `--force-stop` | the same, then `SIGKILL` the ones that ignored `SIGTERM` |
113
+ | `--keep-running` | switch without stopping anything |
114
+
115
+ Without a terminal and without a flag, nothing is ever signalled: the instances are
116
+ listed, the switch proceeds, and the command says which flag would have changed that.
117
+ `SIGKILL` is only ever sent for `--force-stop`. Only pids authswitch enumerated for
118
+ the current user in that operation are signalled -- never a name or pattern match,
119
+ never another user's process -- and a pid is re-verified against a fresh listing
120
+ before a forced kill, so a recycled pid cannot inherit it. The session that is
121
+ running the command is listed but never stopped: stopping it would kill the switch.
122
+ On Windows the instances are listed but not stopped, because its process list does
123
+ not identify their owner. `stash --keep` needs none of this: it writes no native
124
+ file and is guarded by proving the native sources unchanged around the read.
125
+
126
+ After a switch that left instances running, the command says so explicitly, because
127
+ one of them refreshing its token can put the previous account back. A backend
128
+ activation through `AuthSwitchService` gets that same caveat in its outcome, but no
129
+ listing and no offer: a host has no terminal to consent with, and a supervisor that
130
+ completed the mutation itself owns its own runtime's lifecycle.
92
131
 
93
132
  `authswitch codex login` and `authswitch opencode login openai` use the shared
94
133
  OpenAI device login flow. They display a verification link and code, then save
@@ -149,7 +188,21 @@ the local file login, so launch-time flags, managed policies and credentials sup
149
188
  by embedding applications remain under Claude Code's control.
150
189
 
151
190
  Claude's direct OAuth profile and usage lookups report the plan, weekly and five-hour
152
- usage, model-specific windows, and available extra-usage budget information.
191
+ usage, per-model and per-surface windows, and available extra-usage budget information.
192
+ The usage response's `limits[]` rows are the authoritative window list: which
193
+ meters apply, their scope, the service's `severity` (`normal`, `warning`,
194
+ `critical`) and its headline pick are Claude's, so a new per-model weekly limit
195
+ such as **Fable weekly** appears without an authswitch release. `list --json` keeps
196
+ the rows in the service's order; the tables and `limits --json` order them as
197
+ described under **Condensed views**. Rows are classified on their
198
+ kind and reset group exactly as sent. Labels combine the service's model or surface
199
+ name with the window period, and such a scoped row is treated as a feature limit.
200
+ Rows in a reset group authswitch cannot represent (anything but the five-hour
201
+ session and the weekly window) are not shown; one **Unsupported limits**
202
+ availability fact names them. A malformed row, or a `limits` value that is not a
203
+ list, makes the whole usage lookup unavailable rather than partially guessed.
204
+ Responses without `limits[]` rows fall back to the older keyed weekly, five-hour
205
+ and model windows.
153
206
  These endpoints require the `user:profile` scope and may reject expired or restricted
154
207
  logins. A null reset timestamp is shown as **Not scheduled**. Billing renewal and
155
208
  cancellation dates and earned reset credits are unavailable from these endpoints;
@@ -201,7 +254,8 @@ an uncertain request. `--json` remains a list-only option.
201
254
  ### Management dashboard
202
255
 
203
256
  `authswitch --tui` opens a resizable account table with subscription, usage and
204
- reset availability, usage bars, scrollable account details and an activity log.
257
+ reset availability, usage bars for the lead window and the next general window,
258
+ scrollable account details and an activity log.
205
259
  With multiple registered adapters, a harness selector switches the view; account
206
260
  selection and mutations always belong to the displayed harness. Status loads
207
261
  incrementally through each account's read-only API, without activating it.
@@ -283,17 +337,25 @@ output to Codex. Each section includes saved accounts and an identifiable active
283
337
  login even if it has never been saved. `*` marks the active account, and the saved
284
338
  marker checks the credential itself rather than just the existence of metadata.
285
339
 
286
- The overview compares login state, plan provenance, weekly usage, earned resets and
287
- the weekly reset countdown. Countdowns use days, hours and minutes, such as
340
+ The overview compares login state, plan provenance, lead-window usage (the
341
+ provider's headline window, otherwise weekly), earned resets and that window's
342
+ reset countdown. Countdowns use days, hours and minutes, such as
288
343
  `7d 5h 6min`;
289
344
  `<1min` means less than a minute remains and `due` means the reported deadline has
290
345
  passed, without claiming the service has refreshed the quota. All countdowns share
291
346
  the same snapshot time.
292
- Usage and countdown refer to the same general account window, preferring weekly.
293
- If no weekly window is reported, the longest general window is shown with its
294
- actual duration. Other exhausted general windows retain their own reset warning.
295
- Feature limits such as Spark and code review have a separate table and never
296
- determine the general summary. The TUI uses the same weekly-first selection.
347
+ Usage and countdown refer to the same window. When the provider picks a headline
348
+ window (Claude does, for example its Fable weekly limit), that window leads.
349
+ Otherwise the
350
+ general account window leads, preferring weekly; if no weekly window is reported,
351
+ the longest general window is shown with its actual duration. Other exhausted
352
+ general windows retain their own reset warning. A general window is named by its
353
+ period (`Weekly`, `5h`) unless another shown general window has the same period; a
354
+ feature limit, or a general window sharing its period, is named by its label
355
+ (`Fable weekly: 100% used`, `Claude weekly (weekly_oauth) exhausted`). Feature
356
+ limits such as Spark and code review have a separate table and determine the
357
+ summary only when the provider picks one as its headline. The TUI uses the same
358
+ selection and names.
297
359
  Window durations come from the provider, not from the plan name or
298
360
  primary/secondary position; a Pro account with only a general weekly window gets
299
361
  no invented 5-hour limit.
@@ -319,9 +381,9 @@ Account limits
319
381
  ┌──────────────────────┬─────────────────────────┬───────────────────────┬────────┬───────────────┐
320
382
  │ Provider │ Account │ Limit type │ Used % │ Resets in │
321
383
  ├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
322
- │ Claude Code │ phil@example.com │ Claude weekly │ 88% │ 4d 3h │
323
- │ Claude Code │ phil@example.com │ Claude five-hour │ 63% │ 1h 2m │
324
- │ Claude Code │ phil@example.com │ Opus weekly │ 100% │ 4d 3h │
384
+ │ Claude Code │ phil@example.com │ Claude weekly │ 75% │ 4d 3h │
385
+ │ Claude Code │ phil@example.com │ Claude five-hour │ 12% │ 1h 2m │
386
+ │ Claude Code │ phil@example.com │ Fable weekly │ 88% │ 2d 5h │
325
387
  │ Codex │ alice@example.com │ Codex secondary │ 12% │ 3d 4h │
326
388
  │ Codex │ alice@example.com │ Codex primary │ 37% │ 2h 13m │
327
389
  │ Codex │ alice@example.com │ Code review secondary │ 100% │ 3h 59m │
@@ -343,9 +405,18 @@ account never blanks another's numbers. `not scheduled` means the provider repor
343
405
  usage without a reset deadline; it is not the same as `n/a`.
344
406
 
345
407
  Limit types are the provider's own window names, ordered general-before-feature and
346
- weekly-first within an account. The provider column names the harness, qualified by
408
+ weekly-first within an account. Claude's per-model and per-surface limits carry the
409
+ service's name (`Fable weekly`); an unscoped Claude meter other than the five-hour
410
+ session and the weekly total keeps its kind in parentheses
411
+ (`Claude weekly (weekly_oauth)`). The provider column names the harness, qualified by
347
412
  the credential slot when a harness owns several (`OpenCode / openai`).
348
413
 
414
+ **Used %** is coloured by the provider's own reading when it reports one: orange for
415
+ `warning`, red for `critical`, uncoloured for `normal`. Without a reading, `n/a` is
416
+ orange and an exhausted window (100% or more) is red. Colour is omitted when
417
+ `NO_COLOR` is set, output is not a terminal, or the terminal is narrower than 40
418
+ columns.
419
+
349
420
  `authswitch active` answers "what am I logged in as" for every harness at once:
350
421
 
351
422
  ```
@@ -369,6 +440,22 @@ exist, `none` means neither. **Saved** is how long ago `authswitch` last saved t
369
440
  login, or `not saved` — which is also the warning that a switch could not bring it
370
441
  back. A harness with no active login keeps its row and explains itself in a note.
371
442
 
443
+ **Source** also answers whether the file still holds what the last switch wrote.
444
+ When authswitch switches a login it records that account and the hash of the
445
+ credential it wrote, in its own store, never in the harness file. If a later read
446
+ finds a different account in that slot, the row's source reads
447
+ `credential file (changed)` and a note explains it:
448
+
449
+ ```
450
+ note — Claude Code · bob@example.test: The credential file changed since the last
451
+ switch (likely a running instance refreshed the previous login). It now holds
452
+ bob@example.test, which is saved. Run authswitch claude use alice@example.test again.
453
+ ```
454
+
455
+ `authswitch <harness> current` prints the same sentence. The same account with a
456
+ rotated token is that account refreshing itself and is not reported; re-running the
457
+ switch re-arms the check against the file's current contents.
458
+
372
459
  Both commands accept a harness qualifier (`authswitch claude limits`) and `--json`.
373
460
  Both are read-only: they never activate, save or clear a login, they need no harness
374
461
  stopped, and every provider lookup is bounded by the same 10-second timeout and
@@ -385,19 +472,27 @@ arguments to these commands exit with 2.
385
472
  `limits --json` emits `IAccountLimits` and `active --json` emits `IActiveAccounts`:
386
473
  the same rows as the tables, plus the machine-readable fields the tables condense —
387
474
  `accountId`, `slotId`, `scope`, `windowSeconds`, the ISO `resetAt` beside the human
388
- `resetsIn`, the ISO `savedAt` beside the human `savedAgo`, and the per-row
389
- `unavailableReason` that the footnotes summarise. Both carry `schemaVersion: 1`, the
475
+ `resetsIn`, the provider's `severity` (`normal`, `warning`, `critical`, or null when
476
+ it gave none) and `headline` (true for the window the provider picks for a
477
+ single-value summary), the ISO `savedAt` beside the human `savedAgo`, the per-row
478
+ `unavailableReason` that the footnotes summarise, and `drift`, which carries the
479
+ expected and current account ids, their labels, whether the current one is saved,
480
+ the ISO `switchedAt` of the switch that was undone, and the same sentence as `reason`. Both carry `schemaVersion: 1`, the
390
481
  shared `generatedAt` snapshot every countdown is relative to, and `complete`, which
391
482
  is false when any account or harness could not be read.
392
483
 
393
484
  The exported `IAccountList` contract contains:
394
485
 
395
486
  - `schemaVersion: 2`, `generatedAt` (ISO UTC), and `complete`.
396
- - `harnesses[]`: `id`, `label`, `loginHint`, `saveUnavailableReason`, `accounts`, and
397
- harness-level `problems` (distinguishing failed discovery from an empty list).
487
+ - `harnesses[]`: `id`, `label`, `loginHint`, `saveUnavailableReason`, `accounts`,
488
+ `credentialDrift` (slots whose file no longer holds the account the last switch
489
+ wrote), and harness-level `problems` (distinguishing failed discovery from an
490
+ empty list).
398
491
  - Each account's opaque `id`, `label`, `isActive`, verified `isStashed`, `savedAt`,
399
492
  `details`, and `status` containing all labelled `facts`, `problems`, and optional
400
- typed `summary` fields. Missing fields remain omitted, not replaced by zero.
493
+ typed `summary` fields. Missing fields remain omitted, not replaced by zero;
494
+ `summary.usageWindows[]` carries `severity` and `headline` only when the provider
495
+ reported them.
401
496
 
402
497
  JSON retains full fact strings and absolute ISO reset/expiry timestamps. It exports
403
498
  the credential-free adapter contract, never credentials, raw HTTP responses or
@@ -502,11 +597,18 @@ explicit zeroes remain zero. A successful reset-detail lookup supplies the summa
502
597
  count; if it fails, the limits response can still supply availability.
503
598
 
504
599
  Each usage window may set `scope: 'account' | 'feature'` (default `account`).
505
- Adapters mark model-specific or feature-specific quotas as `feature` so those
506
- quotas remain available in JSON and details without distorting the general
507
- account summary. Weekly windows are identified by `durationSeconds: 604800`;
600
+ Adapters mark model-specific or feature-specific quotas as `feature`; those quotas
601
+ appear in the feature tables, JSON and details and lead the account summary only
602
+ when the provider marks one as its headline (see below). Weekly windows are identified by `durationSeconds: 604800`;
508
603
  neither window labels nor plan names are parsed to infer limits.
509
604
 
605
+ A window may also carry the provider's own reading: `severity?: TUsageSeverity`
606
+ (`'normal' | 'warning' | 'critical'`) and `headline?: true` for the window the
607
+ provider picks for a single-value summary. Adapters set them only when the provider
608
+ reports them, never from local thresholds. A headline window leads the account
609
+ summary whatever its scope; the first one in the adapter's order wins if several are
610
+ marked.
611
+
510
612
  Optional `summary.billing` contains provider-reported `hasActiveSubscription`,
511
613
  `autoRenew`, `renewsAt`, `cancelsAt`, and `expiresAt`, independent of the plan name.
512
614
  Dates are ISO UTC and omitted
@@ -529,6 +631,8 @@ scripts must qualify them. Account references never resolve across harnesses.
529
631
  | What | Where |
530
632
  | --- | --- |
531
633
  | Stashed credentials | `~/.authswitch/codex/<email>/auth.json` (mode 0600, in a 0700 directory) |
634
+ | Saved OpenCode/Claude logins | `~/.authswitch/<harness>/<id>.json` (mode 0600, in a 0700 directory) |
635
+ | Last switch per credential slot | `~/.authswitch/<harness>/switches.json` (account ids, credential hashes and timestamps; no credentials) |
532
636
  | Stash metadata and enrollments | `~/.authswitch/codex/<email>/stash.json` |
533
637
  | Codex' active credential | `$CODEX_HOME/auth.json`, default `~/.codex/auth.json` |
534
638
  | Codex' remote-control enrollments | the `remote_control_enrollments` table in `$CODEX_HOME/state_<n>.sqlite` |
@@ -543,6 +647,8 @@ Account identity comes from the `id_token` inside the credential — the email c
543
647
 
544
648
  **Other Codex clients keep running.** Stopping the managed app-server does not stop an editor extension that spawned its own. Close it, or expect it to keep using the credential it already loaded.
545
649
 
650
+ **A running OpenCode or Claude Code can undo a switch.** It holds the previous login in memory and rewrites the credential file at its next token refresh. Authswitch lists those instances, offers to stop them, warns when any keep running, and detects the overwrite afterwards — but it cannot prevent it while they run. Restart them after a switch.
651
+
546
652
  **The Codex stash is keyed by email.** Two Codex workspaces with the same email
547
653
  cannot both be saved under that key. Their account IDs are distinguished during
548
654
  listing and identity checks, and a collision refuses to overwrite either login.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/authswitch',
6
- version: '2.3.0',
6
+ version: '3.1.0',
7
7
  description: 'Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status'
8
8
  }
package/ts/accounts.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessState, IHarnessStatusSummary } from './interfaces.harness.js';
1
+ import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessState, IHarnessUsageWindow } from './interfaces.harness.js';
2
2
  import { plainText } from './formatting.js';
3
3
 
4
4
  export interface IAccountRow {
@@ -41,31 +41,65 @@ export const compactUntil = (timestampArg: string | null, nowArg: number, unavai
41
41
  if (remaining <= 0) return 'due';
42
42
  return remaining < 60000 ? '<1m' : twoUnits(remaining);
43
43
  };
44
- /** The same two-unit form for a past timestamp, for "saved 3d 4h ago". */
44
+ /**
45
+ * The same two-unit form for a past timestamp, for "saved 3d 4h ago".
46
+ *
47
+ * The result is always a bare duration or the caller's unavailable marker, never a phrase: callers
48
+ * suffix it ("... ago"), and a phrase would read as "just now ago". A span under a minute is `<1m`,
49
+ * exactly as `compactUntil` reports one.
50
+ */
45
51
  export const compactSince = (timestampArg: string | null, nowArg: number, unavailableArg = 'n/a'): string => {
46
52
  if (timestampArg === null) return unavailableArg;
47
53
  const elapsed = nowArg - Date.parse(timestampArg);
48
54
  if (!Number.isFinite(elapsed)) return unavailableArg;
49
- return elapsed < 60000 ? 'just now' : twoUnits(elapsed);
55
+ return elapsed < 60000 ? '<1m' : twoUnits(elapsed);
50
56
  };
57
+ /**
58
+ * One sentence for a credential file that no longer holds the account the last switch wrote.
59
+ *
60
+ * It names what the file holds now and how to put the intended account back, because the usual
61
+ * cause -- a harness instance that kept running through the switch and then refreshed its
62
+ * in-memory login -- leaves no other trace.
63
+ */
64
+ export const credentialDriftNote = (harnessIdArg: string, driftArg: IHarnessCredentialDrift): string =>
65
+ 'The credential file changed since the last switch (likely a running instance refreshed the previous login). '
66
+ + (driftArg.currentLabel === null ? 'No login is active in that slot now. '
67
+ : `It now holds ${plainText(driftArg.currentLabel)}${driftArg.currentIsSaved ? ', which is saved' : ', which is not saved'}. `)
68
+ + (driftArg.expectedLabel === null ? 'Switch to the intended account again.'
69
+ : `Run authswitch ${plainText(harnessIdArg)} use ${plainText(driftArg.expectedLabel)} again.`);
70
+
51
71
  export const accountPlan = (rowArg: IAccountRow): string => {
52
72
  const subscription = rowArg.status?.summary?.subscription;
53
73
  return subscription ? `${plainText(subscription.plan)}${subscription.source === 'stored' ? ' (stored; unverified)' : ' (live)'}` : rowArg.status ? 'Unavailable' : 'Loading';
54
74
  };
55
- type TUsageWindow = NonNullable<IHarnessStatusSummary['usageWindows']>[number];
56
75
  type TAccountStatusRow = Pick<IAccountRow, 'status'>;
57
- export const usagePeriod = (windowArg: TUsageWindow): string => windowArg.durationSeconds === 604800 ? 'Weekly' : duration(windowArg.durationSeconds);
76
+ /**
77
+ * How a single-value summary names a window among the windows it shows. An account window is named by
78
+ * its period while it is the only shown account window of that period; a feature window, or an account
79
+ * window sharing its period with another, is named by its own label, so no meter reads as another's.
80
+ */
81
+ export const usageWindowName = (windowArg: IHarnessUsageWindow, shownArg: readonly IHarnessUsageWindow[]): string =>
82
+ windowArg.scope === 'feature' || shownArg.some(other => other !== windowArg && other.scope !== 'feature' && other.durationSeconds === windowArg.durationSeconds)
83
+ ? plainText(windowArg.label)
84
+ : windowArg.durationSeconds === 604800 ? 'Weekly' : duration(windowArg.durationSeconds);
58
85
  /** Order by scope and actual duration: a provider's primary slot need not be five hours. */
59
- export const orderedUsageWindows = (windowsArg: readonly TUsageWindow[] = []): TUsageWindow[] => [...windowsArg].sort((left, right) =>
86
+ export const orderedUsageWindows = (windowsArg: readonly IHarnessUsageWindow[] = []): IHarnessUsageWindow[] => [...windowsArg].sort((left, right) =>
60
87
  Number(left.scope === 'feature') - Number(right.scope === 'feature') ||
61
88
  Number(left.durationSeconds !== 604800) - Number(right.durationSeconds !== 604800) || right.durationSeconds - left.durationSeconds);
62
- export const accountUsageWindows = (rowArg: TAccountStatusRow): TUsageWindow[] | undefined => {
89
+ /**
90
+ * The windows a single-value summary shows, lead window first: the provider's headline pick, whatever
91
+ * its scope, then the account windows in display order. Other feature quotas never lead a summary.
92
+ */
93
+ export const accountUsageWindows = (rowArg: TAccountStatusRow): IHarnessUsageWindow[] | undefined => {
63
94
  const windows = rowArg.status?.summary?.usageWindows;
64
- return windows === undefined ? undefined : orderedUsageWindows(windows.filter(window => window.scope !== 'feature'));
95
+ if (windows === undefined) return undefined;
96
+ const headline = windows.find(window => window.headline === true);
97
+ const accountWindows = orderedUsageWindows(windows.filter(window => window !== headline && window.scope !== 'feature'));
98
+ return headline === undefined ? accountWindows : [headline, ...accountWindows];
65
99
  };
66
100
  export const accountUsage = (rowArg: TAccountStatusRow): string => {
67
101
  const windows = accountUsageWindows(rowArg);
68
- return windows?.length ? `${usagePeriod(windows[0])}: ${windows[0].usedPercent}% used` : windows ? 'None reported' : rowArg.status ? 'Unavailable' : 'Loading';
102
+ return windows?.length ? `${usageWindowName(windows[0], windows)}: ${windows[0].usedPercent}% used` : windows ? 'None reported' : rowArg.status ? 'Unavailable' : 'Loading';
69
103
  };
70
104
  export const accountResets = (rowArg: IAccountRow): string => rowArg.status?.summary?.resets === undefined ? rowArg.status ? 'Unavailable' : 'Loading' : String(rowArg.status.summary.resets.available);
71
105
  export const accountNextReset = (rowArg: TAccountStatusRow, nowArg = Date.now()): string => {
@@ -76,7 +110,7 @@ export const accountQuotaSummary = (rowArg: TAccountStatusRow, nowArg: number):
76
110
  const windows = accountUsageWindows(rowArg);
77
111
  return [accountUsage(rowArg), ...(windows?.length ? [
78
112
  `Reset in: ${accountNextReset(rowArg, nowArg)}`,
79
- ...windows.slice(1).filter(window => window.usedPercent >= 100).map(window => `${usagePeriod(window)} exhausted\nResets in: ${until(window.resetAt, nowArg)}`),
113
+ ...windows.slice(1).filter(window => window.usedPercent >= 100).map(window => `${usageWindowName(window, windows)} exhausted\nResets in: ${until(window.resetAt, nowArg)}`),
80
114
  ] : [])].join('\n');
81
115
  };
82
116
  export const accountState = (rowArg: IAccountRow): string => `${rowArg.account.isActive ? '* active, ' : ''}${rowArg.account.isStashed ? 'saved' : 'not saved'}`;
@@ -16,7 +16,7 @@ const publicStatus = (statusArg: IHarnessAccountStatus): IHarnessAccountStatus =
16
16
  ...(summary === undefined ? {} : { summary: {
17
17
  ...(summary.subscription === undefined ? {} : { subscription: { plan: summary.subscription.plan, source: summary.subscription.source } }),
18
18
  ...(summary.billing === undefined ? {} : { billing: { hasActiveSubscription: summary.billing.hasActiveSubscription, autoRenew: summary.billing.autoRenew, renewsAt: summary.billing.renewsAt, cancelsAt: summary.billing.cancelsAt, expiresAt: summary.billing.expiresAt } }),
19
- ...(summary.usageWindows === undefined ? {} : { usageWindows: summary.usageWindows.map(({ label, scope, durationSeconds, usedPercent, resetAt }) => ({ label, scope, durationSeconds, usedPercent, resetAt })) }),
19
+ ...(summary.usageWindows === undefined ? {} : { usageWindows: summary.usageWindows.map(({ label, scope, durationSeconds, usedPercent, resetAt, severity, headline }) => ({ label, scope, durationSeconds, usedPercent, resetAt, severity, headline })) }),
20
20
  ...(summary.resets === undefined ? {} : { resets: {
21
21
  available: summary.resets.available,
22
22
  ...(summary.resets.details === undefined ? {} : { details: summary.resets.details.map(({ status, kind, expiresAt }) => ({ status, kind, expiresAt })) }),
@@ -28,10 +28,11 @@ const publicStatus = (statusArg: IHarnessAccountStatus): IHarnessAccountStatus =
28
28
  export const readAccountList = async (harnessesArg: IAuthHarness[]): Promise<IAccountList> => {
29
29
  const harnesses: IHarnessAccountList[] = [];
30
30
  for (const harness of harnessesArg) {
31
- const result: IHarnessAccountList = { id: harness.id, label: harness.label, loginHint: harness.loginHint, saveUnavailableReason: null, accounts: [], problems: [] };
31
+ const result: IHarnessAccountList = { id: harness.id, label: harness.label, loginHint: harness.loginHint, saveUnavailableReason: null, accounts: [], credentialDrift: [], problems: [] };
32
32
  try {
33
33
  const { state, rows } = await readAccountRows(harness);
34
34
  result.saveUnavailableReason = state.saveUnavailableReason;
35
+ result.credentialDrift = state.credentialDrift ?? [];
35
36
  result.accounts = rows.map(({ account, status }) => ({
36
37
  id: account.id, label: account.label, isActive: account.isActive, isStashed: account.isStashed,
37
38
  savedAt: account.savedAt, details: [...account.details], ...(account.slotId === undefined ? {} : { slotId: account.slotId }),
@@ -1,9 +1,10 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
3
- import { CredentialStore, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw, requireHarnessStopped } from './classes.credentialstore.js';
3
+ import { CredentialStore, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw } from './classes.credentialstore.js';
4
+ import { HarnessProcesses } from './classes.harnessprocesses.js';
4
5
  import { ClaudeAccountStatus } from './classes.claudestatus.js';
5
6
  import { writeSecretFileAtomically } from './helpers.js';
6
- import type { IHarnessAccountStatus } from './interfaces.harness.js';
7
+ import type { IHarnessAccountStatus, IHarnessProcessControl } from './interfaces.harness.js';
7
8
 
8
9
  export interface IClaudeCodeHarnessOptions {
9
10
  configDir?: string;
@@ -12,7 +13,8 @@ export interface IClaudeCodeHarnessOptions {
12
13
  env?: NodeJS.ProcessEnv;
13
14
  platform?: NodeJS.Platform;
14
15
  fetch?: typeof fetch;
15
- checkStopped?: () => void;
16
+ /** Running-instance inspection, injectable for isolated installations and tests. */
17
+ processes?: IHarnessProcessControl;
16
18
  /** Local settings sources to inspect for credential overrides, in increasing precedence. */
17
19
  settingsFiles?: string[];
18
20
  }
@@ -30,7 +32,7 @@ export class ClaudeCodeHarness extends FileHarness {
30
32
  private readonly env: NodeJS.ProcessEnv;
31
33
  private readonly platform: NodeJS.Platform;
32
34
  private readonly status: ClaudeAccountStatus;
33
- private readonly checkStopped: () => void;
35
+ public readonly processes: IHarnessProcessControl;
34
36
  private readonly settingsFiles: string[];
35
37
 
36
38
  constructor(options: IClaudeCodeHarnessOptions = {}) {
@@ -43,11 +45,10 @@ export class ClaudeCodeHarness extends FileHarness {
43
45
  this.store = new CredentialStore(this.id, options.stashRoot);
44
46
  this.platform = options.platform ?? process.platform;
45
47
  this.status = new ClaudeAccountStatus(options.fetch);
46
- this.checkStopped = options.checkStopped ?? (() => requireHarnessStopped('claude'));
48
+ this.processes = options.processes ?? new HarnessProcesses('claude', { platform: this.platform });
47
49
  this.settingsFiles = options.settingsFiles ?? [plugins.path.join(dir, 'settings.json'),
48
50
  plugins.path.join(process.cwd(), '.claude/settings.json'), plugins.path.join(process.cwd(), '.claude/settings.local.json')];
49
51
  }
50
- protected beforeMutation(): void { this.checkStopped(); }
51
52
  /** One record holds the credential and its matching metadata, so both files must be proven stable. */
52
53
  protected nativeSources(): (string | null)[] { return [readCredentialRaw(this.file), readCredentialRaw(this.configFile)]; }
53
54
  protected inspect(credential: Record<string, unknown>, slotId: string): IFileAccount {
@@ -1,8 +1,107 @@
1
1
  import { credentialRecord, credentialText } from './classes.credentialstore.js';
2
- import type { IHarnessAccountStatus, IHarnessStatusSummary } from './interfaces.harness.js';
2
+ import { plainText } from './formatting.js';
3
+ import type { IHarnessAccountStatus, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
3
4
 
4
5
  class ClaudeStatusError extends Error {}
5
6
 
7
+ interface IClaudeUsageWindows {
8
+ windows: IHarnessUsageWindow[];
9
+ /** Server rows whose reset group the window model cannot represent, named by kind and group. */
10
+ unsupported: string[];
11
+ }
12
+
13
+ /** Reset times are normalised to ISO 8601 UTC; an unparsable one rejects the whole usage response. */
14
+ const resetTimestamp = (value: unknown): string | null => value == null ? null : new Date(String(value)).toISOString();
15
+
16
+ const SERVER_NAME_LENGTH = 64;
17
+
18
+ /** A server-supplied name becomes label text: no control characters, bounded by code points, and never blank. */
19
+ const serverName = (value: unknown): string => {
20
+ const name = typeof value === 'string' ? Array.from(plainText(value).trim()).slice(0, SERVER_NAME_LENGTH).join('').trimEnd() : '';
21
+ if (!name) throw new Error('Unsupported Claude usage name.');
22
+ return name;
23
+ };
24
+
25
+ /** Keyed windows, read only from a usage body that carries no limits[] rows. */
26
+ const KEYED_WINDOWS = [
27
+ ['five_hour', 'Claude five-hour', 18000, 'account'], ['seven_day', 'Claude weekly', 604800, 'account'],
28
+ ['seven_day_oauth_apps', 'OAuth apps weekly', 604800, 'feature'], ['seven_day_opus', 'Opus weekly', 604800, 'feature'], ['seven_day_sonnet', 'Sonnet weekly', 604800, 'feature'],
29
+ ] as const;
30
+
31
+ const keyedWindows = (usage: Record<string, unknown>): IHarnessUsageWindow[] => {
32
+ const windows: IHarnessUsageWindow[] = [];
33
+ for (const [key, label, durationSeconds, scope] of KEYED_WINDOWS) {
34
+ if (usage[key] == null) continue;
35
+ const window = credentialRecord(usage[key]);
36
+ if (window.utilization == null) continue;
37
+ if (typeof window.utilization !== 'number' || !Number.isFinite(window.utilization) || window.utilization < 0) throw new Error('Unsupported Claude usage utilization.');
38
+ windows.push({ label, durationSeconds, scope, usedPercent: window.utilization, resetAt: resetTimestamp(window.resets_at) });
39
+ }
40
+ return windows;
41
+ };
42
+
43
+ /**
44
+ * The limits[] row groups the window model represents: each group's duration, the period word its
45
+ * labels use, and the kind of the group's unscoped account meter, which keeps the plain label.
46
+ */
47
+ const LIMIT_GROUPS: ReadonlyMap<string, { durationSeconds: number; period: string; accountKind: string }> = new Map([
48
+ ['session', { durationSeconds: 18000, period: 'five-hour', accountKind: 'session' }],
49
+ ['weekly', { durationSeconds: 604800, period: 'weekly', accountKind: 'weekly_all' }],
50
+ ]);
51
+
52
+ const isUsageSeverity = (value: unknown): value is TUsageSeverity => value === 'normal' || value === 'warning' || value === 'critical';
53
+
54
+ /** A scope part is absent (null or omitted) or names the model or surface a row is for. */
55
+ const scopeSubject = (value: unknown): string | undefined => value == null ? undefined : serverName(credentialRecord(value).display_name);
56
+
57
+ /**
58
+ * The server's usage rows, in the server's order. An unscoped row is an account meter; a model- or
59
+ * surface-scoped row is a feature quota named by the server's own label. Rows are classified on the
60
+ * kind and group exactly as sent; only the text shown to users is sanitised. Every row is validated
61
+ * before a row in an unrepresentable group is set aside, so a malformed row can never hide behind one.
62
+ */
63
+ const limitWindows = (rows: readonly unknown[]): IClaudeUsageWindows => {
64
+ const windows: IHarnessUsageWindow[] = [];
65
+ const unsupported = new Set<string>();
66
+ for (const value of rows) {
67
+ const row = credentialRecord(value);
68
+ const { kind, group } = row;
69
+ if (typeof kind !== 'string' || typeof group !== 'string') throw new Error('Unsupported Claude usage meter.');
70
+ const kindName = serverName(kind);
71
+ const groupName = serverName(group);
72
+ const usedPercent = row.percent;
73
+ if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent) || usedPercent < 0) throw new Error('Unsupported Claude usage percentage.');
74
+ const resetAt = resetTimestamp(row.resets_at);
75
+ const scope: Record<string, unknown> = row.scope == null ? {} : credentialRecord(row.scope);
76
+ const model = scopeSubject(scope.model);
77
+ const surface = scopeSubject(scope.surface);
78
+ const subject = model ?? surface;
79
+ const meter = LIMIT_GROUPS.get(group);
80
+ if (!meter) {
81
+ unsupported.add(`${kindName} in group ${groupName}`);
82
+ continue;
83
+ }
84
+ const severity = row.severity;
85
+ windows.push({
86
+ label: subject === undefined ? `Claude ${meter.period}${kind === meter.accountKind ? '' : ` (${kindName})`}` : `${subject} ${meter.period}`,
87
+ durationSeconds: meter.durationSeconds, scope: subject === undefined ? 'account' : 'feature', usedPercent, resetAt,
88
+ ...(isUsageSeverity(severity) ? { severity } : {}),
89
+ ...(row.is_active === true ? { headline: true as const } : {}),
90
+ });
91
+ }
92
+ return { windows, unsupported: [...unsupported] };
93
+ };
94
+
95
+ /**
96
+ * A usage body's windows. Its limits[] rows are authoritative whenever the server sends any; the keyed
97
+ * fields describe the same meters and are read only from a body without rows.
98
+ */
99
+ const claudeUsageWindows = (usage: Record<string, unknown>): IClaudeUsageWindows => {
100
+ const limits = usage.limits;
101
+ if (limits != null && !Array.isArray(limits)) throw new Error('Unsupported Claude usage limits.');
102
+ return Array.isArray(limits) && limits.length ? limitWindows(limits) : { windows: keyedWindows(usage), unsupported: [] };
103
+ };
104
+
6
105
  /** Read-only Claude Code OAuth account endpoints. No refresh, inference, or browser session is used. */
7
106
  export class ClaudeAccountStatus {
8
107
  constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
@@ -64,18 +163,7 @@ export class ClaudeAccountStatus {
64
163
  } else result.problems.push(`Profile: ${profile.reason instanceof ClaudeStatusError ? profile.reason.message : 'Lookup failed.'}`);
65
164
  if (usage.status === 'fulfilled') {
66
165
  try {
67
- const windows: NonNullable<IHarnessStatusSummary['usageWindows']> = [];
68
- for (const [key, label, durationSeconds, scope] of [
69
- ['five_hour', 'Claude five-hour', 18000, 'account'], ['seven_day', 'Claude weekly', 604800, 'account'],
70
- ['seven_day_oauth_apps', 'OAuth apps weekly', 604800, 'feature'], ['seven_day_opus', 'Opus weekly', 604800, 'feature'], ['seven_day_sonnet', 'Sonnet weekly', 604800, 'feature'],
71
- ] as const) {
72
- if (usage.value[key] == null) continue;
73
- const window = credentialRecord(usage.value[key]);
74
- if (window.utilization == null) continue;
75
- if (typeof window.utilization !== 'number' || !Number.isFinite(window.utilization) || window.utilization < 0) throw new Error();
76
- const resetAt = window.resets_at == null ? null : new Date(String(window.resets_at)).toISOString();
77
- windows.push({ label, durationSeconds, scope, usedPercent: window.utilization, resetAt });
78
- }
166
+ const { windows, unsupported } = claudeUsageWindows(usage.value);
79
167
  result.summary = { ...result.summary, usageWindows: windows };
80
168
  for (const window of windows) result.facts.push({ section: 'Usage', summaryKey: 'usageWindows', label: window.label, value: `${window.usedPercent}% used; ${window.resetAt ? 'resets ' + window.resetAt : 'reset not scheduled'}` });
81
169
  if (usage.value.extra_usage != null) {
@@ -90,7 +178,7 @@ export class ClaudeAccountStatus {
90
178
  }
91
179
  if (limit !== undefined && limit > 0 && used !== undefined) result.facts.push({ section: 'Extra usage', label: 'Monthly budget used', value: `${Math.round(used / limit * 10000) / 100}%` });
92
180
  }
93
- if (Array.isArray(usage.value.limits) && usage.value.limits.length) result.facts.push({ section: 'Availability', label: 'Additional limits', value: 'The service also returned limits without a supported reset-window contract.' });
181
+ if (unsupported.length) result.facts.push({ section: 'Availability', label: 'Unsupported limits', value: `The service returned limits without a supported reset window, which are not shown: ${unsupported.join(', ')}.` });
94
182
  } catch { result.problems.push('Claude usage response is unsupported; missing values were not inferred.'); }
95
183
  } else result.problems.push(`Usage: ${usage.reason instanceof ClaudeStatusError ? usage.reason.message : 'Lookup failed.'}`);
96
184
  result.facts.push({ section: 'Availability', label: 'Billing dates and earned resets', value: 'Renewal dates, cancellation dates and earned reset credits are not exposed by these Claude Code endpoints.' });