@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
@@ -1,7 +1,8 @@
1
1
  import * as plugins from './plugins.js';
2
- import { compactSince, compactUntil, orderedUsageWindows } from './accounts.js';
2
+ import { compactSince, compactUntil, credentialDriftNote, orderedUsageWindows } from './accounts.js';
3
3
  import { consoleHeading, consoleTable } from './classes.accountlist.js';
4
- import type { IAccountList, IAccountLimitRow, IAccountLimits, IActiveAccountRow, IActiveAccounts, IHarnessAccountList } from './interfaces.list.js';
4
+ import type { IAccountList, IAccountLimitRow, IAccountLimits, IActiveAccountDrift, IActiveAccountRow, IActiveAccounts, IHarnessAccountList } from './interfaces.list.js';
5
+ import type { IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
5
6
  import { dim, plainText } from './formatting.js';
6
7
 
7
8
  type TListedAccount = IHarnessAccountList['accounts'][number];
@@ -34,7 +35,7 @@ const missingLimitReason = (accountArg: TListedAccount): string => {
34
35
  if (accountArg.status.summary?.usageWindows === undefined) {
35
36
  return availability[0] ?? 'This login does not expose a usage or quota API.';
36
37
  }
37
- return 'The provider reported no usage window for this account.';
38
+ return 'The provider reported no usage window that authswitch can show for this account.';
38
39
  };
39
40
 
40
41
  const accountKeys = (harnessArg: IHarnessAccountList, accountArg: TListedAccount) => ({
@@ -60,6 +61,13 @@ const noteFor = (rowArg: { provider: string; account: string | null; unavailable
60
61
  const collectNotes = (rowsArg: { provider: string; account: string | null; unavailableReason: string | null }[]): string[] =>
61
62
  [...new Set(rowsArg.map(noteFor).filter((note): note is string => note !== null))];
62
63
 
64
+ /** The drift a switch record proves, carried into the row document with both accounts named. */
65
+ const activeDrift = (harnessArg: IHarnessAccountList, driftArg: IHarnessCredentialDrift): IActiveAccountDrift => ({
66
+ expectedAccountId: driftArg.expectedAccountId, expectedAccount: driftArg.expectedLabel === null ? null : plainText(driftArg.expectedLabel),
67
+ currentAccountId: driftArg.currentAccountId, currentAccount: driftArg.currentLabel === null ? null : plainText(driftArg.currentLabel),
68
+ currentIsSaved: driftArg.currentIsSaved, switchedAt: driftArg.switchedAt, reason: credentialDriftNote(harnessArg.id, driftArg),
69
+ });
70
+
63
71
  /**
64
72
  * One row per account and limit type, projected from the account list.
65
73
  *
@@ -72,7 +80,7 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
72
80
  for (const harness of listArg.harnesses) {
73
81
  const problem = harnessProblem(harness);
74
82
  const empty = {
75
- limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE,
83
+ limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE, severity: null, headline: false,
76
84
  } as const;
77
85
  if (problem !== null) {
78
86
  rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty, unavailableReason: problem });
@@ -94,7 +102,7 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
94
102
  ...accountKeys(harness, account), isActive: account.isActive,
95
103
  limitType: plainText(window.label), scope: window.scope ?? 'account', windowSeconds: window.durationSeconds,
96
104
  usedPercent: window.usedPercent, resetAt: window.resetAt, resetsIn: compactUntil(window.resetAt, now, UNAVAILABLE),
97
- unavailableReason: null,
105
+ severity: window.severity ?? null, headline: window.headline === true, unavailableReason: null,
98
106
  });
99
107
  }
100
108
  }
@@ -112,10 +120,16 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
112
120
  export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
113
121
  const now = Date.parse(listArg.generatedAt);
114
122
  const rows: IActiveAccountRow[] = [];
123
+ const unattached: string[] = [];
115
124
  for (const harness of listArg.harnesses) {
116
125
  const problem = harnessProblem(harness);
126
+ const drifts = [...harness.credentialDrift];
127
+ const driftFor = (slotIdArg?: string): IActiveAccountDrift | null => {
128
+ const index = drifts.findIndex(item => item.slotId === slotIdArg);
129
+ return index < 0 ? null : activeDrift(harness, drifts.splice(index, 1)[0]);
130
+ };
117
131
  if (problem !== null) {
118
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem });
132
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem, drift: null });
119
133
  continue;
120
134
  }
121
135
  const active = harness.accounts.filter(account => account.isActive);
@@ -127,24 +141,37 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
127
141
  unavailableReason: harness.saveUnavailableReason !== null ? plainText(harness.saveUnavailableReason)
128
142
  : stashed.length ? `No login is active; ${stashed.length} saved account(s) can be activated with authswitch ${plainText(harness.id)} use.`
129
143
  : `No login is active and none is saved. ${plainText(harness.loginHint)}`,
144
+ drift: drifts.length ? activeDrift(harness, drifts.shift()!) : null,
130
145
  });
131
- continue;
132
- }
133
- for (const account of active) {
134
- rows.push({
135
- ...accountKeys(harness, account),
136
- savedAt: account.savedAt, savedAgo: compactSince(account.savedAt, now, UNAVAILABLE),
137
- source: 'credential file',
138
- unavailableReason: account.isStashed ? null : 'This active login is not saved yet; authswitch cannot restore it after a switch.',
139
- });
146
+ } else {
147
+ for (const account of active) {
148
+ rows.push({
149
+ ...accountKeys(harness, account),
150
+ savedAt: account.savedAt, savedAgo: compactSince(account.savedAt, now, UNAVAILABLE),
151
+ source: 'credential file',
152
+ unavailableReason: account.isStashed ? null : 'This active login is not saved yet; authswitch cannot restore it after a switch.',
153
+ drift: driftFor(account.slotId),
154
+ });
155
+ }
140
156
  }
157
+ // A drifted slot with no row of its own still has to be reported, not dropped.
158
+ for (const drift of drifts) unattached.push(`${providerName(harness)}${drift.slotId === undefined ? '' : ` / ${plainText(drift.slotId)}`}: ${credentialDriftNote(harness.id, drift)}`);
141
159
  }
142
160
  const active = byProviderThenAccount(rows);
143
- return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, active, notes: collectNotes(active) };
161
+ const driftNotes = active.filter(row => row.drift !== null)
162
+ .map(row => `${row.provider}${row.account === null ? '' : ` · ${row.account}`}: ${row.drift!.reason}`);
163
+ return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, active, notes: [...collectNotes(active), ...driftNotes, ...unattached] };
144
164
  };
145
165
 
146
166
  const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE : `${rowArg.usedPercent}%`;
147
167
 
168
+ const SEVERITY_COLORS: Record<TUsageSeverity, plugins.smartconsole.TColorName | undefined> = { normal: undefined, warning: 'orange', critical: 'red' };
169
+
170
+ /** The provider's own reading colours a window; without one, n/a and exhausted windows stand out. */
171
+ const percentColor = (rowArg: IAccountLimitRow): plugins.smartconsole.TColorName | undefined =>
172
+ rowArg.severity !== null ? SEVERITY_COLORS[rowArg.severity]
173
+ : rowArg.usedPercent === null ? 'orange' : rowArg.usedPercent >= 100 ? 'red' : undefined;
174
+
148
175
  /** Condensed presentation for `authswitch limits` and `authswitch active`. */
149
176
  export class CondensedRenderer {
150
177
  constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
@@ -163,7 +190,7 @@ export class CondensedRenderer {
163
190
  { key: 'account', title: 'Account', value: row => row.account ?? UNAVAILABLE, style: row => ({ bold: row.isActive }) },
164
191
  { key: 'limit', title: 'Limit type', value: row => row.limitType ?? UNAVAILABLE },
165
192
  { key: 'used', title: 'Used %', value: percent, align: 'right',
166
- style: row => ({ foreground: row.usedPercent === null ? 'orange' : row.usedPercent >= 100 ? 'red' : undefined }) },
193
+ style: row => ({ foreground: percentColor(row) }) },
167
194
  { key: 'reset', title: 'Resets in', value: row => row.resetsIn, align: 'right' },
168
195
  ]);
169
196
  this.notes(limitsArg.notes, 'n/a — ');
@@ -177,10 +204,12 @@ export class CondensedRenderer {
177
204
  { key: 'account', title: 'Account', value: row => row.account ?? NONE,
178
205
  style: row => ({ bold: row.account !== null, foreground: row.account === null ? 'orange' : undefined }) },
179
206
  // The absolute timestamp stays in --json; one screen is worth more here than one more column.
180
- { key: 'saved', title: 'Saved', value: row => row.savedAt === null ? (row.account === null ? UNAVAILABLE : 'not saved') : `${row.savedAgo} ago`, align: 'right',
207
+ // "ago" belongs to a duration only; the unavailable marker is not one.
208
+ { key: 'saved', title: 'Saved', value: row => row.savedAt === null ? (row.account === null ? UNAVAILABLE : 'not saved')
209
+ : row.savedAgo === UNAVAILABLE ? UNAVAILABLE : `${row.savedAgo} ago`, align: 'right',
181
210
  style: row => ({ foreground: row.savedAt === null && row.account !== null ? 'orange' : undefined }) },
182
- { key: 'source', title: 'Source', value: row => row.source,
183
- style: row => ({ foreground: row.source === 'credential file' ? 'green' : 'orange' }) },
211
+ { key: 'source', title: 'Source', value: row => row.drift === null ? row.source : `${row.source} (changed)`,
212
+ style: row => ({ foreground: row.drift !== null ? 'red' : row.source === 'credential file' ? 'green' : 'orange' }) },
184
213
  ]);
185
214
  this.notes(activeArg.notes, 'note — ');
186
215
  }
@@ -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, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw, requireHarnessStopped } from './classes.credentialstore.js';
3
+ import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw } from './classes.credentialstore.js';
4
+ import { HarnessProcesses } from './classes.harnessprocesses.js';
4
5
  import { CodexAccountStatus } from './classes.codexstatus.js';
5
6
  import { writeSecretFileAtomically } from './helpers.js';
6
- import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider } from './interfaces.harness.js';
7
+ import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider, IHarnessProcessControl } from './interfaces.harness.js';
7
8
  import { beginSavedOpenAiLogin, openCodeCredentialFromLogin } from './classes.login.js';
