@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/accounts.d.ts +28 -6
- package/dist_ts/accounts.js +40 -7
- package/dist_ts/classes.accountlist.js +4 -3
- package/dist_ts/classes.claudecodeharness.d.ts +4 -4
- package/dist_ts/classes.claudecodeharness.js +5 -5
- package/dist_ts/classes.claudestatus.js +94 -18
- package/dist_ts/classes.cli.d.ts +12 -0
- package/dist_ts/classes.cli.js +98 -9
- package/dist_ts/classes.credentialstore.d.ts +20 -7
- package/dist_ts/classes.credentialstore.js +36 -23
- package/dist_ts/classes.fileharness.d.ts +23 -3
- package/dist_ts/classes.fileharness.js +102 -16
- package/dist_ts/classes.harnessprocesses.d.ts +62 -0
- package/dist_ts/classes.harnessprocesses.js +198 -0
- package/dist_ts/classes.limits.js +45 -19
- package/dist_ts/classes.opencodeharness.d.ts +4 -5
- package/dist_ts/classes.opencodeharness.js +5 -5
- package/dist_ts/classes.operations.d.ts +9 -1
- package/dist_ts/classes.operations.js +4 -2
- package/dist_ts/classes.tui.js +51 -5
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces.harness.d.ts +79 -8
- package/dist_ts/interfaces.list.d.ts +20 -1
- package/package.json +2 -2
- package/readme.md +137 -31
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/accounts.ts +44 -10
- package/ts/classes.accountlist.ts +3 -2
- package/ts/classes.claudecodeharness.ts +7 -6
- package/ts/classes.claudestatus.ts +102 -14
- package/ts/classes.cli.ts +77 -9
- package/ts/classes.credentialstore.ts +41 -18
- package/ts/classes.fileharness.ts +80 -16
- package/ts/classes.harnessprocesses.ts +225 -0
- package/ts/classes.limits.ts +49 -20
- package/ts/classes.opencodeharness.ts +7 -7
- package/ts/classes.operations.ts +10 -1
- package/ts/classes.tui.ts +39 -7
- package/ts/index.ts +1 -0
- package/ts/interfaces.harness.ts +82 -8
- package/ts/interfaces.list.ts +21 -1
package/ts/classes.limits.ts
CHANGED
|
@@ -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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
16
|
-
|
|
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
|
-
|
|
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.
|
|
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. */
|
package/ts/classes.operations.ts
CHANGED
|
@@ -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,
|
|
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 },
|
|
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 },
|
|
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
|
|
113
|
-
|
|
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';
|
package/ts/interfaces.harness.ts
CHANGED
|
@@ -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>;
|
package/ts/interfaces.list.ts
CHANGED
|
@@ -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
|
}
|