@modelprofile.com/authswitch 2.3.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 (41) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/accounts.d.ts +16 -2
  3. package/dist_ts/accounts.js +21 -3
  4. package/dist_ts/classes.accountlist.js +3 -2
  5. package/dist_ts/classes.claudecodeharness.d.ts +4 -4
  6. package/dist_ts/classes.claudecodeharness.js +5 -5
  7. package/dist_ts/classes.cli.d.ts +12 -0
  8. package/dist_ts/classes.cli.js +98 -9
  9. package/dist_ts/classes.credentialstore.d.ts +20 -7
  10. package/dist_ts/classes.credentialstore.js +36 -23
  11. package/dist_ts/classes.fileharness.d.ts +23 -3
  12. package/dist_ts/classes.fileharness.js +102 -16
  13. package/dist_ts/classes.harnessprocesses.d.ts +62 -0
  14. package/dist_ts/classes.harnessprocesses.js +198 -0
  15. package/dist_ts/classes.limits.js +37 -15
  16. package/dist_ts/classes.opencodeharness.d.ts +4 -5
  17. package/dist_ts/classes.opencodeharness.js +5 -5
  18. package/dist_ts/classes.operations.d.ts +9 -1
  19. package/dist_ts/classes.operations.js +4 -2
  20. package/dist_ts/classes.tui.js +48 -3
  21. package/dist_ts/index.d.ts +1 -0
  22. package/dist_ts/index.js +2 -1
  23. package/dist_ts/interfaces.harness.d.ts +61 -0
  24. package/dist_ts/interfaces.list.d.ts +16 -1
  25. package/package.json +1 -1
  26. package/readme.md +77 -14
  27. package/ts/00_commitinfo_data.ts +1 -1
  28. package/ts/accounts.ts +23 -3
  29. package/ts/classes.accountlist.ts +2 -1
  30. package/ts/classes.claudecodeharness.ts +7 -6
  31. package/ts/classes.cli.ts +77 -9
  32. package/ts/classes.credentialstore.ts +41 -18
  33. package/ts/classes.fileharness.ts +80 -16
  34. package/ts/classes.harnessprocesses.ts +225 -0
  35. package/ts/classes.limits.ts +38 -16
  36. package/ts/classes.opencodeharness.ts +7 -7
  37. package/ts/classes.operations.ts +10 -1
  38. package/ts/classes.tui.ts +34 -3
  39. package/ts/index.ts +1 -0
  40. package/ts/interfaces.harness.ts +62 -0
  41. package/ts/interfaces.list.ts +17 -1
@@ -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,8 @@ 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
  }
@@ -67,4 +69,17 @@ export interface IActiveAccountRow {
67
69
  /** Where the shown account comes from. Whether it is also saved is the `savedAt` field's answer. */
68
70
  source: 'credential file' | 'stash only' | 'none';
69
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;
70
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelprofile.com/authswitch",
3
- "version": "2.3.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",
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
@@ -369,6 +408,22 @@ exist, `none` means neither. **Saved** is how long ago `authswitch` last saved t
369
408
  login, or `not saved` — which is also the warning that a switch could not bring it
370
409
  back. A harness with no active login keeps its row and explains itself in a note.
371
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
+
372
427
  Both commands accept a harness qualifier (`authswitch claude limits`) and `--json`.
373
428
  Both are read-only: they never activate, save or clear a login, they need no harness
374
429
  stopped, and every provider lookup is bounded by the same 10-second timeout and
@@ -385,16 +440,20 @@ arguments to these commands exit with 2.
385
440
  `limits --json` emits `IAccountLimits` and `active --json` emits `IActiveAccounts`:
386
441
  the same rows as the tables, plus the machine-readable fields the tables condense —
387
442
  `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
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
390
447
  shared `generatedAt` snapshot every countdown is relative to, and `complete`, which
391
448
  is false when any account or harness could not be read.
392
449
 
393
450
  The exported `IAccountList` contract contains:
394
451
 
395
452
  - `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).
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).
398
457
  - Each account's opaque `id`, `label`, `isActive`, verified `isStashed`, `savedAt`,
399
458
  `details`, and `status` containing all labelled `facts`, `problems`, and optional
