@cortexkit/opencode-openai-auth 0.7.2 → 0.9.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 (118) hide show
  1. package/README.md +9 -5
  2. package/dist/index-301y4qa1.js +2 -0
  3. package/dist/index-338z7171.js +211 -0
  4. package/dist/index-5y4vzdf8.js +2 -0
  5. package/dist/{index-zxqb5ydj.js → index-9ehhb051.js} +1 -1
  6. package/dist/{index-zg1x8sty.js → index-jwn4r3sw.js} +1 -1
  7. package/dist/{index-9jpc8yct.js → index-sdt2d2ra.js} +1 -1
  8. package/dist/index-w42s0g6m.js +2 -0
  9. package/dist/index.d.ts +213 -36
  10. package/dist/index.js +59 -154
  11. package/dist/internal-Y7QNVDJT.d.ts +908 -0
  12. package/dist/quota-normalize-3qffywcm.js +1 -0
  13. package/dist/rpc/port-file.d.ts +11 -9
  14. package/dist/rpc/port-file.js +1 -1
  15. package/dist/rpc/protocol.d.ts +2 -21
  16. package/dist/rpc/rpc-client.d.ts +7 -4
  17. package/dist/rpc/rpc-client.js +1 -1
  18. package/dist/rpc/rpc-dir.d.ts +7 -5
  19. package/dist/rpc/rpc-dir.js +1 -1
  20. package/dist/sidebar-state-B98sERYt.d.ts +219 -0
  21. package/dist/sidebar-state.d.ts +2 -171
  22. package/dist/sidebar-state.js +1 -1
  23. package/dist/{token-bvp0y166.js → token-9823y0pc.js} +1 -1
  24. package/dist/token-util-ypgz7rge.js +1 -0
  25. package/dist/tui-preferences.d.ts +27 -26
  26. package/dist/tui-preferences.js +1 -1
  27. package/dist/tui.d.ts +42 -19
  28. package/package.json +8 -14
  29. package/src/tui/entry.mjs +2 -2
  30. package/src/tui-compiled/{core/account-paths.ts → raw/core-account-paths.ts} +29 -18
  31. package/src/tui-compiled/raw/core-custody-state.ts +243 -0
  32. package/src/tui-compiled/raw/logger.ts +53 -0
  33. package/src/tui-compiled/{rpc/port-file.ts → raw/rpc-port-file.ts} +1 -1
  34. package/src/tui-compiled/raw/rpc-protocol.ts +12 -0
  35. package/src/{rpc/rpc-client.ts → tui-compiled/raw/rpc-rpc-client.ts} +2 -2
  36. package/src/{sidebar-state.ts → tui-compiled/raw/sidebar-state.ts} +142 -13
  37. package/src/{tui/command-dialogs.tsx → tui-compiled/raw/tui-command-dialogs.tsx} +94 -44
  38. package/src/{tui.tsx → tui-compiled/raw/tui.tsx} +149 -28
  39. package/src/{core/account-paths.ts → tui-compiled/runtime/core-account-paths.ts} +29 -18
  40. package/src/tui-compiled/runtime/core-custody-state.ts +243 -0
  41. package/src/tui-compiled/runtime/logger.ts +53 -0
  42. package/src/{rpc/port-file.ts → tui-compiled/runtime/rpc-port-file.ts} +1 -1
  43. package/src/tui-compiled/runtime/rpc-protocol.ts +12 -0
  44. package/src/tui-compiled/{rpc/rpc-client.ts → runtime/rpc-rpc-client.ts} +2 -2
  45. package/src/tui-compiled/{sidebar-state.ts → runtime/sidebar-state.ts} +142 -13
  46. package/src/tui-compiled/{tui/command-dialogs.tsx → runtime/tui-command-dialogs.tsx} +73 -37
  47. package/src/tui-compiled/{tui.tsx → runtime/tui.tsx} +251 -153
  48. package/src/tui-compiled/shared/internal.ts +6 -0
  49. package/src/{logger.ts → tui-compiled/shared/logger.ts} +91 -23
  50. package/src/tui-compiled/shared/paths.ts +33 -0
  51. package/src/{core → tui-compiled/shared}/refresh-file-lock.ts +7 -4
  52. package/dist/cli.d.ts +0 -2
  53. package/dist/cli.js +0 -15
  54. package/dist/codex-http.d.ts +0 -3
  55. package/dist/commands.d.ts +0 -88
  56. package/dist/config.d.ts +0 -47
  57. package/dist/core/account-paths.d.ts +0 -25
  58. package/dist/core/accounts.d.ts +0 -330
  59. package/dist/core/atomic-write.d.ts +0 -1
  60. package/dist/core/background-quota-refresh.d.ts +0 -46
  61. package/dist/core/backoff.d.ts +0 -53
  62. package/dist/core/cachekeep.d.ts +0 -118
  63. package/dist/core/oauth.d.ts +0 -138
  64. package/dist/core/provider.d.ts +0 -70
  65. package/dist/core/quota-manager.d.ts +0 -192
  66. package/dist/core/refresh-all-quota.d.ts +0 -86
  67. package/dist/core/refresh-file-lock.d.ts +0 -12
  68. package/dist/core/reset-credits.d.ts +0 -116
  69. package/dist/core/sticky-routing.d.ts +0 -43
  70. package/dist/dump.d.ts +0 -20
  71. package/dist/hosted-web-search.d.ts +0 -5
  72. package/dist/index-87145jkw.js +0 -2
  73. package/dist/index-8f1200qx.js +0 -6
  74. package/dist/index-cfjed1st.js +0 -89
  75. package/dist/index-nrvjr97q.js +0 -3
  76. package/dist/index-qaggaze1.js +0 -2
  77. package/dist/index-sdcpzbnk.js +0 -2
  78. package/dist/logger.d.ts +0 -22
  79. package/dist/model-costs.d.ts +0 -29
  80. package/dist/prompt-context.d.ts +0 -19
  81. package/dist/quota-normalize-dvwtmy7f.js +0 -1
  82. package/dist/quota-normalize.d.ts +0 -43
  83. package/dist/raw-ws-bun.d.ts +0 -40
  84. package/dist/raw-ws-node.d.ts +0 -37
  85. package/dist/raw-ws-upgrade.d.ts +0 -2
  86. package/dist/raw-ws.d.ts +0 -3
  87. package/dist/response-stream-error.d.ts +0 -12
  88. package/dist/rpc/notifications.d.ts +0 -5
  89. package/dist/rpc/rpc-server.d.ts +0 -17
  90. package/dist/token-util-jcj7k7pa.js +0 -1
  91. package/dist/tui/command-dialogs.d.ts +0 -21
  92. package/dist/util/error.d.ts +0 -1
  93. package/dist/util/open-url.d.ts +0 -6
  94. package/dist/util/proxy-env.d.ts +0 -4
  95. package/dist/util/record.d.ts +0 -1
  96. package/dist/util/stable-json.d.ts +0 -1
  97. package/dist/util/uuid-v7.d.ts +0 -1
  98. package/dist/version.d.ts +0 -1
  99. package/dist/ws-pool.d.ts +0 -57
  100. package/dist/ws.d.ts +0 -71
  101. package/src/rpc/notifications.ts +0 -54
  102. package/src/rpc/protocol.ts +0 -33
  103. package/src/rpc/rpc-server.ts +0 -172
  104. package/src/tui-compiled/core/refresh-file-lock.ts +0 -381
  105. package/src/tui-compiled/logger.ts +0 -196
  106. package/src/tui-compiled/rpc/protocol.ts +0 -33
  107. package/src/util/error.ts +0 -9
  108. package/src/util/open-url.ts +0 -30
  109. package/src/util/proxy-env.ts +0 -42
  110. package/src/util/record.ts +0 -3
  111. package/src/util/stable-json.ts +0 -15
  112. package/src/util/uuid-v7.ts +0 -18
  113. /package/src/{rpc/rpc-dir.ts → tui-compiled/raw/rpc-rpc-dir.ts} +0 -0
  114. /package/src/tui-compiled/{tui-preferences.ts → raw/tui-preferences.ts} +0 -0
  115. /package/src/tui-compiled/{rpc/rpc-dir.ts → runtime/rpc-rpc-dir.ts} +0 -0
  116. /package/src/{tui-preferences.ts → tui-compiled/runtime/tui-preferences.ts} +0 -0
  117. /package/src/tui-compiled/{util → shared/util}/error.ts +0 -0
  118. /package/src/tui-compiled/{util → shared/util}/open-url.ts +0 -0