8
9
  import { plainText } from './formatting.js';
9
10
 
@@ -12,8 +13,8 @@ export interface IOpenCodeHarnessOptions {
12
13
  stashRoot?: string;
13
14
  env?: NodeJS.ProcessEnv;
14
15
  fetch?: typeof fetch;
15
- /** Process inspection dependency, injectable for isolated installations and tests. */
16
- checkStopped?: () => void;
16
+ /** Running-instance inspection, injectable for isolated installations and tests. */
17
+ processes?: IHarnessProcessControl;
17
18
  }
18
19
 
19
20
  /** OpenCode owns one login per provider. Replacing a slot never replaces the whole provider set. */
@@ -26,7 +27,7 @@ export class OpenCodeHarness extends FileHarness {
26
27
  private readonly file: string;
27
28
  private readonly env: NodeJS.ProcessEnv;
28
29
  private readonly status: CodexAccountStatus;
29
- private readonly checkStopped: () => void;
30
+ public readonly processes: IHarnessProcessControl;
30
31
  private readonly fetcher?: typeof fetch;
31
32
 
32
33
  constructor(options: IOpenCodeHarnessOptions = {}) {
@@ -36,9 +37,8 @@ export class OpenCodeHarness extends FileHarness {
36
37
  this.store = new CredentialStore(this.id, options.stashRoot);
37
38
  this.status = new CodexAccountStatus(options.fetch);
38
39
  this.fetcher = options.fetch;
39
- this.checkStopped = options.checkStopped ?? (() => requireHarnessStopped('opencode'));
40
+ this.processes = options.processes ?? new HarnessProcesses('opencode');
40
41
  }
41
- protected beforeMutation(): void { this.checkStopped(); }
42
42
  /** Every provider entry lives in one file, so its bytes are the whole stability proof. */
43
43
  protected nativeSources(): (string | null)[] { return [readCredentialRaw(this.file)]; }
44
44
  /** Public identity only, for reconciling a host's OpenAI association after native refresh/restart. */
@@ -19,6 +19,14 @@ export type TAuthSwitchCoordinationResult =
19
19
  | { status: 'complete'; outcome: IHarnessOutcome };
20
20
  export type TAuthSwitchCoordinator = (request: IAuthSwitchCoordinationRequest) => Promise<TAuthSwitchCoordinationResult>;
21
21
  export type TAuthSwitchConfirmation = (message: string) => Promise<boolean>;
22
+ /**
23
+ * Runs immediately before a mutation this process performs itself.
24
+ *
25
+ * It never runs when a supervisor completed the mutation: a client must not stop a process whose
26
+ * lifecycle another manager owns, and stopping it after that manager already restarted it would
27
+ * split one transaction across two owners.
28
+ */
29
+ export type TAuthSwitchLocalPreparation = () => Promise<void>;
22
30
 
23
31
  export const authSwitchEnvironmentId = (env: NodeJS.ProcessEnv = process.env): string => plugins.crypto.createHash('sha256').update(JSON.stringify({
24
32
  home: plugins.os.homedir(),
@@ -56,7 +64,7 @@ export const runAuthSwitchMutation = async (harness: IAuthHarness, mutation: TAu
56
64
  /** One mutation path for command, guide and TUI. Supervisors own their complete restart transaction. */
57
65
  export class AuthSwitchOperations {
58
66
  constructor(private readonly coordinator?: TAuthSwitchCoordinator) {}
59
- public async run(harness: IAuthHarness, mutation: TAuthSwitchMutation, confirm?: TAuthSwitchConfirmation): Promise<IHarnessOutcome> {
67
+ public async run(harness: IAuthHarness, mutation: TAuthSwitchMutation, confirm?: TAuthSwitchConfirmation, prepareLocal?: TAuthSwitchLocalPreparation): Promise<IHarnessOutcome> {
60
68
  assertAuthSwitchMutation(mutation);
61
69
  if (harness.id !== mutation.harnessId) throw new Error('Account operation targets a different harness.');
62
70
  if (this.coordinator && mutation.action !== 'remove') {
@@ -69,6 +77,7 @@ export class AuthSwitchOperations {
69
77
  if (result.status === 'complete') return result.outcome;
70
78
  if (result.status === 'busy') return { lines: [], problems: [result.message] };
71
79
  }
80
+ if (prepareLocal) await prepareLocal();
72
81
  return runAuthSwitchMutation(harness, mutation);
73
82
  }
74
83
  }
package/ts/classes.tui.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import * as plugins from './plugins.js';
2
- import type { IAuthHarness, IHarnessOutcome, IHarnessState } from './interfaces.harness.js';
3
- import { accountDetails, accountPlan, accountQuotaSummary, accountResets, accountState, accountUsageWindows, usagePeriod, until, readAccountRows, type IAccountRow } from './accounts.js';
2
+ import type { IAuthHarness, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome } from './interfaces.harness.js';
3
+ import { accountDetails, accountPlan, accountQuotaSummary, accountResets, accountState, accountUsageWindows, usageWindowName, until, readAccountRows, type IAccountRow } from './accounts.js';
4
4
  import { plainText } from './formatting.js';
5
5
  import { AuthSwitchOperations } from './classes.operations.js';
6
+ import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
6
7
 
7
8
  /** Owns account-management actions; terminal rendering and interaction belong to smartconsole. */
8
9
  export class AuthSwitchTui {
@@ -59,6 +60,34 @@ export class AuthSwitchTui {
59
60
  catch { footer.setText(`Operation failed. Run authswitch ${harness.id} doctor for diagnostics.`); }
60
61
  updateDetails();
61
62
  };
63
+ /**
64
+ * Name the harness's running instances before a credential write, and stop them only on consent.
65
+ *
66
+ * The dashboard makes the same offer the command line does, with the same rule: only pids
67
+ * enumerated for this user are signalled, the session running this dashboard is never one of
68
+ * them, and declining still performs the switch.
69
+ */
70
+ const settleInstances = (screen: plugins.smartconsole.ITuiContext) => async (): Promise<void> => {
71
+ const control = harness.processes;
72
+ if (!control) return;
73
+ let running: IHarnessProcess[];
74
+ try { running = control.list(); }
75
+ catch { ui.logs.append(`Could not check for running ${plainText(harness.label)} processes.`); return; }
76
+ if (!running.length) return;
77
+ for (const line of describeHarnessProcesses(harness.label, running)) ui.logs.append(line);
78
+ if (control.stopUnavailableReason !== null) { ui.logs.append(control.stopUnavailableReason); return; }
79
+ const stoppable = running.filter(item => !item.isAncestor);
80
+ if (!stoppable.length || screen.signal.aborted) return;
81
+ if (!await screen.confirm(`Stop these ${stoppable.length} ${plainText(harness.label)} process(es) first?`, { confirmLabel: 'Stop them' })) return;
82
+ let outcome: IHarnessStopOutcome;
83
+ // Stopping re-reads the process table; if that read fails now, the switch is still the safe part.
84
+ try { outcome = await control.stop(stoppable); }
85
+ catch (error) {
86
+ ui.logs.append(`Could not stop the running ${plainText(harness.label)} process(es); leaving them running: ${plainText(error instanceof Error ? error.message : 'the process list could not be read.')}`);
87
+ return;
88
+ }
89
+ for (const line of describeStopOutcome(harness.label, outcome)) ui.logs.append(line);
90
+ };
62
91
  const save = async (screen: plugins.smartconsole.ITuiContext, keepActiveArg: boolean): Promise<void> => {
63
92
  const latest = await harness.readState();
64
93
  const activeAccounts = latest.accounts.filter(account => account.isActive);
@@ -67,7 +96,8 @@ export class AuthSwitchTui {
67
96
  if (!active || latest.saveUnavailableReason) { footer.setText(latest.saveUnavailableReason ?? 'No active login to save.'); return; }
68
97
  if (!await screen.confirm(`Save ${plainText(active.label)}${keepActiveArg ? ' and keep it active' : ' and clear the active login'}?`)) return;
69
98
  if (screen.signal.aborted) return;
70
- const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'save', keepActive: keepActiveArg, accountId: active.id }, message => screen.confirm(message));
99
+ const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'save', keepActive: keepActiveArg, accountId: active.id },
100
+ message => screen.confirm(message), keepActiveArg ? undefined : settleInstances(screen));
71
101
  await refresh(screen); showOutcome(outcome);
72
102
  };
73
103
  const switchSelected = async (screen: plugins.smartconsole.ITuiContext): Promise<void> => {
@@ -87,7 +117,8 @@ export class AuthSwitchTui {
87
117
  }
88
118
  if (!await screen.confirm(`Switch ${plainText(harness.label)} to ${plainText(target.label)}?`, { confirmLabel: 'Switch' })) return;
89
119
  if (screen.signal.aborted) return;
90
- const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'switch', accountId: target.id }, message => screen.confirm(message));
120
+ const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'switch', accountId: target.id },
121
+ message => screen.confirm(message), settleInstances(screen));
91
122
  await refresh(screen); showOutcome(outcome);
92
123
  };
93
124
  const remove = async (screen: plugins.smartconsole.ITuiContext): Promise<void> => {
@@ -109,9 +140,10 @@ export class AuthSwitchTui {
109
140
  const tabs = ui.tabs([{ title: 'Account details', content: details }, { title: 'Activity log', content: ui.logs }]);
110
141
  await ui.run({
111
142
  view: () => {
112
- const usage = accountUsageWindows({ status: table.selected?.status })?.slice(0, 2).map(window => ui.progress(
113
- `${usagePeriod(window)} (${window.usedPercent}% used; reset in ${until(window.resetAt, Date.now())})`, Math.min(100, window.usedPercent), 100,
114
- )) ?? [];
143
+ const shown = accountUsageWindows({ status: table.selected?.status }) ?? [];
144
+ const usage = shown.slice(0, 2).map(window => ui.progress(
145
+ `${usageWindowName(window, shown)} (${window.usedPercent}% used; reset in ${until(window.resetAt, Date.now())})`, Math.min(100, window.usedPercent), 100,
146
+ ));
115
147
  const accountView = ui.column([
116
148
  ui.panel(`${plainText(harness.label)} accounts`, table),
117
149
  ui.panel('Status and diagnostics', ui.column([...usage, tabs])),
package/ts/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from './interfaces.list.js';
4
4
  export * from './classes.codexharness.js';
5
5
  export * from './classes.opencodeharness.js';
6
6
  export * from './classes.claudecodeharness.js';
7
+ export * from './classes.harnessprocesses.js';
7
8
  export * from './classes.claudestatus.js';
8
9
  export * from './classes.codexstatus.js';
9
10
  export * from './classes.codexpreuse.js';
@@ -12,6 +12,64 @@ export interface IHarnessLoginHandle {
12
12
  close(): Promise<void>;
13
13
  }
14
14
 
15
+ /** One running instance of a harness binary, owned by the current user. */
16
+ export interface IHarnessProcess {
17
+ pid: number;
18
+ /** Executable name as the platform reports it, without a path. */
19
+ command: string;
20
+ /** Bounded command line, for identifying which instance this is. Never parsed for identity. */
21
+ commandLine: string;
22
+ /** Start time, which distinguishes a reused pid from the process we enumerated. */
23
+ startedAt: string | null;
24
+ /** An ancestor of the running authswitch command: stopping it would kill this command. */
25
+ isAncestor: boolean;
26
+ }
27
+
28
+ export interface IHarnessStopOutcome {
29
+ /** Instances that exited within the bounded wait. */
30
+ stopped: IHarnessProcess[];
31
+ /** Instances that were still running when the wait elapsed. */
32
+ survivors: IHarnessProcess[];
33
+ /** Instances that were re-verified and then killed, only ever with an explicit force request. */
34
+ forced: IHarnessProcess[];
35
+ }
36
+
37
+ /**
38
+ * Enumerating and stopping a harness's own running instances.
39
+ *
40
+ * Only pids this control enumerated for the current user are ever signalled, and a stop is graceful
41
+ * unless the caller explicitly forces it. Adapters whose process lifecycle belongs to another
42
+ * manager (Codex' app-server daemon) expose no control at all.
43
+ */
44
+ export interface IHarnessProcessControl {
45
+ readonly binary: string;
46
+ /** Why enumerated instances cannot be signalled on this platform; null when they can. */
47
+ readonly stopUnavailableReason: string | null;
48
+ list(): IHarnessProcess[];
49
+ stop(processesArg: readonly IHarnessProcess[], optionsArg?: { force?: boolean; timeoutMs?: number }): Promise<IHarnessStopOutcome>;
50
+ }
51
+
52
+ /**
53
+ * A live credential that no longer holds the account the last switch activated.
54
+ *
55
+ * Authswitch records the hash of what it wrote, in its own store. A different hash with the same
56
+ * account is that account's own token refresh; a different account is what a running instance
57
+ * writing its in-memory login back looks like.
58
+ */
59
+ export interface IHarnessCredentialDrift {
60
+ slotId?: string;
61
+ /** The account the recorded switch activated. */
62
+ expectedAccountId: string;
63
+ /** Its saved label, or null when that account is no longer saved. */
64
+ expectedLabel: string | null;
65
+ /** The account the credential file holds now, or null when the slot has no active login. */
66
+ currentAccountId: string | null;
67
+ currentLabel: string | null;
68
+ /** Whether the current account has a saved copy of its own. */
69
+ currentIsSaved: boolean;
70
+ switchedAt: string;
71
+ }
72
+
15
73
  /** Account IDs are opaque and scoped to one harness. Credentials never cross this interface. */
16
74
  export interface IHarnessAccount {
17
75
  id: string;
@@ -28,6 +86,8 @@ export interface IHarnessState {
28
86
  accounts: IHarnessAccount[];
29
87
  /** Explains why an active login cannot be saved, when applicable. */
30
88
  saveUnavailableReason: string | null;
89
+ /** Slots whose credential file no longer holds the account the last switch wrote. */
90
+ credentialDrift?: IHarnessCredentialDrift[];
31
91
  }
32
92
 
33
93
  export interface IHarnessAccountStatus {
@@ -48,20 +108,32 @@ export interface IHarnessStatusFact {
48
108
  summaryKey?: 'subscription' | 'billing' | 'usageWindows' | 'resets' | 'resetDetails';
49
109
  }
50
110
 
111
+ /** The provider's own reading of a usage window. Adapters set it only when the provider reports one of these. */
112
+ export type TUsageSeverity = 'normal' | 'warning' | 'critical';
113
+
114
+ export interface IHarnessUsageWindow {
115
+ label: string;
116
+ /**
117
+ * Feature-specific quotas (a model, a surface, a product feature) do not determine the general
118
+ * account summary unless the provider picks one as its headline. Omission means account.
119
+ */
120
+ scope?: 'account' | 'feature';
121
+ durationSeconds: number;
122
+ usedPercent: number;
123
+ resetAt: string | null;
124
+ /** Omitted when the provider gives no reading; consumers then apply their own thresholds. */
125
+ severity?: TUsageSeverity;
126
+ /** Set when the provider picks this window as the one a single-value summary shows. */
127
+ headline?: true;
128
+ }
129
+
51
130
  export interface IHarnessStatusSummary {
52
131
  /** A plan name does not imply an active billing status. */
53
132
  subscription?: { plan: string; source: 'live' | 'stored' };
54
133
  /** Live billing information only. Never infer these fields from a plan or entitlement expiry. */
55
134
  billing?: { hasActiveSubscription?: boolean; autoRenew?: boolean; renewsAt?: string; cancelsAt?: string; expiresAt?: string };
56
135
  /** Missing windows are unknown, not unused. Times are ISO 8601 UTC. */
57
- usageWindows?: {
58
- label: string;
59
- /** Feature-specific quotas do not determine the general account summary. Omission means account. */
60
- scope?: 'account' | 'feature';
61
- durationSeconds: number;
62
- usedPercent: number;
63
- resetAt: string | null;
64
- }[];
136
+ usageWindows?: IHarnessUsageWindow[];
65
137
  resets?: {
66
138
  available: number;
67
139
  /** May be a partial list. Omission means the detail lookup was unavailable. */
@@ -97,6 +169,8 @@ export interface IAuthHarness {
97
169
  readonly loginHint: string;
98
170
  readonly diagnosticsLabel: string;
99
171
  readState(): THarnessResult<IHarnessState>;
172
+ /** Running instances of this harness, when the adapter owns their lifecycle. */
173
+ readonly processes?: IHarnessProcessControl;
100
174
  readAccountStatus(accountIdArg: string): Promise<IHarnessAccountStatus>;
101
175
  readonly loginProviders?: IHarnessLoginProvider[];
102
176
  beginLogin?(options: IHarnessLoginOptions): Promise<IHarnessLoginHandle>;
@@ -1,4 +1,4 @@
1
- import type { IHarnessAccount, IHarnessAccountStatus } from './interfaces.harness.js';
1
+ import type { IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
2
2
 
3
3
  /** Credential-free, versioned output of list --json. Missing status fields stay omitted. */
4
4
  export interface IAccountList {
@@ -14,6 +14,8 @@ export interface IHarnessAccountList {
14
14
  loginHint: string;
15
15
  saveUnavailableReason: string | null;
16
16
  accounts: (IHarnessAccount & { status: IHarnessAccountStatus })[];
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
  }
@@ -46,6 +48,10 @@ export interface IAccountLimitRow {
46
48
  resetAt: string | null;
47
49
  /** Human countdown for the same `resetAt`, relative to `generatedAt`. */
48
50
  resetsIn: string;
51
+ /** The provider's reading of this window; null when it gave none or the row has no window. */
52
+ severity: TUsageSeverity | null;
53
+ /** Whether the provider picks this window as the one a single-value summary shows. */
54
+ headline: boolean;
49
55
  unavailableReason: string | null;
50
56
  }
51
57
 
@@ -71,4 +77,18 @@ export interface IActiveAccountRow {
71
77
  /** Where the shown account comes from. Whether it is also saved is the `savedAt` field's answer. */
72
78
  source: 'credential file' | 'stash only' | 'none';
73
79
  unavailableReason: string | null;
80
+ /** Set when the credential file no longer holds the account the last switch wrote. */
81
+ drift: IActiveAccountDrift | null;
82
+ }
83
+
84
+ /** A credential file that changed underneath a switch, with both accounts named where known. */
85
+ export interface IActiveAccountDrift {
86
+ expectedAccountId: string;
87
+ expectedAccount: string | null;
88
+ currentAccountId: string | null;
89
+ currentAccount: string | null;
90
+ currentIsSaved: boolean;
91
+ switchedAt: string;
92
+ /** The sentence the table footnotes, for consumers that display one string. */
93
+ reason: string;
74
94
  }