400
459
  typed `summary` fields. Missing fields remain omitted, not replaced by zero.
@@ -529,6 +588,8 @@ scripts must qualify them. Account references never resolve across harnesses.
529
588
  | What | Where |
530
589
  | --- | --- |
531
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) |
532
593
  | Stash metadata and enrollments | `~/.authswitch/codex/<email>/stash.json` |
533
594
  | Codex' active credential | `$CODEX_HOME/auth.json`, default `~/.codex/auth.json` |
534
595
  | Codex' remote-control enrollments | the `remote_control_enrollments` table in `$CODEX_HOME/state_<n>.sqlite` |
@@ -543,6 +604,8 @@ Account identity comes from the `id_token` inside the credential — the email c
543
604
 
544
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.
545
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
+
546
609
  **The Codex stash is keyed by email.** Two Codex workspaces with the same email
547
610
  cannot both be saved under that key. Their account IDs are distinguished during
548
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.3.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 {
@@ -41,13 +41,33 @@ 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';
@@ -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 {
package/ts/classes.cli.ts CHANGED
@@ -7,15 +7,21 @@ import { AuthSwitchTui } from './classes.tui.js';
7
7
  import { AglAuthSwitchCoordinator, AuthSwitchOperations, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
8
8
  import { AccountListRenderer, readAccountList } from './classes.accountlist.js';
9
9
  import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
10
- import { duration, orderedUsageWindows, until } from './accounts.js';
10
+ import { credentialDriftNote, duration, orderedUsageWindows, until } from './accounts.js';
11
+ import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
11
12
  import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
12
13
  import type { CodexSwitcher } from './classes.codexswitcher.js';
13
- import type { IAuthHarness, IHarnessAccount, IHarnessOutcome, IHarnessState, IHarnessPreuseResult } from './interfaces.harness.js';
14
+ import type { IAuthHarness, IHarnessAccount, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome, IHarnessPreuseResult } from './interfaces.harness.js';
14
15
  import { bold, dim, green, orange, plainText, red } from './formatting.js';
15
16
 
16
17
  const canPrompt = (): boolean =>
17
18
  process.stdin.isTTY === true && process.stdout.isTTY === true && !process.env.CI;
18
19
  const COMMANDS = ['login', 'stash', 'list', 'ls', 'limits', 'active', 'use', 'preuse', 'current', 'drop', 'rm', 'doctor'];
20
+ /** What to do about the harness's own running instances before a credential file is rewritten. */
21
+ export type TStopMode = 'stop' | 'force-stop' | 'keep-running';
22
+ const STOP_FLAGS = ['--stop', '--force-stop', '--keep-running'];
23
+ /** Commands that replace or clear a native login, and therefore offer to stop its instances. */
24
+ const STOP_COMMANDS = ['use', 'stash'];
19
25
  /** Read-only overviews that work across every registered harness and support --json. */
20
26
  const OVERVIEW_COMMANDS = ['list', 'ls', 'limits', 'active'];
21
27
 
@@ -57,7 +63,8 @@ ${bold('Usage')}
57
63
  send one prompt through that account without switching
58
64
  authswitch <harness> stash [account/provider] [--keep]
59
65
  save one active login; --keep leaves it active
60
- authswitch <harness> use [account] activate a saved account; prompts when omitted
66
+ authswitch <harness> use [account] [--stop|--force-stop|--keep-running]
67
+ activate a saved account; prompts when omitted
61
68
  authswitch <harness> current show the active account
62
69
  authswitch <harness> login [provider]
63
70
  sign in and save a new account without switching
@@ -74,6 +81,13 @@ ${bold('Options')}
74
81
  -i, --interactive open the guided account manager
75
82
  --tui open the terminal management dashboard
76
83
  --json JSON output for list / ls / limits / active only
84
+ --stop stop this harness's running instances before the switch
85
+ --force-stop the same, then kill instances that ignore SIGTERM
86
+ --keep-running switch without stopping anything
87
+
88
+ A switch works while OpenCode or Claude Code is running. Instances that keep running hold
89
+ the previous login in memory and can write it back at their next token refresh, so the
90
+ switch offers to stop them first; without a terminal it leaves them running and says so.
77
91
 
78
92
  Preuse consumes the selected account's quota. Its default prompt is:
79
93
  "${defaultPreusePrompt}"
@@ -107,6 +121,18 @@ ${bold('Environment')}
107
121
  return 2;
108
122
  }
109
123
  }
124
+ const stopFlags = args.filter(value => STOP_FLAGS.includes(value));
125
+ if (stopFlags.length > 1) { process.stderr.write(`Use only one of ${STOP_FLAGS.join(', ')}.\n`); return 2; }
126
+ const stopMode = stopFlags.length ? stopFlags[0].slice(2) as TStopMode : undefined;
127
+ if (stopMode !== undefined) {
128
+ args.splice(args.indexOf(stopFlags[0]), 1);
129
+ const route = [...args];
130
+ if (this.harnesses.has(route[0])) route.shift();
131
+ if (!STOP_COMMANDS.includes(route[0]) || (route[0] === 'stash' && args.includes('--keep'))) {
132
+ process.stderr.write(`${STOP_FLAGS.join(', ')} apply to use and to stash without --keep; a save that keeps the login needs no stopped instance.\n`);
133
+ return 2;
134
+ }
135
+ }
110
136
  if (['-h', '--help', 'help'].includes(args[0]) || (args.length === 0 && !canPrompt())) {
111
137
  process.stdout.write(`${this.usage()}\n`);
112
138
  return 0;
@@ -154,9 +180,9 @@ ${bold('Environment')}
154
180
  const references = args.filter(value => value !== '--keep');
155
181
  if (references.length > 1 || references.some(value => value.startsWith('-'))) { process.stderr.write('stash accepts one active account or provider and --keep.\n'); return 2; }
156
182
  const account = await this.selectActive(harness, references[0]);
157
- return account ? this.reportOutcome(await this.mutate(harness, { harnessId: harness.id, action: 'save', keepActive: args.includes('--keep'), accountId: account.id })) : 1;
183
+ return account ? this.reportOutcome(await this.mutate(harness, { harnessId: harness.id, action: 'save', keepActive: args.includes('--keep'), accountId: account.id }, stopMode)) : 1;
158
184
  }
159
- case 'use': return await this.commandUse(harness, args[0]);
185
+ case 'use': return await this.commandUse(harness, args[0], false, stopMode);
160
186
  case 'current': this.printCurrent(harness, await harness.readState()); return 0;
161
187
  case 'drop':
162
188
  case 'rm': {
@@ -185,10 +211,51 @@ ${bold('Environment')}
185
211
  return new AuthSwitchTui([...this.harnesses.values()], this.out, this.operations).run(harnessArg);
186
212
  }
187
213
 
188
- private mutate(harness: IAuthHarness, mutation: TAuthSwitchMutation): Promise<IHarnessOutcome> {
214
+ private mutate(harness: IAuthHarness, mutation: TAuthSwitchMutation, stopModeArg?: TStopMode): Promise<IHarnessOutcome> {
215
+ const replacesNativeLogin = mutation.action === 'switch' || (mutation.action === 'save' && !mutation.keepActive);
189
216
  return this.operations.run(harness, mutation, canPrompt() ? async message => await this.out.prompts.ask({
190
217
  name: 'waitForIdle', type: 'confirm', message, default: false,
191
- }) === true : undefined);
218
+ }) === true : undefined, replacesNativeLogin ? () => this.settleRunningInstances(harness, stopModeArg) : undefined);
219
+ }
220
+
221
+ /**
222
+ * Decide what happens to the harness's own running instances before its credential file changes.
223
+ *
224
+ * The switch itself no longer depends on this: it writes atomically and re-saves the outgoing
225
+ * login either way. What a running instance still costs is durability -- it holds the previous
226
+ * login in memory and can write it back at its next refresh -- so the instances are named and
227
+ * stopping them is offered, never assumed. Without a terminal and without a flag nothing is
228
+ * signalled, and only pids enumerated here for this user are ever signalled at all.
229
+ */
230
+ private async settleRunningInstances(harnessArg: IAuthHarness, modeArg?: TStopMode): Promise<void> {
231
+ const control = harnessArg.processes;
232
+ if (!control) return;
233
+ let running: IHarnessProcess[];
234
+ try { running = control.list(); }
235
+ catch { process.stdout.write(`${orange(`Could not check for running ${plainText(harnessArg.label)} processes; continuing with the switch.`)}\n`); return; }
236
+ if (!running.length) return;
237
+ for (const line of describeHarnessProcesses(harnessArg.label, running)) process.stdout.write(`${line}\n`);
238
+ if (control.stopUnavailableReason !== null) { process.stdout.write(`${dim(control.stopUnavailableReason)}\n`); return; }
239
+ const stoppable = running.filter(item => !item.isAncestor);
240
+ if (!stoppable.length || modeArg === 'keep-running') return;
241
+ let stop = modeArg === 'stop' || modeArg === 'force-stop';
242
+ if (modeArg === undefined) {
243
+ if (!canPrompt()) { process.stdout.write(`${dim('Leaving them running. Use --stop, --force-stop or --keep-running to decide this without a terminal.')}\n`); return; }
244
+ stop = await this.out.prompts.ask({
245
+ name: 'stopHarnessProcesses', type: 'confirm',
246
+ message: `Stop these ${stoppable.length} ${harnessArg.label} process(es) first?`, default: false,
247
+ }) === true;
248
+ }
249
+ if (!stop) return;
250
+ let outcome: IHarnessStopOutcome;
251
+ // Stopping re-reads the process table; if that read fails now, the switch is still the safe part.
252
+ try { outcome = await control.stop(stoppable, { force: modeArg === 'force-stop' }); }
253
+ catch (error) {
254
+ // The reason ends the line so its own punctuation is not doubled.
255
+ process.stdout.write(`${orange(`Could not stop the running ${plainText(harnessArg.label)} process(es); leaving them running: ${plainText(error instanceof Error ? error.message : 'the process list could not be read.')}`)}\n`);
256
+ return;
257
+ }
258
+ for (const line of describeStopOutcome(harnessArg.label, outcome)) process.stdout.write(`${line}\n`);
192
259
  }
