@cortexkit/opencode-openai-auth 0.5.2 → 0.6.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 (50) hide show
  1. package/README.md +2 -1
  2. package/dist/cli.js +1 -1
  3. package/dist/commands.d.ts +16 -0
  4. package/dist/core/accounts.d.ts +22 -0
  5. package/dist/core/oauth.d.ts +1 -1
  6. package/dist/core/refresh-all-quota.d.ts +3 -0
  7. package/dist/core/reset-credits.d.ts +114 -0
  8. package/dist/index-87145jkw.js +2 -0
  9. package/dist/{index-rfgyxxxq.js → index-dz9a4xwy.js} +1 -1
  10. package/dist/{index-zkzb0twr.js → index-e08r33g5.js} +2 -2
  11. package/dist/{index-s5fbqqwx.js → index-hxpv25e8.js} +1 -1
  12. package/dist/index-meg0ng0z.js +2 -0
  13. package/dist/{index-vrdc90yd.js → index-n00ybbbq.js} +1 -1
  14. package/dist/{index-6rnw2cgs.js → index-nwy7ec7p.js} +1 -1
  15. package/dist/{index-wpd7mg3p.js → index-s2vhsb2v.js} +1 -1
  16. package/dist/index-zddpw2sa.js +89 -0
  17. package/dist/index.d.ts +30 -1
  18. package/dist/index.js +120 -70
  19. package/dist/quota-normalize-vf2pj6n7.js +1 -0
  20. package/dist/quota-normalize.d.ts +1 -0
  21. package/dist/rpc/port-file.js +1 -1
  22. package/dist/rpc/protocol.d.ts +1 -1
  23. package/dist/rpc/rpc-client.d.ts +2 -1
  24. package/dist/rpc/rpc-client.js +1 -1
  25. package/dist/rpc/rpc-dir.js +1 -1
  26. package/dist/rpc/rpc-server.d.ts +1 -0
  27. package/dist/sidebar-state.d.ts +2 -0
  28. package/dist/sidebar-state.js +1 -1
  29. package/dist/{token-5hdnazxp.js → token-esc14v6e.js} +1 -1
  30. package/dist/token-util-qkpkvy5t.js +1 -0
  31. package/dist/tui-preferences.js +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/package.json +3 -3
  34. package/src/rpc/protocol.ts +1 -0
  35. package/src/rpc/rpc-client.ts +7 -3
  36. package/src/rpc/rpc-server.ts +24 -4
  37. package/src/sidebar-state.ts +62 -3
  38. package/src/tui/command-dialogs.tsx +245 -0
  39. package/src/tui-compiled/rpc/protocol.ts +1 -0
  40. package/src/tui-compiled/rpc/rpc-client.ts +7 -3
  41. package/src/tui-compiled/sidebar-state.ts +62 -3
  42. package/src/tui-compiled/tui/command-dialogs.tsx +162 -1
  43. package/src/tui-compiled/tui.tsx +1 -1
  44. package/src/tui-compiled/util/error.ts +9 -0
  45. package/src/tui.tsx +4 -1
  46. package/dist/index-2ng03cby.js +0 -2
  47. package/dist/index-qhvsskdz.js +0 -89
  48. package/dist/index-wnxfzjfr.js +0 -2
  49. package/dist/quota-normalize-n02dfdx0.js +0 -1
  50. package/dist/token-util-3d2nj45d.js +0 -1
@@ -1,5 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import type { TuiPluginApi } from '@opencode-ai/plugin/tui'
3
+ import { createLogger } from '../logger'
3
4
  import type { OpenDialogPayload } from '../rpc/protocol.js'
