@cortexkit/opencode-openai-auth 0.8.0 → 0.9.1

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 (40) hide show
  1. package/README.md +1 -1
  2. package/dist/{index-3w4vmckx.js → index-4effjmr6.js} +2 -2
  3. package/dist/{index-507jh8bv.js → index-9ehhb051.js} +1 -1
  4. package/dist/index-g3hsdjh4.js +2 -0
  5. package/dist/index-hh52v8sb.js +2 -0
  6. package/dist/{index-944gsj1s.js → index-jwn4r3sw.js} +1 -1
  7. package/dist/{index-02h7g5f9.js → index-sdt2d2ra.js} +1 -1
  8. package/dist/index-sq36b120.js +211 -0
  9. package/dist/index.d.ts +172 -341
  10. package/dist/index.js +65 -65
  11. package/dist/internal-BTndPmW2.d.ts +914 -0
  12. package/dist/quota-normalize-08hpp51v.js +1 -0
  13. package/dist/rpc/port-file.js +1 -1
  14. package/dist/rpc/protocol.d.ts +1 -1
  15. package/dist/rpc/rpc-client.d.ts +2 -1
  16. package/dist/rpc/rpc-client.js +1 -1
  17. package/dist/rpc/rpc-dir.js +1 -1
  18. package/dist/sidebar-state-CoKYKcLn.d.ts +220 -0
  19. package/dist/sidebar-state.d.ts +2 -172
  20. package/dist/sidebar-state.js +1 -1
  21. package/dist/{token-jdvpwk60.js → token-9823y0pc.js} +1 -1
  22. package/dist/token-util-ypgz7rge.js +1 -0
  23. package/dist/tui-preferences.js +1 -1
  24. package/dist/tui.d.ts +27 -5
  25. package/package.json +3 -2
  26. package/src/tui-compiled/raw/core-custody-state.ts +243 -0
  27. package/src/tui-compiled/raw/sidebar-state.ts +168 -12
  28. package/src/tui-compiled/raw/tui-command-dialogs.tsx +89 -39
  29. package/src/tui-compiled/raw/tui.tsx +138 -18
  30. package/src/tui-compiled/runtime/core-custody-state.ts +243 -0
  31. package/src/tui-compiled/runtime/sidebar-state.ts +168 -12
  32. package/src/tui-compiled/runtime/tui-command-dialogs.tsx +70 -33
  33. package/src/tui-compiled/runtime/tui.tsx +241 -143
  34. package/src/tui-compiled/shared/logger.ts +9 -2
  35. package/dist/index-2xppkvkx.js +0 -2
  36. package/dist/index-nshbgkz1.js +0 -198
  37. package/dist/index-xyg86jgt.js +0 -2
  38. package/dist/protocol-Cv9ZmU0p.d.ts +0 -24
  39. package/dist/quota-normalize-wap0rne8.js +0 -1
  40. package/dist/token-util-zzm7paz9.js +0 -1
