@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.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 (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1816 -594
  5. package/package.json +1 -1
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -174
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +31 -13
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. package/src/token-refresh.ts +14 -8
package/src/storage.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { appendFileSync, existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
+ import type { AccountIdentity } from "./account-identity.js";
5
+ import { findByIdentity } from "./account-identity.js";
4
6
  import { getConfigDir } from "./config.js";
5
7
 
6
8
  export interface AccountStats {
@@ -15,6 +17,8 @@ export interface AccountStats {
15
17
  export interface AccountMetadata {
16
18
  id: string;
17
19
  email?: string;
20
+ identity?: AccountIdentity;
21
+ label?: string;
18
22
  refreshToken: string;
19
23
  access?: string;
20
24
  expires?: number;
@@ -36,6 +40,11 @@ export interface AccountStorage {
36
40
  activeIndex: number;
37
41
  }
38
42
 
43
+ export type StoredAccountMatchCandidate = Pick<
44
+ AccountMetadata,
45
+ "id" | "email" | "identity" | "label" | "refreshToken" | "addedAt" | "source"
46
+ >;
47
+
39
48
  const CURRENT_VERSION = 1;
40
49
 
41
50
  /**
@@ -134,6 +143,8 @@ function validateAccount(raw: unknown, now: number): AccountMetadata | null {
134
143
  return {
135
144
  id,
136
145
  email: typeof acc.email === "string" ? acc.email : undefined,
146
+ identity: isAccountIdentity(acc.identity) ? acc.identity : undefined,
147
+ label: typeof acc.label === "string" ? acc.label : undefined,
137
148
  refreshToken: acc.refreshToken as string,
138
149
  access: typeof acc.access === "string" ? acc.access : undefined,
139
150
  expires: typeof acc.expires === "number" && Number.isFinite(acc.expires) ? acc.expires : undefined,
@@ -155,10 +166,139 @@ function validateAccount(raw: unknown, now: number): AccountMetadata | null {
155
166
  lastFailureTime: typeof acc.lastFailureTime === "number" ? acc.lastFailureTime : null,
156
167
  lastSwitchReason: typeof acc.lastSwitchReason === "string" ? acc.lastSwitchReason : undefined,
157
168
  stats: validateStats(acc.stats, now),
158
- source:
159
- acc.source === "cc-keychain" || acc.source === "cc-file" || acc.source === "oauth"
160
- ? (acc.source as "cc-keychain" | "cc-file" | "oauth")
161
- : undefined,
169
+ source: acc.source === "cc-keychain" || acc.source === "cc-file" || acc.source === "oauth" ? acc.source : undefined,
170
+ };
171
+ }
172
+
173
+ function isAccountIdentity(value: unknown): value is AccountIdentity {
174
+ if (!value || typeof value !== "object") return false;
175
+
176
+ const candidate = value as Record<string, unknown>;
177
+ switch (candidate.kind) {
178
+ case "oauth":
179
+ return typeof candidate.email === "string" && candidate.email.length > 0;
180
+ case "cc":
181
+ return (
182
+ (candidate.source === "cc-keychain" || candidate.source === "cc-file") && typeof candidate.label === "string"
183
+ );
184
+ case "legacy":
185
+ return typeof candidate.refreshToken === "string" && candidate.refreshToken.length > 0;
186
+ default:
187
+ return false;
188
+ }
189
+ }
190
+
191
+ function resolveStoredIdentity(candidate: StoredAccountMatchCandidate): AccountIdentity {
192
+ if (isAccountIdentity(candidate.identity)) {
193
+ return candidate.identity;
194
+ }
195
+
196
+ if (candidate.source === "oauth" && candidate.email) {
197
+ return { kind: "oauth", email: candidate.email };
198
+ }
199
+
200
+ if ((candidate.source === "cc-keychain" || candidate.source === "cc-file") && candidate.label) {
201
+ return {
202
+ kind: "cc",
203
+ source: candidate.source,
204
+ label: candidate.label,
205
+ };
206
+ }
207
+
208
+ return { kind: "legacy", refreshToken: candidate.refreshToken };
209
+ }
210
+
211
+ function resolveTokenUpdatedAt(account: Pick<AccountMetadata, "token_updated_at" | "addedAt">): number {
212
+ return typeof account.token_updated_at === "number" && Number.isFinite(account.token_updated_at)
213
+ ? account.token_updated_at
214
+ : account.addedAt;
215
+ }
216
+
217
+ function clampActiveIndex(accounts: AccountMetadata[], activeIndex: number): number {
218
+ if (accounts.length === 0) {
219
+ return 0;
220
+ }
221
+
222
+ return Math.max(0, Math.min(activeIndex, accounts.length - 1));
223
+ }
224
+
225
+ export function findStoredAccountMatch(
226
+ accounts: AccountMetadata[],
227
+ candidate: StoredAccountMatchCandidate,
228
+ ): AccountMetadata | null {
229
+ const byId = accounts.find((account) => account.id === candidate.id);
230
+ if (byId) {
231
+ return byId;
232
+ }
233
+
234
+ const byIdentity = findByIdentity(accounts, resolveStoredIdentity(candidate));
235
+ if (byIdentity) {
236
+ return byIdentity;
237
+ }
238
+
239
+ const byAddedAt = accounts.filter((account) => account.addedAt === candidate.addedAt);
240
+ if (byAddedAt.length === 1) {
241
+ return byAddedAt[0]!;
242
+ }
243
+
244
+ const byRefreshToken = accounts.find((account) => account.refreshToken === candidate.refreshToken);
245
+ if (byRefreshToken) {
246
+ return byRefreshToken;
247
+ }
248
+
249
+ return byAddedAt[0] ?? null;
250
+ }
251
+
252
+ export function mergeAccountWithFresherAuth(
253
+ account: AccountMetadata,
254
+ diskMatch: AccountMetadata | null,
255
+ ): AccountMetadata {
256
+ const memoryTokenUpdatedAt = resolveTokenUpdatedAt(account);
257
+ const diskTokenUpdatedAt = diskMatch ? resolveTokenUpdatedAt(diskMatch) : 0;
258
+
259
+ if (!diskMatch || diskTokenUpdatedAt <= memoryTokenUpdatedAt) {
260
+ return {
261
+ ...account,
262
+ token_updated_at: memoryTokenUpdatedAt,
263
+ };
264
+ }
265
+
266
+ return {
267
+ ...account,
268
+ refreshToken: diskMatch.refreshToken,
269
+ access: diskMatch.access,
270
+ expires: diskMatch.expires,
271
+ token_updated_at: diskTokenUpdatedAt,
272
+ };
273
+ }
274
+
275
+ export function unionAccountsWithDisk(storage: AccountStorage, disk: AccountStorage | null): AccountStorage {
276
+ if (!disk || storage.accounts.length === 0) {
277
+ return {
278
+ ...storage,
279
+ activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex),
280
+ };
281
+ }
282
+
283
+ const activeAccountId = storage.accounts[storage.activeIndex]?.id ?? null;
284
+ const matchedDiskAccounts = new Set<AccountMetadata>();
285
+ const mergedAccounts = storage.accounts.map((account) => {
286
+ const diskMatch = findStoredAccountMatch(disk.accounts, account);
287
+ if (diskMatch) {
288
+ matchedDiskAccounts.add(diskMatch);
289
+ }
290
+
291
+ return mergeAccountWithFresherAuth(account, diskMatch);
292
+ });
293
+
294
+ const diskOnlyAccounts = disk.accounts.filter((account) => !matchedDiskAccounts.has(account));
295
+ const accounts = [...mergedAccounts, ...diskOnlyAccounts];
296
+ const activeIndex = activeAccountId ? accounts.findIndex((account) => account.id === activeAccountId) : -1;
297
+
298
+ return {
299
+ ...storage,
300
+ accounts,
301
+ activeIndex: activeIndex >= 0 ? activeIndex : clampActiveIndex(accounts, storage.activeIndex),
162
302
  };
163
303
  }
164
304
 
@@ -177,8 +317,9 @@ export async function loadAccounts(): Promise<AccountStorage | null> {
177
317
  }
178
318
 
179
319
  if (data.version !== CURRENT_VERSION) {
180
- // Future: handle migrations here
181
- return null;
320
+ console.warn(
321
+ `Storage version mismatch: ${String(data.version)} vs ${CURRENT_VERSION}. Attempting best-effort migration.`,
322
+ );
182
323
  }
183
324
 
184
325
  const now = Date.now();
@@ -218,66 +359,15 @@ export async function saveAccounts(storage: AccountStorage): Promise<void> {
218
359
  await fs.mkdir(configDir, { recursive: true });
219
360
  ensureGitignore(configDir);
220
361
 
221
- let storageToWrite = storage;
362
+ let storageToWrite = {
363
+ ...storage,
364
+ activeIndex: clampActiveIndex(storage.accounts, storage.activeIndex),
365
+ };
222
366
 
223
367
  // Merge auth fields against disk by freshness to avoid stale-process clobber.
224
368
  try {
225
369
  const disk = await loadAccounts();
226
- if (disk && storage.accounts.length > 0) {
227
- const diskById = new Map(disk.accounts.map((a) => [a.id, a]));
228
- const diskByAddedAt = new Map<number, AccountMetadata[]>();
229
- const diskByToken = new Map(disk.accounts.map((a) => [a.refreshToken, a]));
230
- for (const d of disk.accounts) {
231
- const bucket = diskByAddedAt.get(d.addedAt) || [];
232
- bucket.push(d);
233
- diskByAddedAt.set(d.addedAt, bucket);
234
- }
235
-
236
- const findDiskMatch = (acc: AccountMetadata): AccountMetadata | null => {
237
- const byId = diskById.get(acc.id);
238
- if (byId) return byId;
239
-
240
- const byAddedAt = diskByAddedAt.get(acc.addedAt);
241
- if (byAddedAt?.length === 1) return byAddedAt[0]!;
242
-
243
- const byToken = diskByToken.get(acc.refreshToken);
244
- if (byToken) return byToken;
245
-
246
- if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0]!;
247
- return null;
248
- };
249
-
250
- const mergedAccounts = storage.accounts.map((acc) => {
251
- const diskAcc = findDiskMatch(acc);
252
- const memTs =
253
- typeof acc.token_updated_at === "number" && Number.isFinite(acc.token_updated_at)
254
- ? acc.token_updated_at
255
- : acc.addedAt;
256
- const diskTs = diskAcc?.token_updated_at || 0;
257
- const useDiskAuth = !!diskAcc && diskTs > memTs;
258
-
259
- return {
260
- ...acc,
261
- refreshToken: useDiskAuth ? diskAcc!.refreshToken : acc.refreshToken,
262
- access: useDiskAuth ? diskAcc!.access : acc.access,
263
- expires: useDiskAuth ? diskAcc!.expires : acc.expires,
264
- token_updated_at: useDiskAuth ? diskTs : memTs,
265
- };
266
- });
267
-
268
- let activeIndex = storage.activeIndex;
269
- if (mergedAccounts.length > 0) {
270
- activeIndex = Math.max(0, Math.min(activeIndex, mergedAccounts.length - 1));
271
- } else {
272
- activeIndex = 0;
273
- }
274
-
275
- storageToWrite = {
276
- ...storage,
277
- accounts: mergedAccounts,
278
- activeIndex,
279
- };
280
- }
370
+ storageToWrite = unionAccountsWithDisk(storageToWrite, disk);
281
371
  } catch {
282
372
  // If merge read fails, continue with caller-provided storage payload.
283
373
  }
@@ -1,6 +1,36 @@
1
- // ---------------------------------------------------------------------------
2
- // System prompt block builder
3
- // ---------------------------------------------------------------------------
1
+ // ===========================================================================
2
+ // System Prompt Structure — CC Alignment Audit
3
+ // ===========================================================================
4
+ //
5
+ // Audit date: 2026-04-10
6
+ // Reference: .omc/research/cch-source-analysis.md,
7
+ // .omc/research/cc-vs-plugin-comparison.md
8
+ //
9
+ // VERIFIED ALIGNMENT:
10
+ // [x] Billing block placement: inserted first in the system array, with no
11
+ // cache_control on the emitted block.
12
+ // [x] Identity string text matches CC exactly:
13
+ // "You are Claude Code, Anthropic's official CLI for Claude."
14
+ // [x] Identity cache_control matches CC's request shape:
15
+ // { type: "ephemeral" }.
16
+ // [x] Block ordering remains billing → identity → remaining sanitized blocks.
17
+ // [x] Sanitization rewrites OpenCode-specific references toward Claude/Claude
18
+ // Code phrasing before final prompt assembly.
19
+ //
20
+ // FUTURE HARDENING NOTES:
21
+ // - CC's full system prompt is much larger (tool instructions, permissions,
22
+ // internal workflow text). This builder intentionally preserves only the
23
+ // routing-critical structure documented in the source analysis.
24
+ // - CC records billing cache behavior internally as cacheScope: null. The
25
+ // plugin emits no cache_control field on the billing block, which is the
26
+ // equivalent wire representation.
27
+ // - CC can append cc_workload when AsyncLocalStorage workload tracking is
28
+ // present. That field is not applicable to this plugin.
29
+ // - CC tool naming conventions can evolve independently from this file. Current
30
+ // plugin-specific tool prefix notes live in body/request docs, not here.
31
+ //
32
+ // See src/headers/billing.ts for billing-specific gaps and attestation notes.
33
+ // ===========================================================================
4
34
 
5
35
  import {
6
36
  CLAUDE_CODE_IDENTITY_STRING,
@@ -5,8 +5,18 @@
5
5
  import { CLAUDE_CODE_IDENTITY_STRING } from "../constants.js";
6
6
  import type { PromptCompactionMode } from "../types.js";
7
7
 
8
- export function sanitizeSystemText(text: string): string {
9
- return text.replace(/OpenCode/g, "Claude Code").replace(/opencode/gi, "Claude");
8
+ export function sanitizeSystemText(text: string, enabled = true): string {
9
+ if (!enabled) return text;
10
+ return text
11
+ .replace(/\bOpenCode\b/g, "Claude Code")
12
+ .replace(/\bopencode\b/gi, "Claude")
13
+ .replace(/OhMyClaude\s*Code/gi, "Claude Code")
14
+ .replace(/OhMyClaudeCode/gi, "Claude Code")
15
+ .replace(/\bSisyphus\b/g, "Claude Code Agent")
16
+ .replace(/\bMorph\s+plugin\b/gi, "edit plugin")
17
+ .replace(/\bmorph_edit\b/g, "edit")
18
+ .replace(/\bmorph_/g, "")
19
+ .replace(/\bOhMyClaude\b/gi, "Claude");
10
20
  }
11
21
 
12
22
  export function compactSystemText(text: string, mode: PromptCompactionMode): string {
@@ -1,5 +1,7 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
3
+ import { createDeferred, nextTick } from "./__tests__/helpers/deferred.js";
4
+ import { createRefreshHelpers } from "./refresh-helpers.js";
3
5
 
4
6
  vi.mock("node:child_process", () => ({
5
7
  execSync: vi.fn(),
@@ -32,7 +34,7 @@ import type { ManagedAccount } from "./accounts.js";
32
34
  import type { CCCredential } from "./cc-credentials.js";
33
35
  import { readCCCredentials, readCCCredentialsFromFile } from "./cc-credentials.js";
34
36
  import { refreshToken } from "./oauth.js";
35
- import { refreshAccountToken } from "./token-refresh.js";
37
+ import { applyDiskAuthIfFresher, refreshAccountToken } from "./token-refresh.js";
36
38
 
37
39
  const mockExecSync = execSync as Mock;
38
40
  const mockReadCCCredentials = readCCCredentials as Mock;
@@ -231,4 +233,85 @@ describe("refreshAccountToken", () => {
231
233
  timeout: 5000,
232
234
  });
233
235
  });
236
+
237
+ it("reuses the first foreground retry after an idle refresh rejection", async () => {
238
+ const idleRefresh = createDeferred<{
239
+ access_token: string;
240
+ expires_in: number;
241
+ refresh_token?: string;
242
+ }>();
243
+ const foregroundRefresh = createDeferred<{
244
+ access_token: string;
245
+ expires_in: number;
246
+ refresh_token?: string;
247
+ }>();
248
+ const idleFailure = new Error("idle refresh failed");
249
+ const foregroundFailure = new Error("foreground refresh failed");
250
+ mockRefreshToken
251
+ .mockImplementationOnce(() => idleRefresh.promise)
252
+ .mockImplementationOnce(() => foregroundRefresh.promise)
253
+ .mockRejectedValueOnce(new Error("duplicate foreground refresh"));
254
+ const accountManager = {
255
+ saveToDisk: vi.fn().mockResolvedValue(undefined),
256
+ requestSaveToDisk: vi.fn(),
257
+ getEnabledAccounts: vi.fn().mockReturnValue([]),
258
+ };
259
+ const account = makeAccount();
260
+ const helpers = createRefreshHelpers({
261
+ client: {},
262
+ config: {
263
+ idle_refresh: {
264
+ enabled: true,
265
+ window_minutes: 10,
266
+ min_interval_minutes: 1,
267
+ },
268
+ } as never,
269
+ getAccountManager: () => accountManager as never,
270
+ debugLog: vi.fn(),
271
+ });
272
+ const idleCall = helpers.refreshAccountTokenSingleFlight(account, "idle").catch((error) => error);
273
+ await nextTick();
274
+ await nextTick();
275
+
276
+ const foregroundCallA = helpers.refreshAccountTokenSingleFlight(account, "foreground").catch((error) => error);
277
+ const foregroundCallB = helpers.refreshAccountTokenSingleFlight(account, "foreground").catch((error) => error);
278
+ await nextTick();
279
+
280
+ idleRefresh.reject(idleFailure);
281
+ await expect(idleCall).resolves.toBe(idleFailure);
282
+ await nextTick();
283
+
284
+ expect(mockRefreshToken).toHaveBeenCalledTimes(2);
285
+
286
+ foregroundRefresh.reject(foregroundFailure);
287
+
288
+ await expect(foregroundCallA).resolves.toBe(foregroundFailure);
289
+ await expect(foregroundCallB).resolves.toBe(foregroundFailure);
290
+ });
291
+
292
+ it("does not adopt older expired-fallback disk auth when only access differs", () => {
293
+ const currentTime = Date.now();
294
+ const account = makeAccount({
295
+ refreshToken: "refresh-current",
296
+ access: "access-current",
297
+ expires: currentTime - 1_000,
298
+ tokenUpdatedAt: currentTime,
299
+ });
300
+
301
+ const adopted = applyDiskAuthIfFresher(
302
+ account,
303
+ {
304
+ refreshToken: "refresh-current",
305
+ access: "access-stale",
306
+ expires: currentTime + 60_000,
307
+ tokenUpdatedAt: currentTime - 60_000,
308
+ },
309
+ { allowExpiredFallback: true },
310
+ );
311
+
312
+ expect(adopted).toBe(false);
313
+ expect(account.refreshToken).toBe("refresh-current");
314
+ expect(account.access).toBe("access-current");
315
+ expect(account.tokenUpdatedAt).toBe(currentTime);
316
+ });
234
317
  });
@@ -61,21 +61,25 @@ export function applyDiskAuthIfFresher(
61
61
  if (!diskAuth) return false;
62
62
  const diskTokenUpdatedAt = diskAuth.tokenUpdatedAt || 0;
63
63
  const memTokenUpdatedAt = account.tokenUpdatedAt || 0;
64
- const diskHasDifferentAuth = diskAuth.refreshToken !== account.refreshToken || diskAuth.access !== account.access;
64
+ const diskIsNewer = diskTokenUpdatedAt > memTokenUpdatedAt;
65
+ const diskHasDifferentRefreshToken = diskAuth.refreshToken !== account.refreshToken;
65
66
  const memAuthExpired = !account.expires || account.expires <= Date.now();
66
67
  const allowExpiredFallback = options.allowExpiredFallback === true;
67
- if (diskTokenUpdatedAt <= memTokenUpdatedAt && !(allowExpiredFallback && diskHasDifferentAuth && memAuthExpired)) {
68
+ if (!diskIsNewer && !(allowExpiredFallback && diskHasDifferentRefreshToken && memAuthExpired)) {
68
69
  return false;
69
70
  }
70
71
  account.refreshToken = diskAuth.refreshToken;
71
72
  account.access = diskAuth.access;
72
73
  account.expires = diskAuth.expires;
73
- account.tokenUpdatedAt = Math.max(memTokenUpdatedAt, diskTokenUpdatedAt);
74
+ if (diskIsNewer) {
75
+ account.tokenUpdatedAt = diskTokenUpdatedAt;
76
+ }
74
77
  return true;
75
78
  }
76
79
 
77
80
  export interface RefreshAccountTokenOptions {
78
81
  onTokensUpdated?: () => Promise<void>;
82
+ debugLog?: (...args: unknown[]) => void;
79
83
  }
80
84
 
81
85
  export interface OpenCodeClient {
@@ -188,12 +192,10 @@ export async function refreshAccountToken(
188
192
  account: ManagedAccount,
189
193
  client: OpenCodeClient,
190
194
  source: "foreground" | "idle" = "foreground",
191
- { onTokensUpdated }: RefreshAccountTokenOptions = {},
195
+ { onTokensUpdated, debugLog }: RefreshAccountTokenOptions = {},
192
196
  ): Promise<string> {
193
197
  const lockResult = await acquireRefreshLock(account.id, {
194
- timeoutMs: 2_000,
195
198
  backoffMs: 60,
196
- staleMs: 20_000,
197
199
  });
198
200
  const lock =
199
201
  lockResult && typeof lockResult === "object"
@@ -233,7 +235,9 @@ export async function refreshAccountToken(
233
235
  const accessToken = await refreshCCAccount(account);
234
236
  if (accessToken) {
235
237
  if (onTokensUpdated) {
236
- await onTokensUpdated().catch(() => undefined);
238
+ await onTokensUpdated().catch((err) => {
239
+ debugLog?.("onTokensUpdated failed:", (err as Error).message);
240
+ });
237
241
  }
238
242
 
239
243
  await client.auth
@@ -246,7 +250,9 @@ export async function refreshAccountToken(
246
250
  expires: account.expires,
247
251
  },
248
252
  })
249
- .catch(() => undefined);
253
+ .catch((err) => {
254
+ debugLog?.("auth.set failed:", (err as Error).message);
255
+ });
250
256
  return accessToken;
251
257
  }
252
258
  throw new Error("CC credential refresh failed");