@modelprofile.com/authswitch 10.0.0 → 10.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 (44) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/accounts.d.ts +0 -17
  3. package/dist_ts/accounts.js +2 -82
  4. package/dist_ts/classes.authoritycli.js +3 -2
  5. package/dist_ts/classes.claudeauthority.d.ts +5 -0
  6. package/dist_ts/classes.claudeauthority.js +47 -4
  7. package/dist_ts/classes.claudecodelocks.d.ts +6 -0
  8. package/dist_ts/classes.claudecodelocks.js +30 -2
  9. package/dist_ts/classes.claudenative.d.ts +37 -2
  10. package/dist_ts/classes.claudenative.js +142 -13
  11. package/dist_ts/classes.cli.js +3 -2
  12. package/dist_ts/classes.codexpreuse.js +1 -1
  13. package/dist_ts/classes.limits.js +3 -2
  14. package/dist_ts/classes.listrenderer.js +3 -2
  15. package/dist_ts/classes.tui.js +3 -2
  16. package/dist_ts/classes.watch.js +3 -2
  17. package/dist_ts/index.d.ts +1 -0
  18. package/dist_ts/index.js +2 -1
  19. package/dist_ts/preuse-text.d.ts +18 -0
  20. package/dist_ts/preuse-text.js +28 -0
  21. package/dist_ts/preuse.d.ts +9 -36
  22. package/dist_ts/preuse.js +3 -27
  23. package/dist_ts/timetext.d.ts +23 -0
  24. package/dist_ts/timetext.js +88 -0
  25. package/dist_ts/watchpolicy.js +3 -2
  26. package/package.json +5 -1
  27. package/readme.md +38 -7
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/accounts.ts +1 -69
  30. package/ts/classes.authoritycli.ts +2 -1
  31. package/ts/classes.claudeauthority.ts +37 -3
  32. package/ts/classes.claudecodelocks.ts +31 -1
  33. package/ts/classes.claudenative.ts +161 -14
  34. package/ts/classes.cli.ts +2 -1
  35. package/ts/classes.codexpreuse.ts +3 -3
  36. package/ts/classes.limits.ts +2 -1
  37. package/ts/classes.listrenderer.ts +2 -1
  38. package/ts/classes.tui.ts +2 -1
  39. package/ts/classes.watch.ts +2 -1
  40. package/ts/index.ts +1 -0
  41. package/ts/preuse-text.ts +40 -0
  42. package/ts/preuse.ts +9 -55
  43. package/ts/timetext.ts +76 -0
  44. package/ts/watchpolicy.ts +2 -1
@@ -6,9 +6,25 @@ import { claudeRequest } from './claudehttp.js';
6
6
  import { writeSecretFileAtomically } from './helpers.js';
7
7
  import { HarnessProcesses } from './classes.harnessprocesses.js';
8
8
 
9
- /** The private file and lock contract verified for this Claude Code release. */
10
- export const CLAUDE_NATIVE_HANDOFF_VERSION = '2.1.273';
9
+ /**
10
+ * The earliest Claude Code whose native file contract this adapter writes: `.credentials.json` beside
11
+ * `.claude.json`, and the lock protocol and live credential pickup verified for 2.1.273 (hints.md).
12
+ */
13
+ export const CLAUDE_NATIVE_MINIMUM_VERSION = '2.1.273';
14
+ /**
15
+ * The releases whose lock table, live credential pickup and refresh compare-and-swap were read from their own
16
+ * bundle (hints.md). A Claude Code session may keep running in the home a handoff writes only when it runs one
17
+ * of these; an installed release above the floor that is not listed is proven by `claude auth status` instead,
18
+ * and only while no session runs in that home.
19
+ */
20
+ export const CLAUDE_NATIVE_VERIFIED_RELEASES: readonly string[] = ['2.1.273', '2.1.282'];
21
+ /**
22
+ * Token files Claude Code reads at fixed paths when no descriptor variable names one (2.1.282 `KI`, `YGn`):
23
+ * the remote container's hand-off of an OAuth token or an API key. Either one outranks the native login.
24
+ */
25
+ const REMOTE_CREDENTIAL_FILES = ['/home/claude/.claude/remote/.oauth_token', '/home/claude/.claude/remote/.api_key'] as const;
11
26
  const MAX_NATIVE_FILE_BYTES = 4 * 1024 * 1024;
27
+ const AUTH_STATUS_TIMEOUT_MS = 4_000;
12
28
  const MAX_PROCESS_CONTEXT_BYTES = 1024 * 1024;
13
29
  const PROFILE_TIMEOUT_MS = 10_000;