@@ -0,0 +1,243 @@
1
+ export type CustodyMode = 'local' | 'claustrum'
2
+
3
+ export type ManifestCustodyState = 'absent' | 'present' | 'unreadable'
4
+
5
+ export type LocalCustodyState =
6
+ | 'real'
7
+ | 'tombstone'
8
+ | 'empty'
9
+ | 'gone'
10
+ | 'slot-absent'
11
+
12
+ export type VaultCustodyState =
13
+ | 'serves'
14
+ | 'cold'
15
+ | 'needs_reauth'
16
+ | 'identity_mismatch'
17
+ | 'no_handle'
18
+
19
+ export const CUSTODY_INERT_REASONS = [
20
+ 'needs-login',
21
+ 'mode-mismatch',
22
+ 'corrupt-under-binding',
23
+ 'manifest-unreadable',
24
+ 'unbound-under-claustrum',
25
+ 'orphan-tombstone',
26
+ 'vault-cold',
27
+ 'vault-reauth',
28
+ 'identity-mismatch',
29
+ 'no-handle',
30
+ 'takeover-incomplete',
31
+ 'new-local-family-under-claustrum',
32
+ 'takeover-incomplete/vault-unavailable',
33
+ 'takeover-incomplete/slot-absent',
34
+ ] as const
35
+
36
+ export type CustodyInertReason = (typeof CUSTODY_INERT_REASONS)[number]
37
+
38
+ export type CustodyVerdict =
39
+ | { kind: 'LOCAL' }
40
+ | {
41
+ kind: 'VAULT'
42
+ canonicalize?: boolean
43
+ installTombstone?: boolean
44
+ }
45
+ | {
46
+ kind: 'INERT'
47
+ reason: CustodyInertReason
48
+ installTombstone?: boolean
49
+ canonicalize?: boolean
50
+ }
51
+ | { kind: 'NEEDS_LOGIN'; reason?: 'corrupt' }
52
+
53
+ export interface EvaluateCustodyStartupInput {
54
+ mode: CustodyMode
55
+ manifest: ManifestCustodyState
56
+ local: LocalCustodyState
57
+ vault: () => VaultCustodyState
58
+ manifestFailureReason?: string
59
+ fingerprintMatch?: boolean
60
+ verifiedInProcessLogin: boolean
61
+ isMain?: boolean
62
+ }
63
+
64
+ function unreachable(value: never): never {
65
+ return value
66
+ }
67
+
68
+ function withCanonicalization<
69
+ T extends Extract<CustodyVerdict, { kind: 'VAULT' | 'INERT' }>,
70
+ >(local: LocalCustodyState, verdict: T): T | (T & { canonicalize: true }) {
71
+ return local === 'empty' ? { ...verdict, canonicalize: true } : verdict
72
+ }
73
+
74
+ function evaluateLocal(local: LocalCustodyState): CustodyVerdict {
75
+ switch (local) {
76
+ case 'real':
77
+ return { kind: 'LOCAL' }
78
+ case 'tombstone':
79
+ case 'empty':
80
+ case 'slot-absent':
81
+ return { kind: 'NEEDS_LOGIN' }
82
+ case 'gone':
83
+ return { kind: 'NEEDS_LOGIN', reason: 'corrupt' }
84
+ }
85
+ return unreachable(local)
86
+ }
87
+
88
+ function evaluateLocalWithBinding(
89
+ local: LocalCustodyState,
90
+ verifiedInProcessLogin: boolean,
91
+ ): CustodyVerdict {
92
+ switch (local) {
93
+ case 'real':
94
+ return verifiedInProcessLogin
95
+ ? { kind: 'LOCAL' }
96
+ : { kind: 'INERT', reason: 'needs-login' }
97
+ case 'tombstone':
98
+ case 'empty':
99
+ case 'slot-absent':
100
+ return { kind: 'INERT', reason: 'mode-mismatch' }
101
+ case 'gone':
102
+ return { kind: 'INERT', reason: 'corrupt-under-binding' }
103
+ }
104
+ return unreachable(local)
105
+ }
106
+
107
+ function evaluateUnboundClaustrum(local: LocalCustodyState): CustodyVerdict {
108
+ switch (local) {
109
+ case 'tombstone':
110
+ case 'empty':
111
+ return { kind: 'INERT', reason: 'orphan-tombstone' }
112
+ case 'real':
113
+ case 'gone':
114
+ case 'slot-absent':
115
+ return { kind: 'INERT', reason: 'unbound-under-claustrum' }
116
+ }
117
+ return unreachable(local)
118
+ }
119
+
120
+ function evaluateTombstone(
121
+ local: Extract<LocalCustodyState, 'tombstone' | 'empty'>,
122
+ vault: VaultCustodyState,
123
+ ): CustodyVerdict {
124
+ switch (vault) {
125
+ case 'serves':
126
+ return withCanonicalization(local, { kind: 'VAULT' })
127
+ case 'cold':
128
+ return withCanonicalization(local, {
129
+ kind: 'INERT',
130
+ reason: 'vault-cold',
131
+ })
132
+ case 'needs_reauth':
133
+ return withCanonicalization(local, {
134
+ kind: 'INERT',
135
+ reason: 'vault-reauth',
136
+ })
137
+ case 'identity_mismatch':
138
+ return withCanonicalization(local, {
139
+ kind: 'INERT',
140
+ reason: 'identity-mismatch',
141
+ })
142
+ case 'no_handle':
143
+ return withCanonicalization(local, { kind: 'INERT', reason: 'no-handle' })
144
+ }
145
+ return unreachable(vault)
146
+ }
147
+
148
+ function evaluateReal(
149
+ vault: VaultCustodyState,
150
+ fingerprintMatch: boolean | undefined,
151
+ ): CustodyVerdict {
152
+ switch (vault) {
153
+ case 'serves':
154
+ return fingerprintMatch === false
155
+ ? { kind: 'INERT', reason: 'new-local-family-under-claustrum' }
156
+ : { kind: 'INERT', reason: 'takeover-incomplete' }
157
+ case 'cold':
158
+ case 'needs_reauth':
159
+ return { kind: 'INERT', reason: 'takeover-incomplete/vault-unavailable' }
160
+ case 'identity_mismatch':
161
+ return { kind: 'INERT', reason: 'identity-mismatch' }
162
+ case 'no_handle':
163
+ return { kind: 'INERT', reason: 'no-handle' }
164
+ }
165
+ return unreachable(vault)
166
+ }
167
+
168
+ function evaluateGone(vault: VaultCustodyState): CustodyVerdict {
169
+ switch (vault) {
170
+ case 'serves':
171
+ return { kind: 'VAULT', installTombstone: true }
172
+ case 'cold':
173
+ return { kind: 'INERT', reason: 'vault-cold', installTombstone: true }
174
+ case 'needs_reauth':
175
+ return { kind: 'INERT', reason: 'vault-reauth', installTombstone: true }
176
+ case 'identity_mismatch':
177
+ return { kind: 'INERT', reason: 'identity-mismatch' }
178
+ case 'no_handle':
179
+ return { kind: 'INERT', reason: 'no-handle' }
180
+ }
181
+ return unreachable(vault)
182
+ }
183
+
184
+ function evaluateSlotAbsent(vault: VaultCustodyState): CustodyVerdict {
185
+ switch (vault) {
186
+ case 'serves':
187
+ case 'cold':
188
+ case 'needs_reauth':
189
+ return { kind: 'INERT', reason: 'takeover-incomplete/slot-absent' }
190
+ case 'identity_mismatch':
191
+ return { kind: 'INERT', reason: 'identity-mismatch' }
192
+ case 'no_handle':
193
+ return { kind: 'INERT', reason: 'no-handle' }
194
+ }
195
+ return unreachable(vault)
196
+ }
197
+
198
+ function evaluatePresentClaustrum(
199
+ local: LocalCustodyState,
200
+ vault: VaultCustodyState,
201
+ fingerprintMatch: boolean | undefined,
202
+ ): CustodyVerdict {
203
+ switch (local) {
204
+ case 'tombstone':
205
+ case 'empty':
206
+ return evaluateTombstone(local, vault)
207
+ case 'real':
208
+ return evaluateReal(vault, fingerprintMatch)
209
+ case 'gone':
210
+ return evaluateGone(vault)
211
+ case 'slot-absent':
212
+ return evaluateSlotAbsent(vault)
213
+ }
214
+ return unreachable(local)
215
+ }
216
+
217
+ export function evaluateCustodyStartup(
218
+ input: EvaluateCustodyStartupInput,
219
+ ): CustodyVerdict {
220
+ if (input.manifest === 'unreadable') {
221
+ return { kind: 'INERT', reason: 'manifest-unreadable' }
222
+ }
223
+
224
+ switch (input.mode) {
225
+ case 'local':
226
+ return input.manifest === 'present'
227
+ ? evaluateLocalWithBinding(input.local, input.verifiedInProcessLogin)
228
+ : evaluateLocal(input.local)
229
+ case 'claustrum':
230
+ if (input.manifest === 'absent') {
231
+ return evaluateUnboundClaustrum(input.local)
232
+ }
233
+ if (input.manifest === 'present') {
234
+ return evaluatePresentClaustrum(
235
+ input.local,
236
+ input.vault(),
237
+ input.fingerprintMatch,
238
+ )
239
+ }
240
+ return unreachable(input.manifest)
241
+ }
242
+ return unreachable(input.mode)
243
+ }
@@ -6,10 +6,25 @@ export interface QuotaWindow {
6
6
  windowMinutes?: number
7
7
  }