@@ -16,10 +16,10 @@ import {
16
16
  onCleanup,
17
17
  Show,
18
18
  } from 'solid-js'
19
- import { createLogger } from './logger.js'
20
- import type { ApplyRequest, CommandModalName } from './rpc/protocol.js'
21
- import { createRpcClient } from './rpc/rpc-client.js'
22
- import { resolveRpcDir } from './rpc/rpc-dir.js'
19
+ import { createLogger } from './logger.ts'
20
+ import type { ApplyRequest, CommandModalName } from './rpc-protocol.ts'
21
+ import { createRpcClient } from './rpc-rpc-client.ts'
22
+ import { resolveRpcDir } from './rpc-rpc-dir.ts'
23
23
  import {
24
24
  type AccountQuota,
25
25
  computeQuotaPacing,
@@ -33,8 +33,9 @@ import {
33
33
  resolveSessionSidebarRouting,
34
34
  resolveSessionStickyAccount,
35
35
  type SidebarState,
36
- } from './sidebar-state.js'
37
- import { openCommandDialog } from './tui/command-dialogs.js'
36
+ type SpendControlReading,
37
+ } from './sidebar-state.ts'
38
+ import { openCommandDialog } from './tui-command-dialogs.tsx'
38
39
  import {
39
40
  type AppearancePrefs,
40
41
  computeEffectiveOrder,
@@ -45,7 +46,7 @@ import {
45
46
  readTuiPreferencesFile,
46
47
  resolveOpenaiAuthPrefs,
47
48
  watchTuiPreferences,
48
- } from './tui-preferences.js'
49
+ } from './tui-preferences.ts'
49
50
 