14
30
  const ACCOUNT_CACHES = [
@@ -19,7 +35,8 @@ const ACCOUNT_CACHES = [
19
35
  const CREDENTIAL_OVERRIDES = [
20
36
  'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR', 'ANTHROPIC_API_KEY',
21
37
  'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_PROFILE', 'CLAUDE_CODE_USE_BEDROCK',
22
- 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'ANTHROPIC_BASE_URL',
38
+ 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
39
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD', 'CLAUDE_CODE_USE_MANTLE', 'ANTHROPIC_BASE_URL',
23
40
  'ANTHROPIC_FEDERATION_RULE_ID', 'ANTHROPIC_ORGANIZATION_ID',
24
41
  'ANTHROPIC_IDENTITY_TOKEN_FILE', 'ANTHROPIC_IDENTITY_TOKEN', 'ANTHROPIC_WORKSPACE_ID',
25
42
  'ANTHROPIC_UNIX_SOCKET', 'CLAUDE_CODE_REMOTE', 'CLAUDE_CODE_SIMPLE',
@@ -37,8 +54,9 @@ export class ClaudeNativeEffectiveAuthUnsupportedError extends Error {
37
54
 
38
55
  constructor(public readonly reason: TAuthSwitchClaudeProofFailure) {
39
56
  super(reason === 'unsupported_release'
40
- ? `Native Claude handoff requires Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} for every active session.`
41
- : `Cannot prove that Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} uses its native claude.ai login (${reason}).`);
57
+ ? `Native Claude handoff requires Claude Code ${CLAUDE_NATIVE_MINIMUM_VERSION} or newer, and a session `
58
+ + `running in this home must run a verified release (${CLAUDE_NATIVE_VERIFIED_RELEASES.join(', ')}).`
59
+ : `Cannot prove that Claude Code uses its native claude.ai login (${reason}).`);
42
60
  }
43
61
  }
44
62
 
@@ -95,6 +113,20 @@ export type TClaudeNativePrepareResult =
95
113
 
96
114
  export interface IClaudeNativeLockedSession {
97
115
  readStable(): IClaudeNativeSnapshot;
116
+ /**
117
+ * Proves that Claude Code itself selects the native login of `expected` in this home. A verified release is
118
+ * proven by the adapter's own reading of its selector; any other release above the floor is asked through
119
+ * `claude auth status --json`, which names the organization and the account's email, the only account
120
+ * identifiers it reports. Throws `ClaudeNativeEffectiveAuthUnsupportedError` when it cannot be proven, and
121
+ * `ClaudeCodeLockOutcomeUnknownError` when the locks were lost while it asked.
122
+ */
123
+ proveEffectiveLogin(expected: IClaudeNativeLogin): Promise<void>;
124
+ /**
125
+ * Writes back the exact bytes a written plan replaced, while the locks that guarded the write are still held.
126
+ * True once both files read back as they were; false, writing nothing, when the pair is no longer exactly
127
+ * what the plan wrote.
128
+ */
129
+ restorePreparedSource(plan: IClaudeNativePreparedWrite): boolean;
98
130
  verifyProfileIdentity(snapshot: IClaudeNativeSnapshot,
99
131
  expected: IClaudeNativeIdentity): Promise<IClaudeNativeProfileProof>;
100
132
  prepareWrite(input: {
@@ -115,6 +147,12 @@ export interface IClaudeNativeLockedSession {
115
147
  export interface IClaudeNativeVersionProbe {
116
148
  installedVersion(): string;
117
149
  runningContexts(): readonly IClaudeNativeRunningContext[];
150
+ /**
151
+ * What `claude auth status --json` prints under `env`, whatever its exit code, or null when it does not
152
+ * answer. Asynchronous on purpose: it runs while the vendor locks are held, and their renewal timers must keep
153
+ * running.
154
+ */
155
+ authStatus(env: NodeJS.ProcessEnv): Promise<string | null>;
118
156
  }
119
157
 
120
158
  /** Only selector keys and paths, never a process's credential values, leave the /proc reader. */
@@ -171,8 +209,30 @@ const onlyDigests = (value: IClaudeNativeDigests): IClaudeNativeDigests => ({
171
209
  });
172
210
  const preparedWrites = new WeakMap<IClaudeNativePreparedWrite, {
173
211
  session: ClaudeNativeLockedSession; credentialRaw: string; configRaw: string;
174
- originalConfigRaw: Buffer | null; targetAccessToken: string;
212
+ originalCredentialRaw: Buffer | null; originalConfigRaw: Buffer | null; targetAccessToken: string;
175
213
  }>();
214
+ /** A written plan's original bytes, kept until the handoff proves the write or restores them. */
215
+ const writtenPlans = new WeakMap<IClaudeNativePreparedWrite, {
216
+ session: ClaudeNativeLockedSession; credentialRaw: string; configRaw: string;
217
+ originalCredentialRaw: Buffer; originalConfigRaw: Buffer;
218
+ }>();
219
+
220
+ const parseClaudeVersion = (value: string): [number, number, number] | null => {
221
+ const match = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/.exec(value);
222
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
223
+ };
224
+ const atLeast = (version: [number, number, number], minimum: [number, number, number]): boolean => {
225
+ for (let index = 0; index < 3; index++) {
226
+ if (version[index] !== minimum[index]) return version[index] > minimum[index];
227
+ }
228
+ return true;
229
+ };
230
+
231
+ /** Which proof applies to one operation: the adapter's own selector reading, or Claude Code's own answer. */
232
+ interface IClaudeNativeSupport {
233
+ runningSessions: number;
234
+ proof: 'verified_release' | 'auth_status';
235
+ }
176
236
  const sameIdentity = (left: IClaudeNativeIdentity, right: IClaudeNativeIdentity): boolean =>
177
237
  left.accountUuid === right.accountUuid && left.organizationUuid === right.organizationUuid;
178
238
 
@@ -303,6 +363,15 @@ const assertNoKnownNativeLoginOverride = (env: NodeJS.ProcessEnv, processRoot?:
303
363
  for (const key of CREDENTIAL_OVERRIDES) {
304
364
  if (env[key]) throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
305
365
  }
366
+ for (const file of REMOTE_CREDENTIAL_FILES) {
367
+ try {
368
+ plugins.fs.lstatSync(processViewPath(file, processRoot));
369
+ } catch (error) {
370
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
371
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
372
+ }
373
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
374
+ }
306
375
  const configDir = env.ANTHROPIC_CONFIG_DIR?.trim()
307
376
  || (env.XDG_CONFIG_HOME?.trim()
308
377
  ? plugins.path.join(env.XDG_CONFIG_HOME.trim(), 'anthropic')
@@ -504,6 +573,20 @@ class ClaudeNativeVersionProbe implements IClaudeNativeVersionProbe {
504
573
  } catch { return 'unknown'; }
505
574
  }
506
575
 
576
+ public authStatus(env: NodeJS.ProcessEnv): Promise<string | null> {
577
+ // Well inside the shortest lock renewal window (the config lock renews every 5 s); it answers in ~200 ms.
578
+ return new Promise(resolve => {
579
+ const child = plugins.childProcess.execFile(this.executable, ['auth', 'status', '--json'], {
580
+ encoding: 'utf8', timeout: AUTH_STATUS_TIMEOUT_MS, maxBuffer: 64 * 1024, windowsHide: true, env,
581
+ }, (error, stdout) => {
582
+ // A logged-out status still prints its JSON and exits 1; only no answer at all is null.
583
+ const exited = error === null || typeof error.code === 'number';
584
+ resolve(exited && stdout ? stdout : null);
585
+ });
586
+ child.stdin?.end();
587
+ });
588
+ }
589
+
507
590
  public runningContexts(): readonly IClaudeNativeRunningContext[] {
508
591
  const contexts: IClaudeNativeRunningContext[] = [];
509
592
  // Keep candidates from every mount namespace: a bind mount can expose the same inode.
@@ -597,7 +680,7 @@ export class ClaudeNativeAdapter {
597
680
  this.platform = options.platform ?? process.platform;
598
681
  }
599
682
 
600
- private assertSupported(): number {
683
+ private assertSupported(): IClaudeNativeSupport {
601
684
  if (this.platform !== 'linux') throw new Error('Native Claude handoff requires the verified Linux file backend.');
602
685
  const home = plugins.fs.lstatSync(this.options.configDir);
603
686
  if (!home.isDirectory() || home.isSymbolicLink() || home.uid !== process.getuid?.()
@@ -621,9 +704,12 @@ export class ClaudeNativeAdapter {
621
704
  for (const file of this.settingsFiles) {
622
705
  assertSettingsSelectNative(file);
623
706
  }
624
- if (this.versionProbe.installedVersion() !== CLAUDE_NATIVE_HANDOFF_VERSION) {
707
+ const installedText = this.versionProbe.installedVersion();
708
+ const installed = parseClaudeVersion(installedText);
709
+ if (!installed || !atLeast(installed, parseClaudeVersion(CLAUDE_NATIVE_MINIMUM_VERSION)!)) {
625
710
  throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
626
711
  }
712
+ const installedVerified = CLAUDE_NATIVE_VERIFIED_RELEASES.includes(installedText);
627
713
  let runningSessions = 0;
628
714
  const nativeHomeIdentity = directoryIdentity(this.options.configDir);
629
715
  const configParentIdentity = directoryIdentity(configParent);
@@ -636,7 +722,9 @@ export class ClaudeNativeAdapter {
636
722
  || !sameDirectory(running.configParentIdentity, configParentIdentity)) {
637
723
  throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
638
724
  }
639
- if (running.version !== CLAUDE_NATIVE_HANDOFF_VERSION) {
725
+ // A session keeps taking its own locks and picking up the swapped login while the handoff runs, so
726
+ // it must be a release whose lock and pickup contract was read from its bundle.
727
+ if (running.version === null || !CLAUDE_NATIVE_VERIFIED_RELEASES.includes(running.version)) {
640
728
  throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
641
729
  }
642
730
  if (!running.env || !running.cwd || running.hasSettingsOverride === null
@@ -650,7 +738,34 @@ export class ClaudeNativeAdapter {
650
738
  assertProjectSettingsSelectNative(running.cwd, running.processRoot);
651
739
  runningSessions++;
652
740
  }
653
- return runningSessions;
741
+ // An installed release nobody verified is proven by its own answer, which says nothing about how a session
742
+ // already running would take the locks or pick up the swap; it is written only while none runs here.
743
+ if (!installedVerified && runningSessions > 0) {
744
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
745
+ }
746
+ return { runningSessions, proof: installedVerified ? 'verified_release' : 'auth_status' };
747
+ }
748
+
749
+ /**
750
+ * Claude Code's own account of the login it selects in this home: `claude auth status --json` under the
751
+ * adapter's environment. It must be the claude.ai login of `expected`, read from this home, with no API key.
752
+ */
753
+ private async proveEffectiveLogin(expected: IClaudeNativeLogin): Promise<void> {
754
+ if (this.assertSupported().proof === 'verified_release') return;
755
+ const organizationUuid = expected.oauthAccount.organizationUuid;
756
+ const email = typeof expected.oauthAccount.emailAddress === 'string' ? expected.oauthAccount.emailAddress : null;
757
+ const raw = await this.versionProbe.authStatus(this.env);
758
+ let status: unknown;
759
+ try { status = raw === null ? null : JSON.parse(raw); } catch { status = null; }
760
+ if (!isRecord(status) || typeof status.loggedIn !== 'boolean' || typeof status.authMethod !== 'string') {
761
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
762
+ }
763
+ if (status.loggedIn !== true || status.authMethod !== 'claude.ai' || status.apiProvider !== 'firstParty'
764
+ || (status.apiKeySource !== undefined && status.apiKeySource !== null)
765
+ || status.configDirectory !== this.options.configDir || status.orgId !== organizationUuid
766
+ || (status.email ?? null) !== email) {
767
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
768
+ }
654
769
  }
655
770
 
656
771
  /** The caller keeps its NoSQLDB journal and both ownership transactions inside this callback. */
@@ -659,12 +774,13 @@ export class ClaudeNativeAdapter {
659
774
  return this.locks.holdAsync(async lease => {
660
775
  this.assertSupported();
661
776
  const session = new ClaudeNativeLockedSession(this.credentialFile, this.options.configFile,
662
- this.fetcher, lease, () => this.assertSupported());
777
+ this.fetcher, lease, () => this.assertSupported().runningSessions,
778
+ expected => this.proveEffectiveLogin(expected));
663
779
  const firstSnapshot = session.readStable();
664
780
  const result = await action(session);
665
781
  try {
666
782
  lease.assertHeld();
667
- const afterRunningSessions = this.assertSupported();
783
+ const afterRunningSessions = this.assertSupported().runningSessions;
668
784
  if (firstSnapshot.runningEffectiveAuth === 'no_scoped_sessions' && afterRunningSessions > 0) {
669
785
  throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
670
786
  }
@@ -680,7 +796,34 @@ export class ClaudeNativeAdapter {
680
796
  class ClaudeNativeLockedSession implements IClaudeNativeLockedSession {
681
797
  constructor(private readonly credentialFile: string, private readonly configFile: string,
682
798
  private readonly fetcher: typeof fetch, private readonly lease: IClaudeCodeLockLease,
683
- private readonly assertSupported: () => number) {}
799
+ private readonly assertSupported: () => number,
800
+ private readonly proveEffective: (expected: IClaudeNativeLogin) => Promise<void>) {}
801
+
802
+ public async proveEffectiveLogin(expected: IClaudeNativeLogin): Promise<void> {
803
+ this.lease.assertHeld();
804
+ await this.proveEffective(expected);
805
+ // Asserted after the answer, before anything is decided or written on it.
806
+ this.lease.assertHeld();
807
+ }
808
+
809
+ public restorePreparedSource(plan: IClaudeNativePreparedWrite): boolean {
810
+ this.lease.assertHeld();
811
+ const written = writtenPlans.get(plan);
812
+ if (!written || written.session !== this) throw new Error('Claude native write plan is not held by this lease.');
813
+ writtenPlans.delete(plan);
814
+ let current: ReturnType<typeof readPair>;
815
+ try { current = readPair(this.credentialFile, this.configFile); } catch { return false; }
816
+ if (!sameRaw(current.credentialRaw, Buffer.from(written.credentialRaw))
817
+ || !sameRaw(current.configRaw, Buffer.from(written.configRaw))) return false;
818
+ this.lease.assertHeld();
819
+ writeSecretFileAtomically(this.credentialFile, written.originalCredentialRaw.toString('utf8'));
820
+ writeSecretFileAtomically(this.configFile, written.originalConfigRaw.toString('utf8'));
821
+ this.lease.assertHeld();
822
+ const restored = readPair(this.credentialFile, this.configFile);
823
+ return sameRaw(restored.credentialRaw, written.originalCredentialRaw)
824
+ && sameRaw(restored.configRaw, written.originalConfigRaw)
825
+ && sameDigests(digests(restored), plan.before);
826
+ }
684
827
 
685
828
  private observation(): IClaudeNativeDigests | null {
686
829
  try { return digests(readPair(this.credentialFile, this.configFile)); }
@@ -811,7 +954,7 @@ class ClaudeNativeLockedSession implements IClaudeNativeLockedSession {
811
954
  targetIdentity: Object.freeze({ ...input.expectedIdentity }),
812
955
  profile: Object.freeze({ ...profile }),
813
956
  });
814
- preparedWrites.set(plan, { session: this, credentialRaw, configRaw,
957
+ preparedWrites.set(plan, { session: this, credentialRaw, configRaw, originalCredentialRaw: original.credentialRaw,
815
958
  originalConfigRaw: original.configRaw, targetAccessToken: String(target.login.claudeAiOauth.accessToken) });
816
959
  return { kind: 'prepared', plan };
817
960
  }
@@ -864,6 +1007,10 @@ class ClaudeNativeLockedSession implements IClaudeNativeLockedSession {
864
1007
  || plan.profile.accessTokenDigest !== hash(String(snapshot.login.claudeAiOauth.accessToken))) {
865
1008
  return { kind: 'uncertain', reason: 'native_write_or_readback_failed', observed: onlyDigests(snapshot) };
866
1009
  }
1010
+ if (prepared.originalCredentialRaw !== null && prepared.originalConfigRaw !== null) {
1011
+ writtenPlans.set(plan, { session: this, credentialRaw: prepared.credentialRaw, configRaw: prepared.configRaw,
1012
+ originalCredentialRaw: prepared.originalCredentialRaw, originalConfigRaw: prepared.originalConfigRaw });
1013
+ }
867
1014
  return { kind: 'written', snapshot, profile: plan.profile };
868
1015
  } catch {
869
1016
  return { kind: 'uncertain', reason: 'native_write_or_readback_failed', observed: this.observation() };
package/ts/classes.cli.ts CHANGED
@@ -9,7 +9,8 @@ import { readAccountList } from './classes.accountlist.js';
9
9
  import { AccountListRenderer } from './classes.listrenderer.js';
10
10
  import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
11
11
  import { consoleHeading, consoleTable } from './consoletable.js';
12
- import { accountName, credentialDriftNote, readAccountBadges, until, usagePercentText } from './accounts.js';
12
+ import { accountName, credentialDriftNote, readAccountBadges, usagePercentText } from './accounts.js';
13
+ import { until } from './timetext.js';
13
14
  import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
14
15
  import { defaultPreusePrompt, preuseAccounts, preuseCountsText, PreuseError, preuseTargets, validatePreuseOptions,
15
16
  type IPreuseRunSummary, type TPreuseAccountResult, type TPreuseOutcome, type TPreuseRunEvent } from './preuse.js';
@@ -1,8 +1,8 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { ICodexIdentity } from './interfaces.js';
3
3
  import type { IHarnessPreuseOptions, IHarnessPreuseResult } from './interfaces.harness.js';
4
- import { PreuseError, PreuseFailureError, PreuseLimitError, preuseMayHaveConsumed, validatePreuseOptions,
5
- type IPreuseLimit } from './preuse.js';
4
+ import type { IAuthSwitchPreuseLimit } from './authority-contract.js';
5
+ import { PreuseError, PreuseFailureError, PreuseLimitError, preuseMayHaveConsumed, validatePreuseOptions } from './preuse.js';
6
6
  import { parseOpenAiTokenClaims } from './openaiauth.js';
7
7
  import { OpenAiBackendError, readOpenAiAccountUsage, type IOpenAiAccountUsage } from './openaiusage.js';
8
8
  import { usageWindowPeriod } from './accounts.js';
@@ -14,7 +14,7 @@ const PREUSE_TIMEOUT_MS = 180_000;
14
14
  * The limit the account's own usage reading says is reached, or null. Every window at its limit must reset
15
15
  * before a prompt can pass, so the one that resets last is named.
16
16
  */
17
- const reachedLimit = (usage: IOpenAiAccountUsage): IPreuseLimit | null => {
17
+ const reachedLimit = (usage: IOpenAiAccountUsage): IAuthSwitchPreuseLimit | null => {
18
18
  const limit = usage.rateLimits.rateLimit;
19
19
  if (!limit || (limit.allowed && !limit.limitReached)) return null;
20
20
  const exhausted = [limit.primaryWindow, limit.secondaryWindow]
@@ -1,5 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
- import { accountTypeText, compactSince, compactUntil, credentialDriftNote, localDate, orderedUsageWindows, usagePercentText } from './accounts.js';
2
+ import { accountTypeText, credentialDriftNote, orderedUsageWindows, usagePercentText } from './accounts.js';
3
+ import { compactSince, compactUntil, localDate } from './timetext.js';
3
4
  import { consoleHeading, consoleNote, consoleTable } from './consoletable.js';
4
5
  import type { IAccountList, IAccountLimitRow, IAccountLimits, IActiveAccountDrift, IActiveAccountRow, IActiveAccounts, IHarnessAccountList } from './interfaces.list.js';
5
6
  import type { IAccountType, IAuthHarness, IHarnessBilling, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
@@ -1,5 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
- import { accountUsageWindows, compactUsageLine, credentialDriftProblem, localDate, localDateTime, localiseTimestamps } from './accounts.js';
2
+ import { accountUsageWindows, compactUsageLine, credentialDriftProblem } from './accounts.js';
3
+ import { localDate, localDateTime, localiseTimestamps } from './timetext.js';
3
4
  import { accountBilling, accountCell, accountLimits, accountType, CondensedRenderer, nextRenewal, renewalCell, UNAVAILABLE, usageColor, type THarnessTraits } from './classes.limits.js';
4
5
  import { consoleHeading, consoleNote, consoleTable, consoleWidth } from './consoletable.js';
5
6
  import type { IHarnessStatusFact, IHarnessStatusSummary } from './interfaces.harness.js';
package/ts/classes.tui.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IAuthHarness, IHarnessAccount, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome } from './interfaces.harness.js';
3
- import { accountBadge, accountDetails, accountPlan, accountQuotaSummary, accountResets, accountState, accountUsageWindows, usageWindowName, until, readAccountRows, type IAccountRow } from './accounts.js';
3
+ import { accountBadge, accountDetails, accountPlan, accountQuotaSummary, accountResets, accountState, accountUsageWindows, usageWindowName, readAccountRows, type IAccountRow } from './accounts.js';
4
+ import { until } from './timetext.js';
4
5
  import { defaultPreusePrompt, preuseAccounts, preuseCountsText, preuseTargets, type TPreuseAccountResult } from './preuse.js';
5
6
  import { plainText } from './formatting.js';
6
7
  import { AuthSwitchOperations, authSwitchMutationReplacesLogin, type TAuthSwitchMutation } from './classes.operations.js';
@@ -1,4 +1,5 @@
1
- import { accountBadge, accountName, durationText, localTime, readAccountRows, type IAccountRow } from './accounts.js';
1
+ import { accountBadge, accountName, durationText, readAccountRows, type IAccountRow } from './accounts.js';
2
+ import { localTime } from './timetext.js';
2
3
  import { pause } from './helpers.js';
3
4
  import { bold, dim, green, orange, plainText, red } from './formatting.js';
4
5
  import { decideWatchAction, type IWatchReading } from './watchpolicy.js';
package/ts/index.ts CHANGED
@@ -11,6 +11,7 @@ export * from './classes.claudestatus.js';
11
11
  export * from './classes.codexstatus.js';
12
12
  export * from './classes.codexpreuse.js';
13
13
  export * from './preuse.js';
14
+ export * from './preuse-text.js';
14
15
  export * from './helpers.js';
15
16
  export * from './classes.codexhome.js';
16
17
  export * from './classes.codexauth.js';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The sentences that name what stopped a preuse, with no Node dependency.
3
+ *
4
+ * A preuse receipt carries its limit and its cause as data (`IAuthSwitchPreuseOperation.limit` and `.failure`);
5
+ * the command line and a host that shows receipts, AGL's web client for one, must word them alike. This module
6
+ * therefore loads nothing but the time text it renders a reset with, which imports nothing either, so that it can
7
+ * be bundled for a browser through the `@modelprofile.com/authswitch/preuse-text` entry point. The receipt types
8
+ * are type-only imports, which the emitted JavaScript drops.
9
+ */
10
+ import type { IAuthSwitchPreuseFailure, IAuthSwitchPreuseLimit } from './authority-contract.js';
11
+ import { localDateTime, until } from './timetext.js';
12
+
13
+ export { localDateTime } from './timetext.js';
14
+
15
+ /**
16
+ * `Weekly usage limit reached, resets in 16h 46min (2026-09-27 05:12)`: human text, so the reset reads in local time.
17
+ * The limit itself keeps its ISO `resetsAt` for receipts and JSON output.
18
+ */
19
+ export const describePreuseLimit = (limitArg: IAuthSwitchPreuseLimit, nowArg: number): string => {
20
+ const name = `${limitArg.window === null ? '' : `${limitArg.window} `}${limitArg.kind === 'usage_limit' ? 'usage' : 'rate'} limit reached`;
21
+ const countdown = limitArg.resetsAt === null ? null : until(limitArg.resetsAt, nowArg);
22
+ const at = localDateTime(limitArg.resetsAt);
23
+ const reset = limitArg.resetsAt === null ? ''
24
+ : countdown === 'due' ? `, due to reset now (${at})` : `, resets in ${countdown} (${at})`;
25
+ return `${name.charAt(0).toUpperCase()}${name.slice(1)}${reset}`;
26
+ };
27
+
28
+ /** `the provider answered HTTP 503`: the cause of a failed preuse, as the tail of a sentence. */
29
+ export const describePreuseFailure = (failureArg: IAuthSwitchPreuseFailure): string => {
30
+ const status = failureArg.status === null ? null : `HTTP ${failureArg.status}`;
31
+ switch (failureArg.kind) {
32
+ case 'http': return `the provider answered ${status ?? 'with an HTTP error'}`;
33
+ case 'stream':
34
+ return `the answer stream reported an error${failureArg.providerCode === null ? '' : ` (code ${failureArg.providerCode})`}`;
35
+ case 'response': return `the provider's answer${status ? ` (${status})` : ''} could not be read`;
36
+ case 'network': return 'no answer arrived: the connection failed';
37
+ case 'aborted': return 'the request was aborted';
38
+ case 'unknown': return 'the request ended in an error that names no cause';
39
+ }
40
+ };
package/ts/preuse.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { localDateTime, orderedUsageWindows, until } from './accounts.js';
1
+ import type { IAuthSwitchPreuseFailure, IAuthSwitchPreuseLimit } from './authority-contract.js';
2
+ import { orderedUsageWindows } from './accounts.js';
3
+ import { describePreuseFailure, describePreuseLimit } from './preuse-text.js';
2
4
  import type { IAuthHarness, IHarnessAccount, IHarnessPreuseOptions, IHarnessPreuseResult, IHarnessUsageWindow } from './interfaces.harness.js';
3
5
 
4
6
  export const defaultPreusePrompt = 'Write 2000 words about strawberries.';
@@ -18,31 +20,8 @@ export class PreuseError extends Error {
18
20
  constructor(messageArg: string, public readonly requestStarted = false) { super(messageArg); }
19
21
  }
20
22
 
21
- /**
22
- * A limit that kept a preuse prompt from being answered.
23
- *
24
- * `usage_limit` is the account's quota, spent until it resets; `rate_limit` a short throttle. `window` names the
25
- * window by its length (`weekly`, `five-hour`) and `resetsAt` is an ISO time; either is null when the account's
26
- * usage reading or the provider's refusal did not state it.
27
- */
28
- export interface IPreuseLimit {
29
- kind: 'usage_limit' | 'rate_limit';
30
- window: string | null;
31
- resetsAt: string | null;
32
- }
33
-
34
- /**
35
- * `Weekly usage limit reached, resets in 16h 46min (2026-09-27 05:12)`: human text, so the reset reads in local time.
36
- * The limit itself keeps its ISO `resetsAt` for receipts and JSON output.
37
- */
38
- export const describePreuseLimit = (limitArg: IPreuseLimit, nowArg: number): string => {
39
- const name = `${limitArg.window === null ? '' : `${limitArg.window} `}${limitArg.kind === 'usage_limit' ? 'usage' : 'rate'} limit reached`;
40
- const countdown = limitArg.resetsAt === null ? null : until(limitArg.resetsAt, nowArg);
41
- const at = localDateTime(limitArg.resetsAt);
42
- const reset = limitArg.resetsAt === null ? ''
43
- : countdown === 'due' ? `, due to reset now (${at})` : `, resets in ${countdown} (${at})`;
44
- return `${name.charAt(0).toUpperCase()}${name.slice(1)}${reset}`;
45
- };
23
+ /** The authority's `IAuthSwitchPreuseLimit`, under the name the package root has exported since 10.0.0. */
24
+ export type IPreuseLimit = IAuthSwitchPreuseLimit;
46
25
 
47
26
  /**
48
27
  * A preuse a limit stopped. `usage` is the account's own usage reading before anything was sent; `provider`
@@ -50,7 +29,7 @@ export const describePreuseLimit = (limitArg: IPreuseLimit, nowArg: number): str
50
29
  * answer: a refusal before its answer consumed nothing.
51
30
  */
52
31
  export class PreuseLimitError extends PreuseError {
53
- constructor(public readonly limit: IPreuseLimit, public readonly source: 'usage' | 'provider',
32
+ constructor(public readonly limit: IAuthSwitchPreuseLimit, public readonly source: 'usage' | 'provider',
54
33
  public readonly generationStarted: boolean, nowArg: number) {
55
34
  const described = describePreuseLimit(limit, nowArg);
56
35
  super(source === 'usage' ? `${described}. No preuse prompt was sent.`
@@ -61,37 +40,12 @@ export class PreuseLimitError extends PreuseError {
61
40
  }
62
41
  }
63
42
 
64
- /**
65
- * Why a sent preuse prompt failed without a limit, and never with the provider's text.
66
- *
67
- * `http` is an answer with an HTTP error status; `stream` an error the answer stream reported after it began,
68
- * with the machine-readable code the provider stated and no status, since its answer was a success; `response` an answer that could not be read;
69
- * `network` a connection that failed before any answer; `aborted` an abort that was neither the caller's nor the
70
- * deadline; `unknown` anything else. `status` and `providerCode` are null when the failure did not state them.
71
- */
72
- export interface IPreuseFailure {
73
- kind: 'http' | 'stream' | 'response' | 'network' | 'aborted' | 'unknown';
74
- status: number | null;
75
- providerCode: string | null;
76
- }
77
-
78
- /** `the provider answered HTTP 503`: the cause of a failed preuse, as the tail of a sentence. */
79
- export const describePreuseFailure = (failureArg: IPreuseFailure): string => {
80
- const status = failureArg.status === null ? null : `HTTP ${failureArg.status}`;
81
- switch (failureArg.kind) {
82
- case 'http': return `the provider answered ${status ?? 'with an HTTP error'}`;
83
- case 'stream':
84
- return `the answer stream reported an error${failureArg.providerCode === null ? '' : ` (code ${failureArg.providerCode})`}`;
85
- case 'response': return `the provider's answer${status ? ` (${status})` : ''} could not be read`;
86
- case 'network': return 'no answer arrived: the connection failed';
87
- case 'aborted': return 'the request was aborted';
88
- case 'unknown': return 'the request ended in an error that names no cause';
89
- }
90
- };
43
+ /** The authority's `IAuthSwitchPreuseFailure`, under the name the package root has exported since 10.0.0. */
44
+ export type IPreuseFailure = IAuthSwitchPreuseFailure;
91
45
 
92
46
  /** A sent preuse prompt that failed for the stated cause; its tokens may have been consumed. */
93
47
  export class PreuseFailureError extends PreuseError {
94
- constructor(public readonly failure: IPreuseFailure) {
48
+ constructor(public readonly failure: IAuthSwitchPreuseFailure) {
95
49
  super(`Preuse did not complete: ${describePreuseFailure(failure)}. ${preuseMayHaveConsumed}`, true);
96
50
  }
97
51
  }
package/ts/timetext.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Human time text: countdowns to a deadline and timestamps in local calendar time.
3
+ *
4
+ * It imports nothing, so the browser-safe `./preuse-text` entry point can render a reset exactly as the
5
+ * command line does without pulling a Node built-in into a bundle.
6
+ */
7
+
8
+ /** A countdown is a snapshot of the provider's deadline, not proof a quota has reset. */
9
+ export const until = (timestampArg: string | null, nowArg: number): string => {
10
+ if (timestampArg === null) return 'Not scheduled';
11
+ const remaining = Date.parse(timestampArg) - nowArg;
12
+ if (!Number.isFinite(remaining)) return 'Unavailable';
13
+ if (remaining <= 0) return 'due';
14
+ const minutes = Math.floor(remaining / 60000);
15
+ if (!minutes) return '<1min';
16
+ const days = Math.floor(minutes / 1440);
17
+ const hours = Math.floor(minutes % 1440 / 60);
18
+ return [days ? `${days}d` : '', hours ? `${hours}h` : '', minutes % 60 ? `${minutes % 60}min` : ''].filter(Boolean).join(' ');
19
+ };
20
+ /**
21
+ * A two-unit countdown for condensed tables: `2h 13m`, `3d 4h`, `45m`.
22
+ *
23
+ * `until` stays the long form used by the list view and the TUI. This one trades the third unit for
24
+ * a column that never wraps, and it never invents a deadline: an unknown or unparseable timestamp is
25
+ * reported as such rather than as zero time remaining.
26
+ */
27
+ const twoUnits = (millisecondsArg: number): string => {
28
+ const minutes = Math.floor(millisecondsArg / 60000);
29
+ const days = Math.floor(minutes / 1440);
30
+ const hours = Math.floor(minutes % 1440 / 60);
31
+ if (days) return hours ? `${days}d ${hours}h` : `${days}d`;
32
+ if (hours) return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`;
33
+ return `${minutes}m`;
34
+ };
35
+ export const compactUntil = (timestampArg: string | null, nowArg: number, unavailableArg = 'n/a'): string => {
36
+ if (timestampArg === null) return 'not scheduled';
37
+ const remaining = Date.parse(timestampArg) - nowArg;
38
+ if (!Number.isFinite(remaining)) return unavailableArg;
39
+ if (remaining <= 0) return 'due';
40
+ return remaining < 60000 ? '<1m' : twoUnits(remaining);
41
+ };
42
+ /**
43
+ * The same two-unit form for a past timestamp, for "saved 3d 4h ago".
44
+ *
45
+ * The result is always a bare duration or the caller's unavailable marker, never a phrase: callers
46
+ * suffix it ("... ago"), and a phrase would read as "just now ago". A span under a minute is `<1m`,
47
+ * exactly as `compactUntil` reports one.
48
+ */
49
+ export const compactSince = (timestampArg: string | null, nowArg: number, unavailableArg = 'n/a'): string => {
50
+ if (timestampArg === null) return unavailableArg;
51
+ const elapsed = nowArg - Date.parse(timestampArg);
52
+ if (!Number.isFinite(elapsed)) return unavailableArg;
53
+ return elapsed < 60000 ? '<1m' : twoUnits(elapsed);
54
+ };
55
+ const twoDigits = (valueArg: number): string => String(valueArg).padStart(2, '0');
56
+ /**
57
+ * A timestamp in local calendar time: `2026-10-08`, or `2026-10-08 23:14` with the time. Human output never prints
58
+ * a raw ISO timestamp; an unknown or unparseable one reads as the caller's unavailable marker.
59
+ */
60
+ const localTimestamp = (timestampArg: string | null, withTimeArg: boolean, unavailableArg: string): string => {
61
+ const time = timestampArg === null ? Number.NaN : Date.parse(timestampArg);
62
+ if (!Number.isFinite(time)) return unavailableArg;
63
+ const date = new Date(time);
64
+ const day = `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`;
65
+ return withTimeArg ? `${day} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}` : day;
66
+ };
67
+ export const localDate = (timestampArg: string | null, unavailableArg = 'n/a'): string => localTimestamp(timestampArg, false, unavailableArg);
68
+ export const localDateTime = (timestampArg: string | null, unavailableArg = 'n/a'): string => localTimestamp(timestampArg, true, unavailableArg);
69
+ /** A moment's local time of day with seconds, `14:02:07`, for a log line whose date is implied. */
70
+ export const localTime = (timeArg: number): string => {
71
+ const date = new Date(timeArg);
72
+ return [date.getHours(), date.getMinutes(), date.getSeconds()].map(twoDigits).join(':');
73
+ };
74
+ const ISO_TIMESTAMP = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})/g;
75
+ /** Provider text with every ISO 8601 timestamp in it rewritten as local date and time. */
76
+ export const localiseTimestamps = (textArg: string): string => textArg.replace(ISO_TIMESTAMP, match => localDateTime(match, match));
package/ts/watchpolicy.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { compactUntil, usagePercentText } from './accounts.js';
1
+ import { usagePercentText } from './accounts.js';
2
+ import { compactUntil } from './timetext.js';
2
3
  import { plainText } from './formatting.js';
3
4
  import type { IHarnessUsageWindow } from './interfaces.harness.js';
4
5