4
5
  import {
5
6
  type AccountQuota,
@@ -8,8 +9,11 @@ import {
8
9
  resolveSessionSidebarRouting,
9
10
  type SidebarState,
10
11
  } from '../sidebar-state.js'
12
+ import { errorMessage } from '../util/error'
11
13
  import { openUrl } from '../util/open-url'
12
14
 
15
+ const log = createLogger('rpc-tui')
16
+
13
17
  type ApplyFn = (
14
18
  command: OpenDialogPayload['command'],
15
19
  args: string,
@@ -104,6 +108,242 @@ export function buildCachekeepDialogOptions(payload: OpenDialogPayload) {
104
108
  ]
105
109
  }
106
110
 
111
+ type ResetAccountKnob = {
112
+ accountKey: string
113
+ label: string
114
+ usedPercent?: number
115
+ availableCount?: number
116
+ applicableAvailableCount?: number
117
+ eligible: boolean
118
+ reason?: string
119
+ selectedCreditId?: string
120
+ selectedCreditExpiresAt?: string
121
+ }
122
+
123
+ type ResetPreviewKnob = ResetAccountKnob & {
124
+ chatgptAccountId?: string
125
+ resetTime?: string
126
+ }
127
+
128
+ type ResetDialogOption = {
129
+ title: string
130
+ value: string
131
+ description: string
132
+ disabled?: boolean
133
+ }
134
+
135
+ function resetAccountOptions(payload: OpenDialogPayload): ResetDialogOption[] {
136
+ const accounts = Array.isArray(payload.knobs.accounts)
137
+ ? (payload.knobs.accounts as ResetAccountKnob[])
138
+ : []
139
+ return [
140
+ ...accounts.map((account) => {
141
+ const used =
142
+ account.usedPercent === undefined
143
+ ? 'quota unavailable'
144
+ : `${account.usedPercent}%`
145
+ const counts = `${account.applicableAvailableCount ?? 0}/${account.availableCount ?? 0}`
146
+ const status = account.eligible
147
+ ? 'eligible'
148
+ : (account.reason ?? 'unavailable')
149
+ const details = `${used} · ${counts}`
150
+ const shortDate =
151
+ account.selectedCreditExpiresAt?.slice(0, 10) ?? 'unknown'
152
+ return {
153
+ title: `${account.label} — ${status}`,
154
+ value: `account:${account.accountKey}`,
155
+ description: account.eligible
156
+ ? `${details} · exp ${shortDate}`
157
+ : details,
158
+ }
159
+ }),
160
+ {
161
+ title: 'Refresh',
162
+ value: 'refresh',
163
+ description: 'Re-check account quota and reset credits',
164
+ },
165
+ {
166
+ title: 'Close',
167
+ value: 'close',
168
+ description: 'Close this dialog',
169
+ },
170
+ ]
171
+ }
172
+
173
+ function resetResultOptions(payload: OpenDialogPayload): ResetDialogOption[] {
174
+ const accountKey =
175
+ typeof payload.knobs.accountKey === 'string'
176
+ ? payload.knobs.accountKey
177
+ : undefined
178
+ const chatgptAccountId =
179
+ typeof payload.knobs.chatgptAccountId === 'string'
180
+ ? payload.knobs.chatgptAccountId
181
+ : undefined
182
+ const code = typeof payload.knobs.code === 'string' ? payload.knobs.code : ''
183
+ const retrySafe =
184
+ (code === 'ambiguous' ||
185
+ code === 'http_error' ||
186
+ code === 'expired_unreconciled' ||
187
+ payload.knobs.stateWriteFailed === true) &&
188
+ typeof payload.knobs.retryGuidance === 'string' &&
189
+ accountKey !== undefined &&
190
+ chatgptAccountId !== undefined
191
+ return [
192
+ {
193
+ title: payload.text,
194
+ value: 'result',
195
+ description: 'Command result',
196
+ },
197
+ ...(retrySafe
198
+ ? [
199
+ {
200
+ title: 'Retry',
201
+ value: 'retry',
202
+ description:
203
+ 'Reuse the bound request and credit identifiers for this uncertain outcome',
204
+ },
205
+ ]
206
+ : []),
207
+ {
208
+ title: 'Refresh',
209
+ value: 'refresh',
210
+ description: 'Re-check account quota and reset credits',
211
+ },
212
+ {
213
+ title: 'Close',
214
+ value: 'close',
215
+ description: 'Close this dialog',
216
+ },
217
+ ]
218
+ }
219
+
220
+ function openResetDialog(
221
+ api: TuiPluginApi,
222
+ payload: OpenDialogPayload,
223
+ apply: ApplyFn,
224
+ ) {
225
+ let applyGeneration = 0
226
+ const render = (state: OpenDialogPayload) => {
227
+ const applyAndRender = (args: string) => {
228
+ const generation = ++applyGeneration
229
+ log.debug('reset dialog apply', { args, generation })
230
+ void apply('openai-reset', args)
231
+ .then((result) => {
232
+ log.debug('reset dialog apply result', {
233
+ generation,
234
+ currentGeneration: applyGeneration,
235
+ stage: result.knobs?.stage,
236
+ accountRows: Array.isArray(result.knobs?.accounts)
237
+ ? (result.knobs.accounts as unknown[]).length
238
+ : undefined,
239
+ })
240
+ if (generation !== applyGeneration) return
241
+ if (typeof result.knobs?.stage !== 'string') {
242
+ render({
243
+ command: 'openai-reset',
244
+ text: 'The reset request is still completing or returned no result. Choose Refresh to check the current state — do NOT re-confirm.',
245
+ knobs: { stage: 'result', code: 'unknown' },
246
+ })
247
+ return
248
+ }
249
+ render({
250
+ command: 'openai-reset',
251
+ text: result.text,
252
+ knobs: result.knobs,
253
+ })
254
+ })
255
+ .catch((error) => {
256
+ if (generation !== applyGeneration) return
257
+ log.warn('reset dialog apply rejected', {
258
+ error: errorMessage(error),
259
+ })
260
+ render({
261
+ command: 'openai-reset',
262
+ text: 'Command failed — the plugin could not complete the request; the plugin log has details.',
263
+ knobs: { stage: 'result', code: 'error' },
264
+ })
265
+ })
266
+ }
267
+ const stage = state.knobs.stage
268
+ log.debug('reset dialog render', {
269
+ stage,
270
+ accountRows: Array.isArray(state.knobs.accounts)
271
+ ? (state.knobs.accounts as unknown[]).length
272
+ : undefined,
273
+ knobKeys: Object.keys(state.knobs),
274
+ })
275
+ api.ui.dialog.setSize('xlarge')
276
+
277
+ if (stage === 'confirm') {
278
+ const preview = state.knobs.preview as ResetPreviewKnob | undefined
279
+ const accountKey = preview?.accountKey
280
+ const chatgptAccountId = preview?.chatgptAccountId
281
+ const applicableCount = preview?.applicableAvailableCount ?? 0
282
+ const DialogConfirm = api.ui.DialogConfirm
283
+ api.ui.dialog.replace(() => (
284
+ <DialogConfirm
285
+ title='Reset quota window'
286
+ message={`${state.text}\n\nThis SPENDS 1 of ${applicableCount} reset credits — irreversible.\n\nChoose Reset to continue or Cancel to return.\n\nEnter = Cancel (host default). Press Tab then Enter to Reset.`}
287
+ onConfirm={() => {
288
+ if (!accountKey || !chatgptAccountId) return
289
+ applyAndRender(
290
+ `confirm ${encodeURIComponent(accountKey)} ${encodeURIComponent(chatgptAccountId)}`,
291
+ )
292
+ }}
293
+ onCancel={() => applyAndRender('refresh')}
294
+ />
295
+ ))
296
+ return
297
+ }
298
+
299
+ const DialogSelect = api.ui.DialogSelect<string>
300
+ const options =
301
+ stage === 'result'
302
+ ? resetResultOptions(state)
303
+ : resetAccountOptions(state)
304
+ api.ui.dialog.replace(() => (
305
+ <DialogSelect
306
+ title={
307
+ stage === 'result' ? 'OpenAI reset result' : 'OpenAI reset credits'
308
+ }
309
+ options={options}
310
+ onSelect={(option) => {
311
+ if (option.disabled) return
312
+ if (option.value === 'result') return
313
+ if (option.value === 'close') {
314
+ applyGeneration += 1
315
+ api.ui.dialog.clear()
316
+ return
317
+ }
318
+ if (option.value === 'refresh') {
319
+ applyAndRender('refresh')
320
+ return
321
+ }
322
+ if (option.value === 'retry') {
323
+ const accountKey = state.knobs.accountKey
324
+ const chatgptAccountId = state.knobs.chatgptAccountId
325
+ if (
326
+ typeof accountKey !== 'string' ||
327
+ typeof chatgptAccountId !== 'string'
328
+ ) {
329
+ return
330
+ }
331
+ applyAndRender(
332
+ `retry ${encodeURIComponent(accountKey)} ${encodeURIComponent(chatgptAccountId)}`,
333
+ )
334
+ return
335
+ }
336
+ if (!option.value.startsWith('account:')) return
337
+ const accountKey = option.value.slice('account:'.length)
338
+ applyAndRender(`select ${encodeURIComponent(accountKey)}`)
339
+ }}
340
+ />
341
+ ))
342
+ }
343
+
344
+ render(payload)
345
+ }
346
+
107
347
  function showText(api: TuiPluginApi, text: string) {
108
348
  api.ui.dialog.setSize('xlarge')
109
349
  api.ui.dialog.replace(() => (
@@ -157,6 +397,11 @@ export function openCommandDialog(
157
397
  apply: ApplyFn,
158
398
  sessionId?: string,
159
399
  ) {
400
+ if (payload.command === 'openai-reset') {
401
+ openResetDialog(api, payload, apply)
402
+ return
403
+ }
404
+
160
405
  if (payload.command === 'openai-routing') {
161
406
  const current = (payload.knobs.mode as string) ?? 'main-first'
162
407
  const DialogSelect = api.ui.DialogSelect<string>
@@ -6,6 +6,7 @@ export type CommandModalName =
6
6
  | 'openai-dump'
7
7
  | 'openai-logging'
8
8
  | 'openai-cachekeep'
9
+ | 'openai-reset'
9
10
 
10
11
  export interface OpenDialogPayload {
11
12
  command: CommandModalName
@@ -6,21 +6,24 @@ export interface RpcClient {
6
6
  lastReceivedId: number,
7
7
  sessionId?: string,
8
8
  ) => Promise<RpcNotification[]>
9
- apply: (request: ApplyRequest) => Promise<ApplyResult>
9
+ apply: (request: ApplyRequest, timeoutMs?: number) => Promise<ApplyResult>
10
10
  }
11
11
 
12
+ export const DEFAULT_RPC_TIMEOUT_MS = 2_000
13
+
12
14
  async function call<T>(
13
15
  dir: string,
14
16
  expectedPid: number | undefined,
15
17
  onSelected: ((entry: PortFileEntry | null) => void) | undefined,
16
18
  method: string,
17
19
  params: Record<string, unknown>,
20
+ timeoutMs = DEFAULT_RPC_TIMEOUT_MS,
18
21
  ): Promise<T | null> {
19
22
  const entry = await discoverPortFile(dir, expectedPid)
20
23
  onSelected?.(entry)
21
24
  if (!entry) return null
22
25
  const controller = new AbortController()
23
- const timer = setTimeout(() => controller.abort(), 2_000)
26
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
24
27
  try {
25
28
  const res = await fetch(`http://127.0.0.1:${entry.port}/rpc/${method}`, {
26
29
  method: 'POST',
@@ -62,7 +65,7 @@ export function createRpcClient(
62
65
  )
63
66
  return out?.messages ?? []
64
67
  },
65
- async apply(request) {
68
+ async apply(request, timeoutMs) {
66
69
  const out = await call<ApplyResult>(
67
70
  dir,
68
71
  expectedPid,
@@ -71,6 +74,7 @@ export function createRpcClient(
71
74
  {
72
75
  ...request,
73
76
  },
77
+ timeoutMs,
74
78
  )
75
79
  return out ?? { text: 'apply failed', knobs: {} }
76
80
  },
@@ -100,6 +100,7 @@ export type ActiveRoutingMap = Record<string, ActiveRoutingEntry>
100
100
 
101
101
  export interface StickyAssignment {
102
102
  accountId: string
103
+ wireAccountId?: string
103
104
  assignedAt: number
104
105
  lastSeenAt: number
105
106
  inputBytes: number
@@ -120,6 +121,7 @@ export interface ResolveStickyAssignmentInput {
120
121
  validPinnedAccountIds: readonly string[]
121
122
  excludeAccountIds?: readonly string[]
122
123
  quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>
124
+ wireAccountIdByAccount?: Readonly<Record<string, string | undefined>>
123
125
  choose: (
124
126
  pendingBytes: ReadonlyMap<string, number>,
125
127
  ) => StickyAssignmentChoice | undefined
@@ -256,12 +258,14 @@ function normalizeStickyAssignments(
256
258
  ) {
257
259
  continue
258
260
  }
261
+ const wireAccountId = assignment.wireAccountId
259
262
  normalized[sessionHash] = {
260
263
  accountId: assignment.accountId,
261
264
  assignedAt: assignment.assignedAt,
262
265
  lastSeenAt: assignment.lastSeenAt,
263
266
  inputBytes: assignment.inputBytes,
264
267
  ...(quotaCheckedAt === undefined ? {} : { quotaCheckedAt }),
268
+ ...(typeof wireAccountId === 'string' ? { wireAccountId } : {}),
265
269
  }
266
270
  }
267
271
 
@@ -652,10 +656,32 @@ function stickyAssignmentNeedsMetadataUpdate(
652
656
  assignment: StickyAssignment,
653
657
  requestBytes: number,
654
658
  now: number,
659
+ wireAccountId: string | undefined,
655
660
  ): boolean {
656
661
  return (
657
662
  requestBytes > assignment.inputBytes ||
658
- now - assignment.lastSeenAt >= STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS
663
+ now - assignment.lastSeenAt >= STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS ||
664
+ (assignment.wireAccountId === undefined && wireAccountId !== undefined)
665
+ )
666
+ }
667
+
668
+ function hasStickyIdentityMismatch(
669
+ assignment: StickyAssignment,
670
+ wireAccountId: string | undefined,
671
+ ): boolean {
672
+ // True only when both sides know their ChatGPT account and those accounts
673
+ // differ, which means the slot this session was placed on now holds a
674
+ // different account than it did at placement.
675
+ //
676
+ // An unknown on either side deliberately reports no mismatch. A session is
677
+ // pinned to keep its prompt cache warm, and treating missing data as a
678
+ // change would place sessions again for no reason and throw those caches
679
+ // away — worse than leaving a rare wrong pin in place until real evidence
680
+ // arrives.
681
+ return (
682
+ typeof assignment.wireAccountId === 'string' &&
683
+ typeof wireAccountId === 'string' &&
684
+ assignment.wireAccountId !== wireAccountId
659
685
  )
660
686
  }
661
687
 
@@ -692,9 +718,11 @@ function readonlyPendingBytes(
692
718
  function pendingBytesForAssignments(
693
719
  assignments: StickyAssignmentMap | undefined,
694
720
  quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>,
721
+ excludedSessionHash?: string,
695
722
  ): ReadonlyMap<string, number> {
696
723
  const pendingBytes = new Map<string, number>()
697
- for (const assignment of Object.values(assignments ?? {})) {
724
+ for (const [sessionHash, assignment] of Object.entries(assignments ?? {})) {
725
+ if (sessionHash === excludedSessionHash) continue
698
726
  if (
699
727
  assignment.quotaCheckedAt !==
700
728
  quotaCheckedAtByAccount[assignment.accountId]
@@ -1178,10 +1206,15 @@ export async function resolveSidebarStickyAssignment(
1178
1206
  excludedAccountIds,
1179
1207
  input.now,
1180
1208
  ) &&
1209
+ !hasStickyIdentityMismatch(
1210
+ existing,
1211
+ input.wireAccountIdByAccount?.[existing.accountId],
1212
+ ) &&
1181
1213
  !stickyAssignmentNeedsMetadataUpdate(
1182
1214
  existing,
1183
1215
  input.requestBytes,
1184
1216
  input.now,
1217
+ input.wireAccountIdByAccount?.[existing.accountId],
1185
1218
  )
1186
1219
  ) {
1187
1220
  return existing
@@ -1202,23 +1235,38 @@ export async function resolveSidebarStickyAssignment(
1202
1235
  stickyAssignments,
1203
1236
  )
1204
1237
  const current = stickyAssignments?.[sessionHash]
1238
+ const currentIdentityMismatch =
1239
+ current !== undefined &&
1240
+ hasStickyIdentityMismatch(
1241
+ current,
1242
+ input.wireAccountIdByAccount?.[current.accountId],
1243
+ )
1205
1244
  if (
1206
1245
  isValidStickyAssignment(
1207
1246
  current,
1208
1247
  validPinnedAccountIds,
1209
1248
  excludedAccountIds,
1210
1249
  input.now,
1211
- )
1250
+ ) &&
1251
+ !currentIdentityMismatch
1212
1252
  ) {
1213
1253
  const metadataNeedsUpdate = stickyAssignmentNeedsMetadataUpdate(
1214
1254
  current,
1215
1255
  input.requestBytes,
1216
1256
  input.now,
1257
+ input.wireAccountIdByAccount?.[current.accountId],
1217
1258
  )
1218
1259
  const assignment = metadataNeedsUpdate
1219
1260
  ? {
1220
1261
  ...current,
1221
1262
  inputBytes: Math.max(current.inputBytes, input.requestBytes),
1263
+ ...(current.wireAccountId === undefined &&
1264
+ input.wireAccountIdByAccount?.[current.accountId] !== undefined
1265
+ ? {
1266
+ wireAccountId:
1267
+ input.wireAccountIdByAccount?.[current.accountId],
1268
+ }
1269
+ : {}),
1222
1270
  ...(input.now - current.lastSeenAt >=
1223
1271
  STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS
1224
1272
  ? { lastSeenAt: input.now }
@@ -1241,6 +1289,12 @@ export async function resolveSidebarStickyAssignment(
1241
1289
  pendingBytesForAssignments(
1242
1290
  stickyAssignments,
1243
1291
  input.quotaCheckedAtByAccount,
1292
+ // Pending bytes weigh how much traffic each account is already
1293
+ // committed to, so the session being placed must not weigh its own
1294
+ // stale entry: that entry belongs to the account it is moving off,
1295
+ // and counting it would bias placement away from a perfectly good
1296
+ // destination. Other sessions' entries still count.
1297
+ currentIdentityMismatch ? sessionHash : undefined,
1244
1298
  ),
1245
1299
  )
1246
1300
  if (!choice) {
@@ -1261,6 +1315,11 @@ export async function resolveSidebarStickyAssignment(
1261
1315
  ...(choice.quotaCheckedAt === undefined
1262
1316
  ? {}
1263
1317
  : { quotaCheckedAt: choice.quotaCheckedAt }),
1318
+ ...(input.wireAccountIdByAccount?.[choice.accountId] === undefined
1319
+ ? {}
1320
+ : {
1321
+ wireAccountId: input.wireAccountIdByAccount?.[choice.accountId],
1322
+ }),
1264
1323
  }
1265
1324
  return {
1266
1325
  ...latest,
@@ -1,14 +1,17 @@
1
1
  import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid";
2
2
  import { memo as _$memo } from "opentui:runtime-module:%40opentui%2Fsolid";
3
- import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid";
4
3
  import { insertNode as _$insertNode } from "opentui:runtime-module:%40opentui%2Fsolid";
5
4
  import { insert as _$insert } from "opentui:runtime-module:%40opentui%2Fsolid";
6
5
  import { setProp as _$setProp } from "opentui:runtime-module:%40opentui%2Fsolid";
7
6
  import { createElement as _$createElement } from "opentui:runtime-module:%40opentui%2Fsolid";
7
+ import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid";
8
8
  /** @jsxImportSource @opentui/solid */
9
9
 
10
+ import { createLogger } from "../logger";
10
11
  import { getCollapsedQuotaSummary, getSidebarState, resolveSessionSidebarRouting } from "../sidebar-state.js";
12
+ import { errorMessage } from "../util/error";
11
13
  import { openUrl } from "../util/open-url";
14
+ const log = createLogger('rpc-tui');
12
15
  export function buildCachekeepDialogOptions(payload) {
13
16
  const enabled = payload.knobs.enabled === true;
14
17
  const subagents = payload.knobs.subagents === true;
@@ -59,6 +62,160 @@ export function buildCachekeepDialogOptions(payload) {
59
62
  description: 'Close this dialog'
60
63
  }];
61
64
  }
65
+ function resetAccountOptions(payload) {
66
+ const accounts = Array.isArray(payload.knobs.accounts) ? payload.knobs.accounts : [];
67
+ return [...accounts.map(account => {
68
+ const used = account.usedPercent === undefined ? 'quota unavailable' : `${account.usedPercent}%`;
69
+ const counts = `${account.applicableAvailableCount ?? 0}/${account.availableCount ?? 0}`;
70
+ const status = account.eligible ? 'eligible' : account.reason ?? 'unavailable';
71
+ const details = `${used} · ${counts}`;
72
+ const shortDate = account.selectedCreditExpiresAt?.slice(0, 10) ?? 'unknown';
73
+ return {
74
+ title: `${account.label} — ${status}`,
75
+ value: `account:${account.accountKey}`,
76
+ description: account.eligible ? `${details} · exp ${shortDate}` : details
77
+ };
78
+ }), {
79
+ title: 'Refresh',
80
+ value: 'refresh',
81
+ description: 'Re-check account quota and reset credits'
82
+ }, {
83
+ title: 'Close',
84
+ value: 'close',
85
+ description: 'Close this dialog'
86
+ }];
87
+ }
88
+ function resetResultOptions(payload) {
89
+ const accountKey = typeof payload.knobs.accountKey === 'string' ? payload.knobs.accountKey : undefined;
90
+ const chatgptAccountId = typeof payload.knobs.chatgptAccountId === 'string' ? payload.knobs.chatgptAccountId : undefined;
91
+ const code = typeof payload.knobs.code === 'string' ? payload.knobs.code : '';
92
+ const retrySafe = (code === 'ambiguous' || code === 'http_error' || code === 'expired_unreconciled' || payload.knobs.stateWriteFailed === true) && typeof payload.knobs.retryGuidance === 'string' && accountKey !== undefined && chatgptAccountId !== undefined;
93
+ return [{
94
+ title: payload.text,
95
+ value: 'result',
96
+ description: 'Command result'
97
+ }, ...(retrySafe ? [{
98
+ title: 'Retry',
99
+ value: 'retry',
100
+ description: 'Reuse the bound request and credit identifiers for this uncertain outcome'
101
+ }] : []), {
102
+ title: 'Refresh',
103
+ value: 'refresh',
104
+ description: 'Re-check account quota and reset credits'
105
+ }, {
106
+ title: 'Close',
107
+ value: 'close',
108
+ description: 'Close this dialog'
109
+ }];
110
+ }
111
+ function openResetDialog(api, payload, apply) {
112
+ let applyGeneration = 0;
113
+ const render = state => {
114
+ const applyAndRender = args => {
115
+ const generation = ++applyGeneration;
116
+ log.debug('reset dialog apply', {
117
+ args,
118
+ generation
119
+ });
120
+ void apply('openai-reset', args).then(result => {
121
+ log.debug('reset dialog apply result', {
122
+ generation,
123
+ currentGeneration: applyGeneration,
124
+ stage: result.knobs?.stage,
125
+ accountRows: Array.isArray(result.knobs?.accounts) ? result.knobs.accounts.length : undefined
126
+ });
127
+ if (generation !== applyGeneration) return;
128
+ if (typeof result.knobs?.stage !== 'string') {
129
+ render({
130
+ command: 'openai-reset',
131
+ text: 'The reset request is still completing or returned no result. Choose Refresh to check the current state — do NOT re-confirm.',
132
+ knobs: {
133
+ stage: 'result',
134
+ code: 'unknown'
135
+ }
136
+ });
137
+ return;
138
+ }
139
+ render({
140
+ command: 'openai-reset',
141
+ text: result.text,
142
+ knobs: result.knobs
143
+ });
144
+ }).catch(error => {
145
+ if (generation !== applyGeneration) return;
146
+ log.warn('reset dialog apply rejected', {
147
+ error: errorMessage(error)
148
+ });
149
+ render({
150
+ command: 'openai-reset',
151
+ text: 'Command failed — the plugin could not complete the request; the plugin log has details.',
152
+ knobs: {
153
+ stage: 'result',
154
+ code: 'error'
155
+ }
156
+ });
157
+ });
158
+ };
159
+ const stage = state.knobs.stage;
160
+ log.debug('reset dialog render', {
161
+ stage,
162
+ accountRows: Array.isArray(state.knobs.accounts) ? state.knobs.accounts.length : undefined,
163
+ knobKeys: Object.keys(state.knobs)
164
+ });
165
+ api.ui.dialog.setSize('xlarge');
166
+ if (stage === 'confirm') {
167
+ const preview = state.knobs.preview;
168
+ const accountKey = preview?.accountKey;
169
+ const chatgptAccountId = preview?.chatgptAccountId;
170
+ const applicableCount = preview?.applicableAvailableCount ?? 0;
171
+ const DialogConfirm = api.ui.DialogConfirm;
172
+ api.ui.dialog.replace(() => _$createComponent(DialogConfirm, {
173
+ title: "Reset quota window",
174
+ get message() {
175
+ return `${state.text}\n\nThis SPENDS 1 of ${applicableCount} reset credits — irreversible.\n\nChoose Reset to continue or Cancel to return.\n\nEnter = Cancel (host default). Press Tab then Enter to Reset.`;
176
+ },
177
+ onConfirm: () => {
178
+ if (!accountKey || !chatgptAccountId) return;
179
+ applyAndRender(`confirm ${encodeURIComponent(accountKey)} ${encodeURIComponent(chatgptAccountId)}`);
180
+ },
181
+ onCancel: () => applyAndRender('refresh')
182
+ }));
183
+ return;
184
+ }
185
+ const DialogSelect = api.ui.DialogSelect;
186
+ const options = stage === 'result' ? resetResultOptions(state) : resetAccountOptions(state);
187
+ api.ui.dialog.replace(() => _$createComponent(DialogSelect, {
188
+ title: stage === 'result' ? 'OpenAI reset result' : 'OpenAI reset credits',
189
+ options: options,
190
+ onSelect: option => {
191
+ if (option.disabled) return;
192
+ if (option.value === 'result') return;
193
+ if (option.value === 'close') {
194
+ applyGeneration += 1;
195
+ api.ui.dialog.clear();
196
+ return;
197
+ }
198
+ if (option.value === 'refresh') {
199
+ applyAndRender('refresh');
200
+ return;
201
+ }
202
+ if (option.value === 'retry') {
203
+ const accountKey = state.knobs.accountKey;
204
+ const chatgptAccountId = state.knobs.chatgptAccountId;
205
+ if (typeof accountKey !== 'string' || typeof chatgptAccountId !== 'string') {
206
+ return;
207
+ }
208
+ applyAndRender(`retry ${encodeURIComponent(accountKey)} ${encodeURIComponent(chatgptAccountId)}`);
209
+ return;
210
+ }
211
+ if (!option.value.startsWith('account:')) return;
212
+ const accountKey = option.value.slice('account:'.length);
213
+ applyAndRender(`select ${encodeURIComponent(accountKey)}`);
214
+ }
215
+ }));
216
+ };
217
+ render(payload);
218
+ }
62
219
  function showText(api, text) {
63
220
  api.ui.dialog.setSize('xlarge');
64
221
  api.ui.dialog.replace(() => (() => {
@@ -107,6 +264,10 @@ function showSetCachekeepWindowPrompt(api, apply, state, render) {
107
264
  }));
108
265
  }
109
266
  export function openCommandDialog(api, payload, apply, sessionId) {
267
+ if (payload.command === 'openai-reset') {
268
+ openResetDialog(api, payload, apply);
269
+ return;
270
+ }
110
271
  if (payload.command === 'openai-routing') {
111
272
  const current = payload.knobs.mode ?? 'main-first';
112
273
  const DialogSelect = api.ui.DialogSelect;
@@ -1118,7 +1118,7 @@ const tui = async api => {
1118
1118
  }));
1119
1119
  continue;
1120
1120
  }
1121
- openCommandDialog(api, message.payload, (command, args) => rpcClient.apply(buildApplyRequest(command, args, sessionId)), sessionId);
1121
+ openCommandDialog(api, message.payload, (command, args) => rpcClient.apply(buildApplyRequest(command, args, sessionId), command === 'openai-reset' ? 90_000 : undefined), sessionId);
1122
1122
  }
1123
1123
  }).catch(() => {}).finally(() => {
1124
1124
  rpcInFlight = false;
@@ -0,0 +1,9 @@
1
+ export function errorMessage(error: unknown) {
2
+ if (error instanceof Error) return error.message
3
+ if (typeof error === 'string') return error
4
+ try {
5
+ return JSON.stringify(error)
6
+ } catch {
7
+ return String(error)
8
+ }
9
+ }