193
260
 
194
261
  private async commandLogin(harness: IAuthHarness, providerId?: string): Promise<number> {
@@ -415,6 +482,7 @@ ${bold('Environment')}
415
482
  process.stdout.write(`${dim('no account is currently active')}\n`);
416
483
  }
417
484
  if (stateArg.saveUnavailableReason) process.stdout.write(`${dim(stateArg.saveUnavailableReason)}\n`);
485
+ for (const drift of stateArg.credentialDrift ?? []) process.stdout.write(`${orange(credentialDriftNote(harnessArg.id, drift))}\n`);
418
486
  }
419
487
 
420
488
  private async selectActive(harness: IAuthHarness, reference?: string): Promise<IHarnessAccount | undefined> {
@@ -470,7 +538,7 @@ ${bold('Environment')}
470
538
  return null;
471
539
  }
472
540
 
473
- private async commandUse(harnessArg: IAuthHarness, referenceArg?: string, allowBackArg = false): Promise<number> {
541
+ private async commandUse(harnessArg: IAuthHarness, referenceArg?: string, allowBackArg = false, stopModeArg?: TStopMode): Promise<number> {
474
542
  let id: string | null = null;
475
543
  if (referenceArg) {
476
544
  id = await this.resolveAccount(harnessArg, referenceArg);
@@ -482,7 +550,7 @@ ${bold('Environment')}
482
550
  }
483
551
  id ??= await this.promptForAccount(harnessArg, `Which ${harnessArg.label} account should be active?`, allowBackArg);
484
552
  if (id === null) return allowBackArg ? 0 : 1;
485
- return this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: id }));
553
+ return this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: id }, stopModeArg));
486
554
  }