50
51
  const RPC_POLL_MS = 500
51
52
  let rpcPollStarted = false
@@ -61,14 +62,15 @@ export function buildApplyRequest(
61
62
  return { command, arguments: arguments_, sessionId }
62
63
  }
63
64
 
64
- // Read package metadata from either the raw src/ entry or its generated
65
- // src/tui-compiled/ counterpart. Avoid a JSON import because package.json sits
66
- // outside the declaration build's rootDir.
65
+ // Read package metadata from the development source or either generated TUI
66
+ // variant. Avoid a JSON import because package.json sits outside the declaration
67
+ // build's rootDir.
67
68
  const PLUGIN_VERSION: string = (() => {
68
69
  const here = dirname(fileURLToPath(import.meta.url))
69
70
  for (const packageFile of [
70
71
  join(here, '..', 'package.json'),
71
72
  join(here, '..', '..', 'package.json'),
73
+ join(here, '..', '..', '..', 'package.json'),
72
74
  ]) {
73
75
  try {
74
76
  const raw = readFileSync(packageFile, 'utf8')
@@ -240,28 +242,97 @@ function CollapsedRow(props: {
240
242
  }
241
243
 
242
244
  export interface QuotaDisplayRow {
243
- key: 'primary' | 'secondary'
245
+ key: 'primary' | 'secondary' | 'spendControl'
244
246
  label: string
247
+ labelWidth: number
245
248
  window: QuotaWindow
246
249
  pacing: QuotaPacing | null
247
250
  }
248
251
 
252
+ const SPEND_CONTROL_LABEL = 'credits'
253
+
254
+ // Labels a quota snapshot contributes to the sidebar, independent of pacing or
255
+ // the wall clock — the label column is sized from these alone.
256
+ function quotaRowLabels(quota: AccountQuota | null): string[] {
257
+ const labels = getPresentQuotaWindows(quota).map((row) => row.label)
258
+ if (quota?.spendControl) labels.push(SPEND_CONTROL_LABEL)
259
+ return labels
260
+ }
261
+
262
+ // The label column is a property of the whole sidebar, not of one account's row
263
+ // set: every account's bars must start at the same column, so the width is
264
+ // derived once from every account rendered (main plus fallbacks) and threaded
265
+ // into each account's rows. The +1 keeps a separator before the longest label's
266
+ // bar; 3 is the floor the short 5h/7d labels already relied on.
267
+ /**
268
+ * Every quota the sidebar will draw, in one list.
269
+ *
270
+ * The label column is shared by all of them, so the width has to be measured
271
+ * over this exact set — measuring a subset is what made stacked accounts
272
+ * disagree about where the bar starts. Named rather than inlined at each call
273
+ * site because the set is the part that can be got wrong, and a call site
274
+ * cannot be tested without the host's rendering runtime.
275
+ */
276
+ export function renderedQuotas(state: {
277
+ main?: { quota?: AccountQuota | null } | null
278
+ fallbacks?: ReadonlyArray<{ enabled?: boolean; quota?: AccountQuota | null }>
279
+ }): ReadonlyArray<AccountQuota | null | undefined> {
280
+ return [
281
+ state.main?.quota ?? null,
282
+ ...(state.fallbacks ?? [])
283
+ .filter((fallback) => fallback.enabled)
284
+ .map((fallback) => fallback.quota),
285
+ ]
286
+ }
287
+
288
+ export function computeQuotaLabelWidth(
289
+ quotas: ReadonlyArray<AccountQuota | null | undefined>,
290
+ ): number {
291
+ let width = 3
292
+ for (const quota of quotas) {
293
+ for (const label of quotaRowLabels(quota ?? null)) {
294
+ width = Math.max(width, label.length + 1)
295
+ }
296
+ }
297
+ return width
298
+ }
299
+
249
300
  // Maps present quota windows onto display rows and computes pacing against each
250
301
  // dynamic duration or the historical 5h/7d duration for legacy snapshots.
302
+ // `labelWidth` comes from computeQuotaLabelWidth for rendered sidebars; callers
303
+ // that only inspect pacing may omit it and get this row set's own width.
251
304
  export function buildQuotaRowsForDisplay(
252
305
  quota: AccountQuota | null,
253
306
  now: number,
254
307
  pacingEnabled: boolean,
308
+ labelWidth?: number,
255
309
  ): QuotaDisplayRow[] {
256
- return getPresentQuotaWindows(quota).map((row) => ({
257
- key: row.key,
258
- label: row.label,
259
- window: row.window,
260
- pacing:
261
- pacingEnabled && row.windowMs !== null
262
- ? computeQuotaPacing(row.window, row.windowMs, now)
263
- : null,
264
- }))
310
+ const rows: Array<Omit<QuotaDisplayRow, 'labelWidth'>> =
311
+ getPresentQuotaWindows(quota).map((row) => ({
312
+ key: row.key,
313
+ label: row.label,
314
+ window: row.window,
315
+ pacing:
316
+ pacingEnabled && row.windowMs !== null
317
+ ? computeQuotaPacing(row.window, row.windowMs, now)
318
+ : null,
319
+ }))
320
+ const spendControl = quota?.spendControl
321
+ if (spendControl) {
322
+ rows.push({
323
+ key: 'spendControl',
324
+ label: SPEND_CONTROL_LABEL,
325
+ window: {
326
+ usedPercent: spendControl.usedPercent,
327
+ remainingPercent: spendControl.remainingPercent,
328
+ resetsAt: spendControl.resetsAt,
329
+ },
330
+ pacing: null,
331
+ })
332
+ }
333
+ const width =
334
+ labelWidth ?? Math.max(3, ...rows.map((row) => row.label.length + 1))
335
+ return rows.map((row) => ({ ...row, labelWidth: width }))
265
336
  }
266
337
 
267
338
  export function isQuotaLoaded(quota: AccountQuota | null): boolean {
@@ -279,12 +350,39 @@ export function getQuotaMetadataRows(
279
350
  return rows
280
351
  }
281
352
 
353
+ function formatGroupedAmount(value: number): string {
354
+ return Math.round(value).toLocaleString('en-US')
355
+ }
356
+
357
+ // The unit names the budget the amounts are counted in, so it agrees with the
358
+ // total rather than staying singular against a plural quantity.
359
+ function pluralizeUnit(unit: string, count: number): string {
360
+ if (count === 1) return unit
361
+ return unit.endsWith('s') ? unit : `${unit}s`
362
+ }
363
+
364
+ export function formatSpendControlAmounts(
365
+ spendControl: SpendControlReading,
366
+ ): string {
367
+ const unit = pluralizeUnit(spendControl.unit ?? 'unit', spendControl.limit)
368
+ return `${formatGroupedAmount(spendControl.used)} / ${formatGroupedAmount(spendControl.limit)} ${unit}`
369
+ }
370
+
282
371
  export function getAccountMetadataRows(
283
372
  resetCredits: number | undefined,
373
+ spendControl?: SpendControlReading,
284
374
  ): Array<{ label: string; value: string }> {
285
- return resetCredits === undefined
286
- ? []
287
- : [{ label: 'resets', value: String(resetCredits) }]
375
+ const rows: Array<{ label: string; value: string }> = []
376
+ if (spendControl) {
377
+ rows.push({
378
+ label: 'credits',
379
+ value: formatSpendControlAmounts(spendControl),
380
+ })
381
+ }
382
+ if (resetCredits !== undefined) {
383
+ rows.push({ label: 'resets', value: String(resetCredits) })
384
+ }
385
+ return rows
288
386
  }
289
387
 
290
388
  // Quota window row: muted label left, tone-colored bar + percentage right,
@@ -295,6 +393,7 @@ function QuotaRow(props: {
295
393
  theme: ThemeCurrent
296
394
  appearance: AppearancePrefs
297
395
  label: string
396
+ labelWidth: number
298
397
  window: { usedPercent: number; resetsAt?: string } | undefined
299
398
  pacing: QuotaPacing | null
300
399
  }) {
@@ -314,7 +413,9 @@ function QuotaRow(props: {
314
413
  when={props.window}
315
414
  fallback={
316
415
  <box width='100%' flexDirection='row'>
317
- <text fg={props.theme.textMuted}>{props.label.padEnd(3)}</text>
416
+ <text fg={props.theme.textMuted}>
417
+ {props.label.padEnd(props.labelWidth)}
418
+ </text>
318
419
  <text fg={props.theme.textMuted}>{'\u2014'}</text>
319
420
  </box>
320
421
  }
@@ -324,7 +425,9 @@ function QuotaRow(props: {
324
425
  the right edge so reset times align in their own right column. */}
325
426
  <box width='100%' flexDirection='row' justifyContent='space-between'>
326
427
  <box flexDirection='row'>
327
- <text fg={props.theme.textMuted}>{props.label.padEnd(3)}</text>
428
+ <text fg={props.theme.textMuted}>
429
+ {props.label.padEnd(props.labelWidth)}
430
+ </text>
328
431
  <For each={quotaBarSegments(used(), props.appearance, props.pacing)}>
329
432
  {(segment) => (
330
433
  <text fg={toneColor(props.theme, segment.tone)}>
@@ -344,7 +447,7 @@ function QuotaRow(props: {
344
447
  </box>
345
448
  <Show when={paceLine()}>
346
449
  <box width='100%' flexDirection='row'>
347
- <text fg={props.theme.textMuted}>{' '}</text>
450
+ <text fg={props.theme.textMuted}>{''.padEnd(props.labelWidth)}</text>
348
451
  <text
349
452
  fg={toneColor(
350
453
  props.theme,
@@ -370,6 +473,7 @@ function AccountBlock(props: {
370
473
  killed: boolean
371
474
  active: boolean
372
475
  pacingEnabled: boolean
476
+ labelWidth: number
373
477
  resetCredits?: number
374
478
  marginTop?: number
375
479
  }) {
@@ -378,7 +482,12 @@ function AccountBlock(props: {
378
482
  const statusTone = (): Tone =>
379
483
  props.killed ? 'err' : props.active ? 'ok' : 'muted'
380
484
  const rows = () =>
381
- buildQuotaRowsForDisplay(props.quota, Date.now(), props.pacingEnabled)
485
+ buildQuotaRowsForDisplay(
486
+ props.quota,
487
+ Date.now(),
488
+ props.pacingEnabled,
489
+ props.labelWidth,
490
+ )
382
491
  return (
383
492
  <box width='100%' flexDirection='column' marginTop={props.marginTop ?? 0}>
384
493
  <box width='100%' flexDirection='row' justifyContent='space-between'>
@@ -403,6 +512,7 @@ function AccountBlock(props: {
403
512
  theme={props.theme}
404
513
  appearance={props.appearance}
405
514
  label={row.label}
515
+ labelWidth={row.labelWidth}
406
516
  window={row.window}
407
517
  pacing={row.pacing}
408
518
  />
@@ -410,7 +520,12 @@ function AccountBlock(props: {
410
520
  </For>
411
521
  </Show>
412
522
  </Show>
413
- <For each={getAccountMetadataRows(props.resetCredits)}>
523
+ <For
524
+ each={getAccountMetadataRows(
525
+ props.resetCredits,
526
+ props.quota?.spendControl,
527
+ )}
528
+ >
414
529
  {(row) => (
415
530
  <StatRow
416
531
  theme={props.theme}
@@ -452,6 +567,7 @@ function QuotaDialogContent(props: {
452
567
  const enabledFallbacks = () =>
453
568
  (state().fallbacks ?? []).filter((f) => f.enabled)
454
569
  const activeId = () => resolveQuotaDialogActiveId(state(), props.sessionId)
570
+ const quotaLabelWidth = () => computeQuotaLabelWidth(renderedQuotas(state()))
455
571
  return (
456
572
  <box flexDirection='column' padding={2} width='100%' alignItems='center'>
457
573
  <box flexDirection='column' width={58}>
@@ -468,6 +584,7 @@ function QuotaDialogContent(props: {
468
584
  killed={state().main?.killed ?? false}
469
585
  active={activeId() === 'main'}
470
586
  pacingEnabled={prefs().sections.pacing}
587
+ labelWidth={quotaLabelWidth()}
471
588
  resetCredits={state().main?.resetCredits}
472
589
  />
473
590
  <Show when={prefs().sections.fallbackAccounts}>
@@ -481,6 +598,7 @@ function QuotaDialogContent(props: {
481
598
  killed={fb.killed}
482
599
  active={activeId() === fb.id}
483
600
  pacingEnabled={prefs().sections.pacing}
601
+ labelWidth={quotaLabelWidth()}
484
602
  resetCredits={fb.resetCredits}
485
603
  marginTop={1}
486
604
  />
@@ -661,6 +779,7 @@ function QuotaSidebar(props: {
661
779
  route: sessionRouting().route,
662
780
  })
663
781
  const activeAccount = () => resolveActiveAccount(sessionState())
782
+ const quotaLabelWidth = () => computeQuotaLabelWidth(renderedQuotas(state()))
664
783
  const activeQuotaSummary = () =>
665
784
  getCollapsedQuotaSummary(activeAccount().quota)
666
785
  const activePacingDeficit = () => {
@@ -793,6 +912,7 @@ function QuotaSidebar(props: {
793
912
  killed={state().main?.killed ?? false}
794
913
  active={sessionRouting().activeId === 'main'}
795
914
  pacingEnabled={prefs().sections.pacing}
915
+ labelWidth={quotaLabelWidth()}
796
916
  resetCredits={state().main?.resetCredits}
797
917
  />
798
918
  <Show when={prefs().sections.fallbackAccounts}>
@@ -806,6 +926,7 @@ function QuotaSidebar(props: {
806
926
  killed={fb.killed}
807
927
  active={sessionRouting().activeId === fb.id}
808
928
  pacingEnabled={prefs().sections.pacing}
929
+ labelWidth={quotaLabelWidth()}
809
930
  resetCredits={fb.resetCredits}
810
931
  marginTop={1}
811
932
  />
@@ -1,23 +1,28 @@
1
1
  import { realpathSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { basename, dirname, join, resolve } from 'node:path'
4
+ import {
5
+ ACCOUNT_FILE_NAME,
6
+ ACCOUNT_STATE_FILE_NAME,
7
+ type AccountPaths,
8
+ deriveStatePath,
9
+ } from '../shared/internal.ts'
4
10
 
5
11
  /**
6
- * Where the account store lives.
12
+ * Where OpenCode's account store lives.
7
13
  *
8
- * This is the ONLY definition. It used to be duplicated verbatim in both
9
- * `accounts.ts` and `refresh-file-lock.ts`, because `accounts.ts` imports the
10
- * lock and the reverse import would have been a cycle. Two copies of a path
11
- * resolver is a bad trade for that: the lock and the write must agree on the
12
- * exact file, and if the copies ever drifted, a writer would take a lock on one
13
- * path while writing another — mutual exclusion silently lost on a file holding
14
- * credentials. Nothing would fail a test, since each copy works on its own.
14
+ * This is the ONLY definition of this host's resolution rules. The store code
15
+ * itself takes both paths as arguments and resolves nothing, so the lock and
16
+ * the write it guards can never disagree about which file they mean — a
17
+ * disagreement would lose mutual exclusion on a file holding credentials while
18
+ * every test still passed, since each side works on its own.
15
19
  *
16
- * This module imports nothing from either side, so both can depend on it.
20
+ * The file names come from the core so the two hosts cannot drift apart on what
21
+ * the store is called.
17
22
  */
18
23
 
19
- export const ACCOUNT_FILE_NAME = 'openai-auth.json'
20
- export const ACCOUNT_STATE_FILE_NAME = 'openai-auth-state.json'
24
+ export type { AccountPaths }
25
+ export { ACCOUNT_FILE_NAME, ACCOUNT_STATE_FILE_NAME, deriveStatePath }
21
26
 
22
27
  function getConfigDir() {
23
28
  if (process.env.OPENCODE_CONFIG_DIR?.trim()) {
@@ -36,13 +41,6 @@ export function getAccountStoragePath() {
36
41
  )
37
42
  }
38
43
 
39
- /** Derive the state-file path from the config path without reading env vars. */
40
- export function deriveStatePath(configPath: string): string {
41
- return basename(configPath) === ACCOUNT_FILE_NAME
42
- ? join(dirname(configPath), ACCOUNT_STATE_FILE_NAME)
43
- : `${configPath}.state.json`
44
- }
45
-
46
44
  function normalizePathForComparison(path: string, platform: NodeJS.Platform) {
47
45
  const resolved = resolve(path)
48
46
  return platform === 'win32' || platform === 'darwin'
@@ -101,3 +99,16 @@ export function getAccountStatePath(configPath = getAccountStoragePath()) {
101
99
  }
102
100
  return deriveStatePath(configPath)
103
101
  }
102
+
103
+ /**
104
+ * Both file paths a store call needs, resolved the way this host resolves them.
105
+ *
106
+ * Every call site that used to pass a single config path and let the store
107
+ * derive the state path now passes this pair instead, so the state-file
108
+ * override keeps being honoured in exactly the places it was before.
109
+ */
110
+ export function getAccountPaths(
111
+ configPath = getAccountStoragePath(),
112
+ ): AccountPaths {
113
+ return { configPath, statePath: getAccountStatePath(configPath) }
114
+ }
@@ -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
+ }