8
8
 
9
+ export interface SpendControlReading {
10
+ limit: number
11
+ used: number
12
+ remaining: number
13
+ usedPercent: number
14
+ remainingPercent: number
15
+ resetsAt?: string
16
+ unit?: string
17
+ source?: string
18
+ reached: boolean
19
+ }
20
+
9
21
  export interface AccountQuota {
10
22
  checkedAt?: number
11
23
  primary?: QuotaWindow
12
24
  secondary?: QuotaWindow
25
+ spendControl?: SpendControlReading
26
+ // The source reported that no credit budget exists; see OAuthQuotaSnapshot.
27
+ spendControlCleared?: true
13
28
  resetCreditsAvailable?: number
14
29
  }
15
30
 
@@ -88,6 +103,17 @@ export interface SidebarAccountState {
88
103
  killed: boolean
89
104
  enabled: boolean
90
105
  resetCredits?: number
106
+ custody?: SidebarAccountCustody
107
+ }
108
+
109
+ export type SidebarCustodyState = 'vault' | 'needsLogin' | 'local' | 'inert'
110
+
111
+ export type SidebarCustodyReason = CustodyInertReason | 'corrupt'
112
+
113
+ export interface SidebarAccountCustody {
114
+ state: SidebarCustodyState
115
+ reason?: SidebarCustodyReason
116
+ recordVersion?: number
91
117
  }