487
555
 
488
556
  private async promptForAccount(harnessArg: IAuthHarness, messageArg: string, allowBackArg: boolean, includeIncompleteArg = false): Promise<string | null> {
@@ -38,6 +38,21 @@ export const readCredentialDocument = (file: string): { raw: string | null; data
38
38
  catch { throw new Error('Credential file is unreadable, malformed, or too large; no changes were made.'); }
39
39
  };
40
40
 
41
+ /**
42
+ * What the last switch wrote into a native credential slot.
43
+ *
44
+ * This is authswitch's own state, never part of the harness file: it records the account and the
45
+ * hash of the credential that was activated, so a later read can tell a credential file that still
46
+ * holds that account from one a running instance has overwritten with a different login. It carries
47
+ * hashes and timestamps only, never a credential.
48
+ */
49
+ export interface ISwitchRecord {
50
+ slotId: string;
51
+ accountId: string;
52
+ credentialHash: string;
53
+ switchedAt: string;
54
+ }
55
+
41
56
  export interface IStoredCredential {
42
57
  schemaVersion: 1;
43
58
  id: string;
@@ -83,6 +98,32 @@ export class CredentialStore {
83
98
  return this.read(record.id);
84
99
  }
85
100
  public remove(id: string): void { this.read(id); plugins.fs.unlinkSync(this.file(id)); }
101
+ private get switchFile(): string { return plugins.path.join(this.dir, 'switches.json'); }
102
+ /** Advisory state: an absent, malformed or partial record reports no switch, never an error. */
103
+ public switchRecords(): ISwitchRecord[] {
104
+ let data: Record<string, unknown>;
105
+ try { data = readCredentialDocument(this.switchFile).data; } catch { return []; }
106
+ if (data.schemaVersion !== 1 || !Array.isArray(data.switches)) return [];
107
+ return data.switches.filter((item: unknown): item is ISwitchRecord => {
108
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return false;
109
+ const record = item as Record<string, unknown>;
110
+ return !!credentialText(record.slotId) && /^[a-f0-9]{64}$/.test(String(record.accountId))
111
+ && /^[a-f0-9]{64}$/.test(String(record.credentialHash))
112
+ && typeof record.switchedAt === 'string' && Number.isFinite(Date.parse(record.switchedAt));
113
+ });
114
+ }
115
+ public recordSwitch(entry: Omit<ISwitchRecord, 'switchedAt'>): void {
116
+ this.writeSwitchRecords([...this.switchRecords().filter(item => item.slotId !== entry.slotId), { ...entry, switchedAt: new Date().toISOString() }]);
117
+ }
118
+ public clearSwitch(slotId: string): void {
119
+ const remaining = this.switchRecords().filter(item => item.slotId !== slotId);
120
+ if (remaining.length) this.writeSwitchRecords(remaining);
121
+ else plugins.fs.rmSync(this.switchFile, { force: true });
122
+ }
123
+ private writeSwitchRecords(records: ISwitchRecord[]): void {
124
+ plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
125
+ writeSecretFileAtomically(this.switchFile, JSON.stringify({ schemaVersion: 1, switches: records }, null, 2) + '\n');
126
+ }
86
127
  public locked<T>(action: () => T): T {
87
128
  plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
88
129
  const file = plugins.path.join(this.dir, '.lock');
@@ -93,21 +134,3 @@ export class CredentialStore {
93
134
  finally { plugins.fs.closeSync(fd); plugins.fs.unlinkSync(file); }
94
135
  }
95
136
  }
96
-
97
- /**
98
- * Native harnesses hold their login in memory and rewrite it on their own refresh schedule, so a
99
- * credential written underneath a running one is destroyed by the next refresh. Operations that
100
- * replace a native login therefore require it stopped. Reading for a save that keeps the login in
101
- * place writes nothing native and is guarded by content stability instead (see FileHarness).
102
- */
103
- export const requireHarnessStopped = (binary: 'opencode' | 'claude'): void => {
104
- let names: string[];
105
- try {
106
- if (process.platform === 'win32') {
107
- names = plugins.childProcess.execFileSync('tasklist', ['/FO', 'CSV', '/NH'], { encoding: 'utf8', timeout: 5000 }).split('\n').map(line => line.split('",')[0].replace(/^"/, ''));
108
- } else {
109
- names = plugins.childProcess.execFileSync('ps', ['-u', String(process.getuid!()), '-o', 'comm='], { encoding: 'utf8', timeout: 5000 }).split('\n').map(line => plugins.path.basename(line.trim()));
110
- }
111
- } catch { throw new Error(`Could not verify that ${binary} is stopped. No login was changed.`); }
112
- if (names.some(name => name === binary || name === `${binary}.exe`)) throw new Error(`Exit running ${binary} processes before replacing or clearing its login, then start ${binary} again. Saving with --keep does not need this.`);
113
- };