@modelprofile.com/authswitch 2.2.0 → 3.0.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 +18 -1
  3. package/dist_ts/accounts.js +55 -1
  4. package/dist_ts/classes.accountlist.d.ts +4 -0
  5. package/dist_ts/classes.accountlist.js +20 -13
  6. package/dist_ts/classes.claudecodeharness.d.ts +4 -4
  7. package/dist_ts/classes.claudecodeharness.js +5 -5
  8. package/dist_ts/classes.cli.d.ts +17 -0
  9. package/dist_ts/classes.cli.js +128 -16
  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.d.ts +24 -0
  17. package/dist_ts/classes.limits.js +200 -0
  18. package/dist_ts/classes.opencodeharness.d.ts +4 -5
  19. package/dist_ts/classes.opencodeharness.js +5 -5
  20. package/dist_ts/classes.operations.d.ts +9 -1
  21. package/dist_ts/classes.operations.js +4 -2
  22. package/dist_ts/classes.tui.js +48 -3
  23. package/dist_ts/index.d.ts +1 -0
  24. package/dist_ts/index.js +2 -1
  25. package/dist_ts/interfaces.harness.d.ts +61 -0
  26. package/dist_ts/interfaces.list.d.ts +67 -1
  27. package/package.json +2 -2
  28. package/readme.md +155 -19
  29. package/ts/00_commitinfo_data.ts +1 -1
  30. package/ts/accounts.ts +50 -1
  31. package/ts/classes.accountlist.ts +24 -11
  32. package/ts/classes.claudecodeharness.ts +7 -6
  33. package/ts/classes.cli.ts +105 -16
  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 +209 -0
  38. package/ts/classes.opencodeharness.ts +7 -7
  39. package/ts/classes.operations.ts +10 -1
  40. package/ts/classes.tui.ts +34 -3
  41. package/ts/index.ts +1 -0
  42. package/ts/interfaces.harness.ts +62 -0
  43. package/ts/interfaces.list.ts +72 -1
@@ -19,6 +19,63 @@ export interface IHarnessLoginHandle {
19
19
  cancel(): Promise<void>;
20
20
  close(): Promise<void>;
21
21
  }
22
+ /** One running instance of a harness binary, owned by the current user. */
23
+ export interface IHarnessProcess {
24
+ pid: number;
25
+ /** Executable name as the platform reports it, without a path. */
26
+ command: string;
27
+ /** Bounded command line, for identifying which instance this is. Never parsed for identity. */
28
+ commandLine: string;
29
+ /** Start time, which distinguishes a reused pid from the process we enumerated. */
30
+ startedAt: string | null;
31
+ /** An ancestor of the running authswitch command: stopping it would kill this command. */
32
+ isAncestor: boolean;
33
+ }
34
+ export interface IHarnessStopOutcome {
35
+ /** Instances that exited within the bounded wait. */
36
+ stopped: IHarnessProcess[];
37
+ /** Instances that were still running when the wait elapsed. */
38
+ survivors: IHarnessProcess[];
39
+ /** Instances that were re-verified and then killed, only ever with an explicit force request. */
40
+ forced: IHarnessProcess[];
41
+ }
42
+ /**
43
+ * Enumerating and stopping a harness's own running instances.
44
+ *
45
+ * Only pids this control enumerated for the current user are ever signalled, and a stop is graceful
46
+ * unless the caller explicitly forces it. Adapters whose process lifecycle belongs to another
47
+ * manager (Codex' app-server daemon) expose no control at all.
48
+ */
49
+ export interface IHarnessProcessControl {
50
+ readonly binary: string;
51
+ /** Why enumerated instances cannot be signalled on this platform; null when they can. */
52
+ readonly stopUnavailableReason: string | null;
53
+ list(): IHarnessProcess[];
54
+ stop(processesArg: readonly IHarnessProcess[], optionsArg?: {
55
+ force?: boolean;
56
+ timeoutMs?: number;
57
+ }): Promise<IHarnessStopOutcome>;
58
+ }
59
+ /**
60
+ * A live credential that no longer holds the account the last switch activated.
61
+ *
62
+ * Authswitch records the hash of what it wrote, in its own store. A different hash with the same
63
+ * account is that account's own token refresh; a different account is what a running instance
64
+ * writing its in-memory login back looks like.
65
+ */
66
+ export interface IHarnessCredentialDrift {
67
+ slotId?: string;
68
+ /** The account the recorded switch activated. */
69
+ expectedAccountId: string;
70
+ /** Its saved label, or null when that account is no longer saved. */
71
+ expectedLabel: string | null;
72
+ /** The account the credential file holds now, or null when the slot has no active login. */
73
+ currentAccountId: string | null;
74
+ currentLabel: string | null;
75
+ /** Whether the current account has a saved copy of its own. */
76
+ currentIsSaved: boolean;
77
+ switchedAt: string;
78
+ }
22
79
  /** Account IDs are opaque and scoped to one harness. Credentials never cross this interface. */