92
118
 
93
119
  export interface ActiveRoutingEntry {
@@ -132,6 +158,7 @@ export interface SidebarState {
132
158
  quota: AccountQuota | null
133
159
  /** ChatGPT identity of the main account this quota belongs to. */
134
160
  mainAccountId?: string
161
+ custody?: SidebarAccountCustody
135
162
  killed: boolean
136
163
  quotaBackedOff?: boolean
137
164
  quotaBackoffUntil?: number
@@ -156,6 +183,11 @@ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
156
183
  import { tmpdir } from 'node:os'
157
184
  import { dirname, join } from 'node:path'
158
185
  import { acquireRefreshFileLock } from '../shared/internal.ts'
186
+ import {
187
+ CUSTODY_INERT_REASONS,
188
+ type CustodyInertReason,
189
+ type CustodyVerdict,
190
+ } from './core-custody-state.ts'
159
191
  import { createLogger } from './logger.ts'
160
192
 
161
193
  const logSb = createLogger('sidebar')
@@ -183,6 +215,49 @@ function resetCreditsField(value: unknown): { resetCredits?: number } {
183
215
  return credits !== undefined ? { resetCredits: credits } : {}
184
216
  }
185
217
 
218
+ const CUSTODY_STATES = new Set<SidebarCustodyState>([
219
+ 'vault',
220
+ 'needsLogin',
221
+ 'local',
222
+ 'inert',
223
+ ])
224
+
225
+ const CUSTODY_REASONS = new Set<SidebarCustodyReason>([
226
+ ...CUSTODY_INERT_REASONS,
227
+ 'corrupt',
228
+ ])
229
+
230
+ /**
231
+ * Tolerant reader for the per-fallback `custody` projection. Unknown state
232
+ * or reason values are dropped (NOT replaced with a default — a stale state
233
+ * file with an experimental `vaultHealing` value must not silently render
234
+ * as `local`, it must render as no-projection-at-all). Valid values round-
235
+ * trip byte-identical. The output contains only `state` and `reason`.
236
+ */
237
+ function normalizeSidebarCustody(
238
+ value: unknown,
239
+ ): SidebarAccountCustody | undefined {
240
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
241
+ return undefined
242
+ }
243
+ const c = value as Record<string, unknown>
244
+ if (
245
+ typeof c.state !== 'string' ||
246
+ !CUSTODY_STATES.has(c.state as SidebarCustodyState)
247
+ ) {
248
+ return undefined
249
+ }
250
+ const state = c.state as SidebarCustodyState
251
+ const out: SidebarAccountCustody = { state }
252
+ if (
253
+ typeof c.reason === 'string' &&
254
+ CUSTODY_REASONS.has(c.reason as SidebarCustodyReason)
255
+ ) {
256
+ out.reason = c.reason as SidebarCustodyReason
257
+ }
258
+ return out
259
+ }
260
+
186
261
  function normalizeActiveRouting(value: unknown): ActiveRoutingMap | undefined {
187
262
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
188
263
  return undefined
@@ -276,6 +351,23 @@ export function getSidebarStateFile(): string {
276
351
  return process.env[STATE_FILE_ENV] || DEFAULT_STATE_FILE
277
352
  }
278
353
 
354
+ export function projectCustodyForSidebar(
355
+ verdict: CustodyVerdict,
356
+ ): SidebarAccountCustody {
357
+ switch (verdict.kind) {
358
+ case 'LOCAL':
359
+ return { state: 'local' }
360
+ case 'VAULT':
361
+ return { state: 'vault' }
362
+ case 'INERT':
363
+ return { state: 'inert', reason: verdict.reason }
364
+ case 'NEEDS_LOGIN':
365
+ return verdict.reason === 'corrupt'
366
+ ? { state: 'needsLogin', reason: 'corrupt' }
367
+ : { state: 'needsLogin' }
368
+ }
369
+ }
370
+
279
371
  export const DEFAULT_SIDEBAR_STATE: SidebarState = {
280
372
  main: { quota: null, killed: false },
281
373
  fallbacks: [],
@@ -315,6 +407,10 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
315
407
  ...(typeof m.mainAccountId === 'string'
316
408
  ? { mainAccountId: m.mainAccountId }
317
409
  : {}),
410
+ ...(() => {
411
+ const custody = normalizeSidebarCustody(m.custody)
412
+ return custody ? { custody } : {}
413
+ })(),
318
414
  // Preserve optional backoff fields if present
319
415
  ...(typeof m.quotaBackedOff === 'boolean'
320
416
  ? { quotaBackedOff: m.quotaBackedOff }
@@ -347,17 +443,21 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
347
443
  !Array.isArray(entry) &&
348
444
  typeof (entry as Record<string, unknown>).id === 'string',
349
445
  )
350
- .map((e) => ({
351
- id: e.id as string,
352
- label: typeof e.label === 'string' ? e.label : undefined,
353
- ...(typeof e.accountId === 'string'
354
- ? { accountId: e.accountId }
355
- : {}),
356
- quota: ('quota' in e ? e.quota : null) as AccountQuota | null,
357
- killed: typeof e.killed === 'boolean' ? e.killed : false,
358
- enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
359
- ...resetCreditsField(e.resetCredits),
360
- }))
446
+ .map((e) => {
447
+ const custody = normalizeSidebarCustody(e.custody)
448
+ return {
449
+ id: e.id as string,
450
+ label: typeof e.label === 'string' ? e.label : undefined,
451
+ ...(typeof e.accountId === 'string'
452
+ ? { accountId: e.accountId }
453
+ : {}),
454
+ quota: ('quota' in e ? e.quota : null) as AccountQuota | null,
455
+ killed: typeof e.killed === 'boolean' ? e.killed : false,
456
+ enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
457
+ ...resetCreditsField(e.resetCredits),
458
+ ...(custody ? { custody } : {}),
459
+ }
460
+ })
361
461
  : []
362
462
 
363
463
  // activeId — string or undefined
@@ -428,6 +528,27 @@ export function isUsableRoutingEntry(
428
528
  )
429
529
  }
430
530
 
531
+ // The credit budget's own exhaustion signal, shared by admission and sticky
532
+ // migration so both agree on what "spent" means. `reached` is the provider's
533
+ // authoritative boolean — the percentage is only a display approximation — and
534
+ // the check fails open on a missing or lapsed reset exactly like a rate-limit
535
+ // window, so a stale or corrupt reading never blocks.
536
+ export function spendControlExhaustedResetAt(
537
+ quota: AccountQuota | null | undefined,
538
+ now = Date.now(),
539
+ ): { resetsAt: string; resetAtMs: number } | undefined {
540
+ const spendControl = quota?.spendControl
541
+ if (
542
+ spendControl?.reached !== true ||
543
+ typeof spendControl.resetsAt !== 'string'
544
+ ) {
545
+ return undefined
546
+ }
547
+ const resetAtMs = Date.parse(spendControl.resetsAt)
548
+ if (!Number.isFinite(resetAtMs) || resetAtMs <= now) return undefined
549
+ return { resetsAt: spendControl.resetsAt, resetAtMs }
550
+ }
551
+
431
552
  // Earliest future reset among the quota's exhausted windows, or undefined when
432
553
  // no present window is exhausted. Every present window is evaluated — matching
433
554
  // the admission policy, which rejects an account when ANY live window is below
@@ -454,6 +575,12 @@ export function exhaustedQuotaResetAt(
454
575
  earliest = { resetsAt: window.resetsAt, resetAtMs }
455
576
  }
456
577
  }
578
+ // The credit budget is a third axis on its own reset clock (a month, not
579
+ // 5h/7d), judged by the same shared signal sticky migration uses.
580
+ const spendReset = spendControlExhaustedResetAt(quota, now)
581
+ if (spendReset && (!earliest || spendReset.resetAtMs < earliest.resetAtMs)) {
582
+ earliest = spendReset
583
+ }
457
584
  return earliest
458
585
  }
459
586
 
@@ -979,6 +1106,19 @@ function freshestWindow(
979
1106
  // of the merged result. Only safe when both snapshots share an account identity
980
1107
  // (sameAccountIdentity) — an identity switch must whole-pick (freshestQuota) so
981
1108
  // windows from two accounts are never combined.
1109
+ // The fresher side decides the budget when it knows anything about it: its own
1110
+ // reading, or its report that no budget exists. Only when it says nothing (a
1111
+ // header or WebSocket push, which never carries spend control) does the other
1112
+ // side's reading survive, and even then not if that side reported none.
1113
+ function mergeSpendControl(
1114
+ fresher: AccountQuota,
1115
+ older: AccountQuota,
1116
+ ): SpendControlReading | undefined {
1117
+ if (fresher.spendControl !== undefined) return fresher.spendControl
1118
+ if (fresher.spendControlCleared === true) return undefined
1119
+ return older.spendControl
1120
+ }
1121
+
982
1122
  function mergeQuotaByWindow(
983
1123
  incoming: AccountQuota | null,
984
1124
  existing: AccountQuota | null,
@@ -1004,6 +1144,13 @@ function mergeQuotaByWindow(
1004
1144
  incoming.checkedAt,
1005
1145
  existing.checkedAt,
1006
1146
  )
1147
+ const spendControl = existingSnapshotIsFresher
1148
+ ? mergeSpendControl(existing, incoming)
1149
+ : mergeSpendControl(incoming, existing)
1150
+ const spendControlCleared =
1151
+ spendControl === undefined &&
1152
+ (incoming.spendControlCleared === true ||
1153
+ existing.spendControlCleared === true)
1007
1154
  let checkedAt: number | undefined
1008
1155
  for (const stamp of [
1009
1156
  finiteWindowCheckedAt(primary),
@@ -1013,10 +1160,19 @@ function mergeQuotaByWindow(
1013
1160
  checkedAt = checkedAt === undefined ? stamp : Math.max(checkedAt, stamp)
1014
1161
  }
1015
1162
  }
1163
+ // Both budget fields are decided above, so neither may leak in from the
1164
+ // spread: an older incoming budget must not survive a fresher "no budget".
1165
+ const {
1166
+ spendControl: _incomingSpendControl,
1167
+ spendControlCleared: _incomingCleared,
1168
+ ...incomingRest
1169
+ } = incoming
1016
1170
  return {
1017
- ...incoming,
1171
+ ...incomingRest,
1018
1172
  primary,
1019
1173
  secondary,
1174
+ ...(spendControl !== undefined ? { spendControl } : {}),
1175
+ ...(spendControlCleared ? { spendControlCleared: true as const } : {}),
1020
1176
  checkedAt: checkedAt ?? incoming.checkedAt,
1021
1177
  }
1022
1178
  }
@@ -619,7 +619,7 @@ export function openCommandDialog(
619
619
  }
620
620
 
621
621
  if (payload.command === 'openai-account') {
622
- openAccountDialog(api, apply, sessionId)
622
+ openAccountDialog(api, apply, sessionId, payload)
623
623
  return
624
624
  }
625
625
 
@@ -676,12 +676,71 @@ export function buildAccountDialogRows(
676
676
  ]
677
677
  }
678
678
 
679
+ export function accountDialogModeOption(mode: unknown) {
680
+ const claustrum = mode === 'claustrum'
681
+ return {
682
+ title: claustrum ? 'Leave Claustrum' : 'Enter Claustrum',
683
+ value: claustrum ? '__local__' : '__claustrum__',
684
+ description: claustrum
685
+ ? 'Require fresh local logins before local refresh resumes'
686
+ : 'Verify every enabled account before custody takes over',
687
+ }
688
+ }
689
+
690
+ export function buildFallbackAccountOptions(input: {
691
+ id: string
692
+ label?: string
693
+ enabled: boolean
694
+ index: number
695
+ count: number
696
+ }) {
697
+ const name = input.label ?? input.id
698
+ const options: Array<{
699
+ title: string
700
+ value: string
701
+ description: string
702
+ }> = [
703
+ {
704
+ title: input.enabled ? 'Disable' : 'Enable',
705
+ value: input.enabled ? 'disable' : 'enable',
706
+ description: `${input.enabled ? 'Disable' : 'Enable'} ${name}`,
707
+ },
708
+ {
709
+ title: 'Remove',
710
+ value: 'remove',
711
+ description: `Remove ${name}`,
712
+ },
713
+ ]
714
+ if (input.index > 0) {
715
+ options.push({
716
+ title: 'Move up',
717
+ value: 'move_up',
718
+ description: 'Swap with the preceding fallback',
719
+ })
720
+ }
721
+ if (input.index < input.count - 1) {
722
+ options.push({
723
+ title: 'Move down',
724
+ value: 'move_down',
725
+ description: 'Swap with the following fallback',
726
+ })
727
+ }
728
+ options.push({
729
+ title: 'Back',
730
+ value: 'back',
731
+ description: 'Return to account list',
732
+ })
733
+ return options
734
+ }
735
+
679
736
  function openAccountDialog(
680
737
  api: TuiPluginApi,
681
738
  apply: ApplyFn,
682
739
  sessionId?: string,
740
+ payload?: OpenDialogPayload,
683
741
  ) {
684
742
  const DialogConfirm = api.ui.DialogConfirm
743
+ let claustrumMode = payload?.knobs.claustrumMode
685
744
 
686
745
  function showL1() {
687
746
  void getSidebarState().then((state) => {
@@ -691,6 +750,7 @@ function openAccountDialog(
691
750
  <DialogSelectInner
692
751
  title='OpenAI Accounts'
693
752
  options={[
753
+ accountDialogModeOption(claustrumMode),
694
754
  ...buildAccountDialogRows(state, sessionId),
695
755
  {
696
756
  title: 'Add account\u2026',
@@ -699,6 +759,19 @@ function openAccountDialog(
699
759
  },
700
760
  ]}
701
761
  onSelect={(option) => {
762
+ if (
763
+ option.value === '__claustrum__' ||
764
+ option.value === '__local__'
765
+ ) {
766
+ const args =
767
+ option.value === '__claustrum__' ? 'claustrum' : 'local'
768
+ void apply('openai-account', args).then((r) => {
769
+ api.ui.toast({ message: r.text })
770
+ claustrumMode = r.knobs.claustrumMode
771
+ showL1()
772
+ })
773
+ return
774
+ }
702
775
  if (option.value === '__add__') {
703
776
  showAddFlow()
704
777
  return
@@ -722,44 +795,12 @@ function openAccountDialog(
722
795
  showL1()
723
796
  return
724
797
  }
725
- const options: Array<{
726
- title: string
727
- value: string
728
- description: string
729
- }> = []
730
-
731
- options.push({
732
- title: 'Remove',
733
- value: 'remove',
734
- description: `Remove ${fb.label ?? fb.id}`,
735
- })
736
-
737
- if (fbIndex > 0) {
738
- const neighbor = state.fallbacks[fbIndex - 1]
739
- if (neighbor) {
740
- options.push({
741
- title: 'Move up',
742
- value: 'move_up',
743
- description: `Swap with ${neighbor.label ?? neighbor.id}`,
744
- })
745
- }
746
- }
747
-
748
- if (fbIndex < state.fallbacks.length - 1) {
749
- const neighbor = state.fallbacks[fbIndex + 1]
750
- if (neighbor) {
751
- options.push({
752
- title: 'Move down',
753
- value: 'move_down',
754
- description: `Swap with ${neighbor.label ?? neighbor.id}`,
755
- })
756
- }
757
- }
758
-
759
- options.push({
760
- title: 'Back',
761
- value: 'back',
762
- description: 'Return to account list',
798
+ const options = buildFallbackAccountOptions({
799
+ id: fb.id,
800
+ label: fb.label,
801
+ enabled: fb.enabled,
802
+ index: fbIndex,
803
+ count: state.fallbacks.length,
763
804
  })
764
805
 
765
806
  api.ui.dialog.setSize('xlarge')
@@ -785,6 +826,15 @@ function openAccountDialog(
785
826
  ))
786
827
  return
787
828
  }
829
+ if (option.value === 'enable' || option.value === 'disable') {
830
+ void apply('openai-account', `${option.value} ${id}`).then(
831
+ (r) => {
832
+ api.ui.toast({ message: r.text })
833
+ showL1()
834
+ },
835
+ )
836
+ return
837
+ }
788
838
  if (option.value === 'move_up') {
789
839
  const neighbor = state.fallbacks[fbIndex - 1]
790
840
  if (neighbor) {