23
80
  export interface IHarnessAccount {
24
81
  id: string;
@@ -34,6 +91,8 @@ export interface IHarnessState {
34
91
  accounts: IHarnessAccount[];
35
92
  /** Explains why an active login cannot be saved, when applicable. */
36
93
  saveUnavailableReason: string | null;
94
+ /** Slots whose credential file no longer holds the account the last switch wrote. */
95
+ credentialDrift?: IHarnessCredentialDrift[];
37
96
  }
38
97
  export interface IHarnessAccountStatus {
39
98
  /** Provider-reported facts, already labelled with their units and provenance. */
@@ -108,6 +167,8 @@ export interface IAuthHarness {
108
167
  readonly loginHint: string;
109
168
  readonly diagnosticsLabel: string;
110
169
  readState(): THarnessResult<IHarnessState>;
170
+ /** Running instances of this harness, when the adapter owns their lifecycle. */
171
+ readonly processes?: IHarnessProcessControl;
111
172
  readAccountStatus(accountIdArg: string): Promise<IHarnessAccountStatus>;
112
173
  readonly loginProviders?: IHarnessLoginProvider[];
113
174
  beginLogin?(options: IHarnessLoginOptions): Promise<IHarnessLoginHandle>;
@@ -1,4 +1,4 @@
1
- import type { IHarnessAccount, IHarnessAccountStatus } from './interfaces.harness.js';
1
+ import type { IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift } from './interfaces.harness.js';
2
2
  /** Credential-free, versioned output of list --json. Missing status fields stay omitted. */
3
3
  export interface IAccountList {
4
4
  schemaVersion: 2;
@@ -14,6 +14,72 @@ export interface IHarnessAccountList {
14
14
  accounts: (IHarnessAccount & {
15
15
  status: IHarnessAccountStatus;
16
16
  })[];
17
+ /** Slots whose credential file no longer holds the account the last switch wrote. */
18
+ credentialDrift: IHarnessCredentialDrift[];
17
19
  /** A state lookup failure is distinct from an empty account list. */
18
20
  problems: string[];
19
21
  }
22
+ /** Credential-free, versioned output of limits --json. One entry per account and limit type. */
23
+ export interface IAccountLimits {
24
+ schemaVersion: 1;
25
+ generatedAt: string;
26
+ complete: boolean;
27
+ limits: IAccountLimitRow[];
28
+ /** Why a row has no numbers. Never a substitute for a value. */
29
+ notes: string[];
30
+ }
31
+ export interface IAccountLimitRow {
32
+ harnessId: string;
33
+ /** Harness label, qualified by the credential slot when the harness owns several. */
34
+ provider: string;
35
+ /** The account's public label, which is its email wherever the provider reports one. */
36
+ account: string | null;
37
+ accountId: string | null;
38
+ slotId?: string;
39
+ isActive: boolean;
40
+ /** The provider's own window name, or null when no limit data is available. */
41
+ limitType: string | null;
42
+ scope: 'account' | 'feature' | null;
43
+ windowSeconds: number | null;
44
+ /** Never inferred: null means the provider reported no percentage, not zero usage. */
45
+ usedPercent: number | null;
46
+ resetAt: string | null;
47
+ /** Human countdown for the same `resetAt`, relative to `generatedAt`. */
48
+ resetsIn: string;
49
+ unavailableReason: string | null;
50
+ }
51
+ /** Credential-free, versioned output of active --json. One entry per active credential slot. */
52
+ export interface IActiveAccounts {
53
+ schemaVersion: 1;
54
+ generatedAt: string;
55
+ complete: boolean;
56
+ active: IActiveAccountRow[];
57
+ notes: string[];
58
+ }
59
+ export interface IActiveAccountRow {
60
+ harnessId: string;
61
+ provider: string;
62
+ account: string | null;
63
+ accountId: string | null;
64
+ slotId?: string;
65
+ /** When authswitch last saved this login; null when it was never stashed. */
66
+ savedAt: string | null;
67
+ /** Human elapsed time for the same `savedAt`, relative to `generatedAt`. */
68
+ savedAgo: string;
69
+ /** Where the shown account comes from. Whether it is also saved is the `savedAt` field's answer. */
70
+ source: 'credential file' | 'stash only' | 'none';
71
+ unavailableReason: string | null;
72
+ /** Set when the credential file no longer holds the account the last switch wrote. */
73
+ drift: IActiveAccountDrift | null;
74
+ }
75
+ /** A credential file that changed underneath a switch, with both accounts named where known. */
76
+ export interface IActiveAccountDrift {
77
+ expectedAccountId: string;
78
+ expectedAccount: string | null;
79
+ currentAccountId: string | null;
80
+ currentAccount: string | null;
81
+ currentIsSaved: boolean;
82
+ switchedAt: string;
83
+ /** The sentence the table footnotes, for consumers that display one string. */
84
+ reason: string;
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelprofile.com/authswitch",
3
- "version": "2.2.0",
3
+ "version": "3.0.0",
4
4
  "private": false,
5
5
  "description": "Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status",
6
6
  "main": "dist_ts/index.js",
@@ -25,7 +25,7 @@
25
25
  "devDependencies": {
26
26
  "@git.zone/tsbuild": "^4.4.3",
27
27
  "@git.zone/tsrun": "^2.0.6",
28
- "@git.zone/tstest": "^6.0.0",
28
+ "@git.zone/tstest": "^6.1.1",
29
29
  "@types/node": "26.5.0"
30
30
  },
31
31
  "dependencies": {
package/readme.md CHANGED
@@ -16,11 +16,7 @@ Remote control makes that worse. A ChatGPT client pairs with the local Codex app
16
16
 
17
17
  ## Install
18
18
 
19
- This package is published to the private Verdaccio registry, not to npmjs, so the scope has to be mapped first:
20
-
21
- ```bash
22
- pnpm config set @modelprofile.com:registry https://verdaccio.lossless.digital
23
- ```
19
+ The package is published to npmjs:
24
20
 
25
21
  ```bash
26
22
  pnpm install -g @modelprofile.com/authswitch
@@ -57,9 +53,13 @@ authswitch --tui # full-screen account management
57
53
  authswitch codex --tui # start the dashboard on Codex
58
54
  authswitch list # all known accounts across registered harnesses, with live status
59
55
  authswitch list --json # complete account/status document for scripts
56
+ authswitch limits # every account and limit type: used % and reset countdown
57
+ authswitch active # which account each provider is using right now
60
58
  authswitch codex stash # save the active credential under its account email
61
59
  authswitch codex list # Codex accounts, including an unsaved active login, with live status
62
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
63
63
  authswitch codex preuse <email> # send the default prompt without activating the account
64
64
  authswitch codex current # print the account currently in use
65
65
  authswitch codex drop <email> # forget a stash
@@ -81,16 +81,53 @@ the management TUI and human/JSON lists. With an AGL installation that supports
81
81
  authswitch coordination, the CLI delegates credential changes to AGL. AGL owns
82
82
  stopping and restarting its OpenCode runtime. Idle changes need no additional
83
83
  restart confirmation; active work requires consent to wait for it to finish.
84
- Unmanaged native processes must still be exited manually: a terminal session
85
- cannot safely be reconstructed from its PID. `stash --keep` is the exception --
86
- it only reads the native credential files and writes to authswitch's own store,
87
- so it works while OpenCode or Claude Code is running, and it is refused only if
88
- those files keep changing while they are read. The operations that replace a
89
- native login -- `stash` without `--keep`, `use`, and backend activation --
90
- still require the harness stopped, because a running one holds the outgoing
91
- token in memory and would write it back over the incoming login at its next
92
- refresh. Status lookups remain read-only and work while harnesses are running.
93
- `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.
94
131
 
95
132
  `authswitch codex login` and `authswitch opencode login openai` use the shared
96
133
  OpenAI device login flow. They display a verification link and code, then save
@@ -310,18 +347,113 @@ marked unverified. Renewal/cancellation dates are shown only when an adapter can
310
347
  provide live billing data. Codex uses the desktop app's account-check endpoint for
311
348
  automatic renewal, explicit renewal/cancellation dates and subscription expiry.
312
349
 
350
+ ### Condensed views
351
+
352
+ `authswitch limits` answers "how much is left, and when does it come back" in one
353
+ table. It is one row per account **and** limit type, sorted by provider and then by
354
+ account email, with a two-unit countdown (`2h 13m`, `3d 4h`, `45m`, `<1m`, `due`):
355
+
356
+ ```
357
+ Account limits
358
+ ┌──────────────────────┬─────────────────────────┬───────────────────────┬────────┬───────────────┐
359
+ │ Provider │ Account │ Limit type │ Used % │ Resets in │
360
+ ├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
361
+ │ Claude Code │ phil@example.com │ Claude weekly │ 88% │ 4d 3h │
362
+ │ Claude Code │ phil@example.com │ Claude five-hour │ 63% │ 1h 2m │
363
+ │ Claude Code │ phil@example.com │ Opus weekly │ 100% │ 4d 3h │
364
+ │ Codex │ alice@example.com │ Codex secondary │ 12% │ 3d 4h │
365
+ │ Codex │ alice@example.com │ Codex primary │ 37% │ 2h 13m │
366
+ │ Codex │ alice@example.com │ Code review secondary │ 100% │ 3h 59m │
367
+ │ Codex │ bob@example.com │ n/a │ n/a │ n/a │
368
+ │ OpenCode / anthropic │ anthropic API key 20e75 │ n/a │ n/a │ n/a │
369
+ │ │ 1c3b707 │ │ │ │
370
+ │ OpenCode / openai │ alice@example.com │ Codex primary │ 0% │ not scheduled │
371
+ └──────────────────────┴─────────────────────────┴───────────────────────┴────────┴───────────────┘
372
+
373
+ n/a — Codex · bob@example.com: Subscription and limits: Login expired or was rejected.
374
+ n/a — OpenCode / anthropic · anthropic API key 20e751c3b707: This provider login does not expose a supported subscription or quota API.
375
+ ```
376
+
377
+ A number is never invented. An account whose provider exposes no quota API, whose
378
+ lookup failed, or whose login cannot be read shows `n/a` in **Used %** and
379
+ **Resets in**, and the footnote under the table says why — one line per distinct
380
+ reason. A provider is never dropped from the table for lacking data, and one failed
381
+ account never blanks another's numbers. `not scheduled` means the provider reported
382
+ usage without a reset deadline; it is not the same as `n/a`.
383
+
384
+ Limit types are the provider's own window names, ordered general-before-feature and
385
+ weekly-first within an account. The provider column names the harness, qualified by
386
+ the credential slot when a harness owns several (`OpenCode / openai`).
387
+
388
+ `authswitch active` answers "what am I logged in as" for every harness at once:
389
+
390
+ ```
391
+ Active accounts
392
+ ┌──────────────────────┬────────────────────────────────┬────────────┬─────────────────┐
393
+ │ Provider │ Account │ Saved │ Source │
394
+ ├──────────────────────┼────────────────────────────────┼────────────┼─────────────────┤
395
+ │ Claude Code │ phil@example.com │ 45m ago │ credential file │
396
+ │ Codex │ alice@example.com │ 2h 13m ago │ credential file │
397
+ │ Flex │ none │ n/a │ none │
398
+ │ OpenCode / anthropic │ anthropic API key 20e751c3b707 │ 30d ago │ credential file │
399
+ │ OpenCode / openai │ alice@example.com │ not saved │ credential file │
400
+ └──────────────────────┴────────────────────────────────┴────────────┴─────────────────┘
401
+
402
+ note — OpenCode / openai · alice@example.com: This active login is not saved yet; authswitch cannot restore it after a switch.
403
+ ```
404
+
405
+ **Source** is where the shown login comes from: `credential file` is the harness's
406
+ own live credential, `stash only` means nothing is active and only saved copies
407
+ exist, `none` means neither. **Saved** is how long ago `authswitch` last saved that
408
+ login, or `not saved` — which is also the warning that a switch could not bring it
409
+ back. A harness with no active login keeps its row and explains itself in a note.
410
+
411
+ **Source** also answers whether the file still holds what the last switch wrote.
412
+ When authswitch switches a login it records that account and the hash of the
413
+ credential it wrote, in its own store, never in the harness file. If a later read
414
+ finds a different account in that slot, the row's source reads
415
+ `credential file (changed)` and a note explains it:
416
+
417
+ ```
418
+ note — Claude Code · bob@example.test: The credential file changed since the last
419
+ switch (likely a running instance refreshed the previous login). It now holds
420
+ bob@example.test, which is saved. Run authswitch claude use alice@example.test again.
421
+ ```
422
+
423
+ `authswitch <harness> current` prints the same sentence. The same account with a
424
+ rotated token is that account refreshing itself and is not reported; re-running the
425
+ switch re-arms the check against the file's current contents.
426
+
427
+ Both commands accept a harness qualifier (`authswitch claude limits`) and `--json`.
428
+ Both are read-only: they never activate, save or clear a login, they need no harness
429
+ stopped, and every provider lookup is bounded by the same 10-second timeout and
430
+ isolated per account as `authswitch list`.
431
+
313
432
  ### JSON output
314
433
 
315
434
  `authswitch list --json`, `authswitch codex list --json`, and the `ls` alias write
316
435
  one JSON document to stdout, with no color, tables or progress messages. `--json`
317
- may appear before or after the command and requires `list` or `ls`. It cannot be
318
- combined with a mutation or interactive mode. Unknown list arguments exit with 2.
436
+ may appear before or after the command and requires `list`, `ls`, `limits` or
437
+ `active`. It cannot be combined with a mutation or interactive mode. Unknown
438
+ arguments to these commands exit with 2.
439
+
440
+ `limits --json` emits `IAccountLimits` and `active --json` emits `IActiveAccounts`:
441
+ the same rows as the tables, plus the machine-readable fields the tables condense —
442
+ `accountId`, `slotId`, `scope`, `windowSeconds`, the ISO `resetAt` beside the human
443
+ `resetsIn`, the ISO `savedAt` beside the human `savedAgo`, the per-row
444
+ `unavailableReason` that the footnotes summarise, and `drift`, which carries the
445
+ expected and current account ids, their labels, whether the current one is saved,
446
+ the ISO `switchedAt` of the switch that was undone, and the same sentence as `reason`. Both carry `schemaVersion: 1`, the
447
+ shared `generatedAt` snapshot every countdown is relative to, and `complete`, which
448
+ is false when any account or harness could not be read.
319
449
 
320
450
  The exported `IAccountList` contract contains:
321
451
 
322
452
  - `schemaVersion: 2`, `generatedAt` (ISO UTC), and `complete`.
323
- - `harnesses[]`: `id`, `label`, `loginHint`, `saveUnavailableReason`, `accounts`, and
324
- harness-level `problems` (distinguishing failed discovery from an empty list).
453
+ - `harnesses[]`: `id`, `label`, `loginHint`, `saveUnavailableReason`, `accounts`,
454
+ `credentialDrift` (slots whose file no longer holds the account the last switch
455
+ wrote), and harness-level `problems` (distinguishing failed discovery from an
456
+ empty list).
325
457
  - Each account's opaque `id`, `label`, `isActive`, verified `isStashed`, `savedAt`,
326
458
  `details`, and `status` containing all labelled `facts`, `problems`, and optional
327
459
  typed `summary` fields. Missing fields remain omitted, not replaced by zero.
@@ -456,6 +588,8 @@ scripts must qualify them. Account references never resolve across harnesses.
456
588
  | What | Where |
457
589
  | --- | --- |
458
590
  | Stashed credentials | `~/.authswitch/codex/<email>/auth.json` (mode 0600, in a 0700 directory) |
591
+ | Saved OpenCode/Claude logins | `~/.authswitch/<harness>/<id>.json` (mode 0600, in a 0700 directory) |
592
+ | Last switch per credential slot | `~/.authswitch/<harness>/switches.json` (account ids, credential hashes and timestamps; no credentials) |
459
593
  | Stash metadata and enrollments | `~/.authswitch/codex/<email>/stash.json` |
460
594
  | Codex' active credential | `$CODEX_HOME/auth.json`, default `~/.codex/auth.json` |
461
595
  | Codex' remote-control enrollments | the `remote_control_enrollments` table in `$CODEX_HOME/state_<n>.sqlite` |
@@ -470,6 +604,8 @@ Account identity comes from the `id_token` inside the credential — the email c
470
604
 
471
605
  **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.
472
606
 
607
+ **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.
608
+
473
609
  **The Codex stash is keyed by email.** Two Codex workspaces with the same email
474
610
  cannot both be saved under that key. Their account IDs are distinguished during
475
611
  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.2.0',
6
+ version: '3.0.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, IHarnessStatusSummary } from './interfaces.harness.js';
2
2
  import { plainText } from './formatting.js';
3
3
 
4
4
  export interface IAccountRow {
@@ -19,6 +19,55 @@ export const until = (timestampArg: string | null, nowArg: number): string => {
19
19
  const hours = Math.floor(minutes % 1440 / 60);
20
20
  return [days ? `${days}d` : '', hours ? `${hours}h` : '', minutes % 60 ? `${minutes % 60}min` : ''].filter(Boolean).join(' ');
21
21
  };
22
+ /**
23
+ * A two-unit countdown for condensed tables: `2h 13m`, `3d 4h`, `45m`.
24
+ *
25
+ * `until` stays the long form used by the list view and the TUI. This one trades the third unit for
26
+ * a column that never wraps, and it never invents a deadline: an unknown or unparseable timestamp is
27
+ * reported as such rather than as zero time remaining.
28
+ */
29
+ const twoUnits = (millisecondsArg: number): string => {
30
+ const minutes = Math.floor(millisecondsArg / 60000);
31
+ const days = Math.floor(minutes / 1440);
32
+ const hours = Math.floor(minutes % 1440 / 60);
33
+ if (days) return hours ? `${days}d ${hours}h` : `${days}d`;
34
+ if (hours) return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`;
35
+ return `${minutes}m`;
36
+ };
37
+ export const compactUntil = (timestampArg: string | null, nowArg: number, unavailableArg = 'n/a'): string => {
38
+ if (timestampArg === null) return 'not scheduled';
39
+ const remaining = Date.parse(timestampArg) - nowArg;
40
+ if (!Number.isFinite(remaining)) return unavailableArg;
41
+ if (remaining <= 0) return 'due';
42
+ return remaining < 60000 ? '<1m' : twoUnits(remaining);
43
+ };
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
+ */
51
+ export const compactSince = (timestampArg: string | null, nowArg: number, unavailableArg = 'n/a'): string => {
52
+ if (timestampArg === null) return unavailableArg;
53
+ const elapsed = nowArg - Date.parse(timestampArg);
54
+ if (!Number.isFinite(elapsed)) return unavailableArg;
55
+ return elapsed < 60000 ? '<1m' : twoUnits(elapsed);
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
+
22
71
  export const accountPlan = (rowArg: IAccountRow): string => {
23
72
  const subscription = rowArg.status?.summary?.subscription;
24
73
  return subscription ? `${plainText(subscription.plan)}${subscription.source === 'stored' ? ' (stored; unverified)' : ' (live)'}` : rowArg.status ? 'Unavailable' : 'Loading';
@@ -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 }),
@@ -49,21 +50,33 @@ export const readAccountList = async (harnessesArg: IAuthHarness[]): Promise<IAc
49
50
  };
50
51
  };
51
52
 
53
+ export const consoleWidth = (): number => process.stdout.columns ?? 100;
54
+ export const consoleHeading = (textArg: string): void => { process.stdout.write(`\n${bold(textArg)}\n`); };
55
+
56
+ /** One table implementation for every command, including the label-per-line fallback on narrow terminals. */
57
+ export const consoleTable = async <TRow>(
58
+ outArg: plugins.smartconsole.SmartConsole,
59
+ rowsArg: TRow[],
60
+ columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[],
61
+ ): Promise<void> => {
62
+ if (!rowsArg.length) return;
63
+ if (consoleWidth() < 40) {
64
+ for (const row of rowsArg) {
65
+ process.stdout.write(columnsArg.map(column => ` ${column.title}: ${String(column.value(row) ?? 'Unavailable').replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
66
+ }
67
+ return;
68
+ }
69
+ await outArg.table(rowsArg, { columns: columnsArg, overflow: 'wrap', theme: { header: { bold: true, foreground: 'cyan' }, border: { dim: true } } });
70
+ };
71
+
52
72
  /** Shared, provider-independent list presentation. Adapters supply grouping metadata. */
53
73
  export class AccountListRenderer {
54
74
  constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
55
- private get width(): number { return process.stdout.columns ?? 100; }
56
- private heading(textArg: string): void { process.stdout.write(`\n${bold(textArg)}\n`); }
75
+ private get width(): number { return consoleWidth(); }
76
+ private heading(textArg: string): void { consoleHeading(textArg); }
57
77
 
58
78
  private async table<TRow>(rowsArg: TRow[], columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[]): Promise<void> {
59
- if (!rowsArg.length) return;
60
- if (this.width < 40) {
61
- for (const row of rowsArg) {
62
- process.stdout.write(columnsArg.map(column => ` ${column.title}: ${String(column.value(row) ?? 'Unavailable').replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
63
- }
64
- return;
65
- }
66
- await this.out.table(rowsArg, { columns: columnsArg, overflow: 'wrap', theme: { header: { bold: true, foreground: 'cyan' }, border: { dim: true } } });
79
+ await consoleTable(this.out, rowsArg, columnsArg);
67
80
  }
68
81
 
69
82
  public async render(listArg: IAccountList): Promise<void> {
@@ -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 {