opencode-copilot-account-switcher 0.13.2 → 0.13.4

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.
@@ -0,0 +1,32 @@
1
+ import type { CodexAccountEntry, CodexStoreFile } from "./codex-store.js";
2
+ export type CodexRecoveryCandidate = {
3
+ name: string;
4
+ entry: CodexAccountEntry;
5
+ };
6
+ export type CodexSetAuthInput = {
7
+ path: {
8
+ id: string;
9
+ };
10
+ body: {
11
+ type: "oauth";
12
+ refresh?: string;
13
+ access?: string;
14
+ expires?: number;
15
+ accountId?: string;
16
+ };
17
+ };
18
+ export type RecoverInvalidCodexAccountResult = {
19
+ removed: string;
20
+ replacement?: string;
21
+ switched: boolean;
22
+ weekRecoveryOnly: boolean;
23
+ noCandidates: boolean;
24
+ store: CodexStoreFile;
25
+ };
26
+ export declare function getCodexDisplayName(entry: CodexAccountEntry | undefined, fallbackName: string): string;
27
+ export declare function sortCodexRecoveryCandidates(candidates: CodexRecoveryCandidate[]): CodexRecoveryCandidate[];
28
+ export declare function recoverInvalidCodexAccount(input: {
29
+ store: CodexStoreFile;
30
+ invalidAccountName: string;
31
+ setAuth?: (next: CodexSetAuthInput) => Promise<unknown>;
32
+ }): Promise<RecoverInvalidCodexAccountResult>;
@@ -0,0 +1,106 @@
1
+ function pickPositiveNumber(value) {
2
+ if (typeof value !== "number" || !Number.isFinite(value))
3
+ return 0;
4
+ return value > 0 ? value : 0;
5
+ }
6
+ function pickFiniteNumber(value) {
7
+ if (typeof value !== "number" || !Number.isFinite(value))
8
+ return undefined;
9
+ return value;
10
+ }
11
+ function getWeekRemaining(entry) {
12
+ return pickPositiveNumber(entry.snapshot?.usageWeek?.remaining);
13
+ }
14
+ function get5hRemaining(entry) {
15
+ return pickPositiveNumber(entry.snapshot?.usage5h?.remaining);
16
+ }
17
+ function compareResetAt(a, b) {
18
+ const aMissing = a === undefined;
19
+ const bMissing = b === undefined;
20
+ if (aMissing && bMissing)
21
+ return 0;
22
+ if (aMissing)
23
+ return 1;
24
+ if (bMissing)
25
+ return -1;
26
+ if (a === b)
27
+ return 0;
28
+ return a < b ? -1 : 1;
29
+ }
30
+ export function getCodexDisplayName(entry, fallbackName) {
31
+ return entry?.workspaceName
32
+ ?? entry?.name
33
+ ?? entry?.email
34
+ ?? entry?.accountId
35
+ ?? fallbackName;
36
+ }
37
+ export function sortCodexRecoveryCandidates(candidates) {
38
+ const withIndex = candidates.map((candidate, index) => ({ candidate, index }));
39
+ const weekPositive = withIndex.filter(({ candidate }) => getWeekRemaining(candidate.entry) > 0);
40
+ const pool = weekPositive.length > 0 ? weekPositive : withIndex;
41
+ const has5hPositiveInPool = pool.some(({ candidate }) => get5hRemaining(candidate.entry) > 0);
42
+ return pool
43
+ .slice()
44
+ .sort((a, b) => {
45
+ if (weekPositive.length > 0 && has5hPositiveInPool) {
46
+ const a5h = get5hRemaining(a.candidate.entry);
47
+ const b5h = get5hRemaining(b.candidate.entry);
48
+ if (a5h > 0 && b5h <= 0)
49
+ return -1;
50
+ if (a5h <= 0 && b5h > 0)
51
+ return 1;
52
+ const by5hResetAt = compareResetAt(pickFiniteNumber(a.candidate.entry.snapshot?.usage5h?.resetAt), pickFiniteNumber(b.candidate.entry.snapshot?.usage5h?.resetAt));
53
+ if (by5hResetAt !== 0)
54
+ return by5hResetAt;
55
+ }
56
+ const byWeekResetAt = compareResetAt(pickFiniteNumber(a.candidate.entry.snapshot?.usageWeek?.resetAt), pickFiniteNumber(b.candidate.entry.snapshot?.usageWeek?.resetAt));
57
+ if (byWeekResetAt !== 0)
58
+ return byWeekResetAt;
59
+ return a.index - b.index;
60
+ })
61
+ .map(({ candidate }) => candidate);
62
+ }
63
+ export async function recoverInvalidCodexAccount(input) {
64
+ const store = {
65
+ ...input.store,
66
+ accounts: { ...input.store.accounts },
67
+ };
68
+ delete store.accounts[input.invalidAccountName];
69
+ const candidates = Object.entries(store.accounts).map(([name, entry]) => ({
70
+ name,
71
+ entry,
72
+ }));
73
+ const sorted = sortCodexRecoveryCandidates(candidates);
74
+ const replacement = sorted[0];
75
+ const noCandidates = !replacement;
76
+ const switched = Boolean(replacement);
77
+ const weekRecoveryOnly = Boolean(replacement
78
+ && getWeekRemaining(replacement.entry) > 0
79
+ && get5hRemaining(replacement.entry) <= 0);
80
+ if (replacement) {
81
+ store.active = replacement.name;
82
+ }
83
+ else if (store.active === input.invalidAccountName) {
84
+ delete store.active;
85
+ }
86
+ if (replacement && input.setAuth) {
87
+ await input.setAuth({
88
+ path: { id: "openai" },
89
+ body: {
90
+ type: "oauth",
91
+ refresh: replacement.entry.refresh,
92
+ access: replacement.entry.access,
93
+ expires: replacement.entry.expires,
94
+ accountId: replacement.entry.accountId,
95
+ },
96
+ });
97
+ }
98
+ return {
99
+ removed: input.invalidAccountName,
100
+ replacement: replacement?.name,
101
+ switched,
102
+ weekRecoveryOnly,
103
+ noCandidates,
104
+ store,
105
+ };
106
+ }
@@ -7,11 +7,33 @@ export type TokenResponse = {
7
7
  export type IdTokenClaims = {
8
8
  chatgpt_account_id?: string;
9
9
  organizations?: Array<{
10
- id: string;
10
+ id?: string;
11
+ name?: string;
12
+ display_name?: string;
13
+ workspace_name?: string;
14
+ slug?: string;
11
15
  }>;
16
+ organization?: {
17
+ id?: string;
18
+ name?: string;
19
+ display_name?: string;
20
+ workspace_name?: string;
21
+ slug?: string;
22
+ };
23
+ workspace?: {
24
+ id?: string;
25
+ name?: string;
26
+ display_name?: string;
27
+ workspace_name?: string;
28
+ slug?: string;
29
+ };
30
+ workspace_name?: string;
12
31
  email?: string;
13
32
  "https://api.openai.com/auth"?: {
14
33
  chatgpt_account_id?: string;
34
+ workspace_name?: string;
35
+ workspace_id?: string;
36
+ organization_id?: string;
15
37
  };
16
38
  };
17
39
  export type CodexOAuthAccount = {
@@ -20,6 +42,7 @@ export type CodexOAuthAccount = {
20
42
  expires?: number;
21
43
  accountId?: string;
22
44
  email?: string;
45
+ workspaceName?: string;
23
46
  };
24
47
  type OAuthMode = "browser" | "headless";
25
48
  type RunCodexOAuthInput = {
@@ -35,5 +58,7 @@ type RunCodexOAuthInput = {
35
58
  export declare function parseJwtClaims(token: string): IdTokenClaims | undefined;
36
59
  export declare function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined;
37
60
  export declare function extractAccountId(tokens: TokenResponse): string | undefined;
61
+ export declare function extractWorkspaceNameFromClaims(claims: IdTokenClaims): string | undefined;
62
+ export declare function extractWorkspaceName(tokens: TokenResponse): string | undefined;
38
63
  export declare function runCodexOAuth(input?: RunCodexOAuthInput): Promise<CodexOAuthAccount | undefined>;
39
64
  export {};
@@ -82,6 +82,32 @@ export function extractAccountId(tokens) {
82
82
  const claims = parseJwtClaims(tokens.access_token);
83
83
  return claims ? extractAccountIdFromClaims(claims) : undefined;
84
84
  }
85
+ function pickWorkspaceLikeLabel(input) {
86
+ if (!input)
87
+ return undefined;
88
+ return input.workspace_name ?? input.display_name ?? input.name ?? input.slug ?? input.id;
89
+ }
90
+ export function extractWorkspaceNameFromClaims(claims) {
91
+ return (claims.workspace_name
92
+ || claims["https://api.openai.com/auth"]?.workspace_name
93
+ || claims["https://api.openai.com/auth"]?.workspace_id
94
+ || claims["https://api.openai.com/auth"]?.organization_id
95
+ || pickWorkspaceLikeLabel(claims.workspace)
96
+ || pickWorkspaceLikeLabel(claims.organization)
97
+ || pickWorkspaceLikeLabel(claims.organizations?.[0]));
98
+ }
99
+ export function extractWorkspaceName(tokens) {
100
+ if (tokens.id_token) {
101
+ const claims = parseJwtClaims(tokens.id_token);
102
+ const workspaceName = claims && extractWorkspaceNameFromClaims(claims);
103
+ if (workspaceName)
104
+ return workspaceName;
105
+ }
106
+ if (!tokens.access_token)
107
+ return undefined;
108
+ const claims = parseJwtClaims(tokens.access_token);
109
+ return claims ? extractWorkspaceNameFromClaims(claims) : undefined;
110
+ }
85
111
  function extractEmail(tokens) {
86
112
  if (tokens.id_token) {
87
113
  const claims = parseJwtClaims(tokens.id_token);
@@ -291,12 +317,14 @@ function normalizeTokens(tokens, now) {
291
317
  const access = tokens.access_token;
292
318
  if (!refresh && !access)
293
319
  return undefined;
320
+ const workspaceName = extractWorkspaceName(tokens);
294
321
  return {
295
322
  refresh,
296
323
  access,
297
324
  expires: now() + (tokens.expires_in ?? 3600) * 1000,
298
325
  accountId: extractAccountId(tokens),
299
326
  email: extractEmail(tokens),
327
+ ...(workspaceName ? { workspaceName } : {}),
300
328
  };
301
329
  }
302
330
  export async function runCodexOAuth(input = {}) {
@@ -1,6 +1,7 @@
1
1
  import { resolveCodexAuthSource } from "./codex-auth-source.js";
2
2
  import { fetchCodexStatus } from "./codex-status-fetcher.js";
3
3
  import { getActiveCodexAccount, normalizeCodexStore, readCodexStore, writeCodexStore, } from "./codex-store.js";
4
+ import { getCodexDisplayName, recoverInvalidCodexAccount } from "./codex-invalid-account.js";
4
5
  import { readAuth } from "./store.js";
5
6
  export class CodexStatusCommandHandledError extends Error {
6
7
  constructor() {
@@ -44,6 +45,12 @@ function ratio(remaining, entitlement) {
44
45
  function value(value) {
45
46
  return value === undefined ? "n/a" : String(value);
46
47
  }
48
+ function pickWorkspaceLabel(input) {
49
+ return input.workspaceName
50
+ ?? input.name
51
+ ?? input.email
52
+ ?? input.accountId;
53
+ }
47
54
  function renderWindow(label, window) {
48
55
  if (window.entitlement === 100 && window.remaining !== undefined) {
49
56
  return `${label}: ${window.remaining}% left`;
@@ -51,16 +58,16 @@ function renderWindow(label, window) {
51
58
  return `${label}: ${ratio(window.remaining, window.entitlement)}`;
52
59
  }
53
60
  function renderStatus(status) {
61
+ const identity = status.identity;
54
62
  return [
55
- "Codex status updated.",
56
- "[identity]",
57
- `account: ${value(status.identity.accountId)}`,
58
- `email: ${value(status.identity.email)}`,
59
- `plan: ${value(status.identity.plan)}`,
60
- "[usage]",
63
+ `账号: ${value(identity.accountId ?? identity.email)}`,
64
+ `Workspace: ${value(pickWorkspaceLabel({
65
+ workspaceName: identity.workspaceName,
66
+ email: identity.email,
67
+ accountId: identity.accountId,
68
+ }))}`,
61
69
  renderWindow("5h", status.windows.primary),
62
70
  renderWindow("week", status.windows.secondary),
63
- `credits: ${ratio(status.credits.remaining, status.credits.total)}`,
64
71
  ].join("\n");
65
72
  }
66
73
  function renderCachedStatus(store) {
@@ -68,14 +75,15 @@ function renderCachedStatus(store) {
68
75
  const entry = active?.entry;
69
76
  const snapshot = entry?.snapshot;
70
77
  return [
71
- "[identity]",
72
- `account: ${value(entry?.accountId ?? active?.name)}`,
73
- `email: ${value(entry?.email)}`,
74
- `plan: ${value(snapshot?.plan)}`,
75
- "[usage]",
78
+ `账号: ${value(entry?.accountId ?? active?.name ?? entry?.email)}`,
79
+ `Workspace: ${value(pickWorkspaceLabel({
80
+ workspaceName: entry?.workspaceName,
81
+ name: entry?.name ?? active?.name,
82
+ email: entry?.email,
83
+ accountId: entry?.accountId,
84
+ }))}`,
76
85
  renderWindow("5h", snapshot?.usage5h ?? {}),
77
- "week: n/a",
78
- "credits: n/a",
86
+ renderWindow("week", snapshot?.usageWeek ?? {}),
79
87
  ].join("\n");
80
88
  }
81
89
  function getCachedAccountForSource(store, input) {
@@ -96,14 +104,15 @@ function renderCachedStatusForAccount(store, input) {
96
104
  const entry = active?.entry;
97
105
  const snapshot = entry?.snapshot;
98
106
  return [
99
- "[identity]",
100
- `account: ${value(entry?.accountId ?? active?.name)}`,
101
- `email: ${value(entry?.email)}`,
102
- `plan: ${value(snapshot?.plan)}`,
103
- "[usage]",
107
+ `账号: ${value(entry?.accountId ?? active?.name ?? entry?.email)}`,
108
+ `Workspace: ${value(pickWorkspaceLabel({
109
+ workspaceName: entry?.workspaceName,
110
+ name: entry?.name ?? active?.name,
111
+ email: entry?.email,
112
+ accountId: entry?.accountId,
113
+ }))}`,
104
114
  renderWindow("5h", snapshot?.usage5h ?? {}),
105
- "week: n/a",
106
- "credits: n/a",
115
+ renderWindow("week", snapshot?.usageWeek ?? {}),
107
116
  ].join("\n");
108
117
  }
109
118
  function hasCachedStore(store) {
@@ -230,12 +239,55 @@ export async function handleCodexStatusCommand(input) {
230
239
  },
231
240
  }));
232
241
  if (!fetched.ok) {
242
+ if (fetched.error.kind === "invalid_account") {
243
+ const currentRaw = await readStore().catch(() => ({}));
244
+ const currentStore = normalizeCodexStore(currentRaw);
245
+ const invalid = getCachedAccountForSource(currentStore, { accountId: source.accountId });
246
+ const invalidName = invalid?.name ?? currentStore.active;
247
+ if (invalidName && currentStore.accounts[invalidName]) {
248
+ const recovered = await recoverInvalidCodexAccount({
249
+ store: currentStore,
250
+ invalidAccountName: invalidName,
251
+ setAuth: input.client?.auth?.set
252
+ ? async (next) => {
253
+ const authClient = input.client?.auth;
254
+ const setAuth = input.client?.auth?.set;
255
+ if (!setAuth)
256
+ return;
257
+ await setAuth.call(authClient, next);
258
+ }
259
+ : undefined,
260
+ });
261
+ await writeStore(recovered.store);
262
+ const removedDisplay = getCodexDisplayName(invalid?.entry, recovered.removed);
263
+ const messageLines = [`无效账号${removedDisplay}已移除,请及时检查核对`];
264
+ if (recovered.replacement) {
265
+ const replacementEntry = recovered.store.accounts[recovered.replacement];
266
+ const replacementDisplay = getCodexDisplayName(replacementEntry, recovered.replacement);
267
+ messageLines.push(`已切换到${replacementDisplay}`);
268
+ if (recovered.weekRecoveryOnly) {
269
+ messageLines.push("请检查账号状态");
270
+ }
271
+ }
272
+ await showToast({
273
+ client: input.client,
274
+ message: messageLines.join("\n"),
275
+ variant: "warning",
276
+ });
277
+ throw new CodexStatusCommandHandledError();
278
+ }
279
+ }
233
280
  const cachedRaw = await readStore().catch(() => ({}));
234
281
  const cached = normalizeCodexStore(cachedRaw);
235
282
  if (hasCachedStore(cached)) {
236
283
  await showToast({
237
284
  client: input.client,
238
- message: `Codex status fetch failed (${fetched.error.message}); showing cached snapshot.\n${renderCachedStatusForAccount(cached, { accountId: source.accountId })}`,
285
+ message: `Codex status fetch failed: ${fetched.error.message}`,
286
+ variant: "warning",
287
+ });
288
+ await showToast({
289
+ client: input.client,
290
+ message: renderCachedStatusForAccount(cached, { accountId: source.accountId }),
239
291
  variant: "warning",
240
292
  });
241
293
  }
@@ -279,6 +331,7 @@ export async function handleCodexStatusCommand(input) {
279
331
  providerId: previousEntry.providerId ?? "codex",
280
332
  accountId: fetched.status.identity.accountId ?? previousEntry.accountId ?? source.accountId,
281
333
  email: fetched.status.identity.email ?? previousEntry.email,
334
+ workspaceName: fetched.status.identity.workspaceName ?? previousEntry.workspaceName,
282
335
  lastUsed: fetched.status.updatedAt,
283
336
  snapshot: {
284
337
  ...(previousEntry.snapshot ?? {}),
@@ -23,6 +23,10 @@ export type CodexStatusSnapshot = {
23
23
  updatedAt: number;
24
24
  };
25
25
  export type CodexStatusError = {
26
+ kind: "invalid_account";
27
+ status: 400;
28
+ message: string;
29
+ } | {
26
30
  kind: "rate_limited";
27
31
  status: 429;
28
32
  message: string;
@@ -62,5 +66,6 @@ export declare function fetchCodexStatus(input: {
62
66
  accountId?: string;
63
67
  fetchImpl?: typeof globalThis.fetch;
64
68
  now?: () => number;
69
+ timeoutMs?: number;
65
70
  refreshTokens?: (oauth: OpenAIOAuthAuth) => Promise<OpenAIOAuthAuth | undefined>;
66
71
  }): Promise<CodexStatusFetcherResult>;
@@ -1,4 +1,50 @@
1
1
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/codex/usage";
2
+ const CODEX_USAGE_TIMEOUT_MS = 15_000;
3
+ function isInvalidAccountRefreshError(error) {
4
+ if (!error || typeof error !== "object")
5
+ return false;
6
+ const value = error;
7
+ return value.kind === "invalid_account"
8
+ && value.status === 400
9
+ && typeof value.message === "string"
10
+ && value.message.length > 0;
11
+ }
12
+ function readErrorStatus(error) {
13
+ if (!error || typeof error !== "object")
14
+ return undefined;
15
+ const value = error.status;
16
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
17
+ }
18
+ function readErrorMessage(error) {
19
+ if (error instanceof Error)
20
+ return error.message;
21
+ if (typeof error === "string")
22
+ return error;
23
+ if (error && typeof error === "object") {
24
+ const message = error.message;
25
+ if (typeof message === "string" && message.length > 0)
26
+ return message;
27
+ }
28
+ return String(error);
29
+ }
30
+ function mapRefreshTokenError(error) {
31
+ if (isInvalidAccountRefreshError(error))
32
+ return error;
33
+ const message = readErrorMessage(error);
34
+ const status = readErrorStatus(error);
35
+ const hasRefresh400Message = /refresh/i.test(message) && /\b400\b/.test(message);
36
+ if (status === 400 || hasRefresh400Message) {
37
+ return {
38
+ kind: "invalid_account",
39
+ status: 400,
40
+ message,
41
+ };
42
+ }
43
+ return {
44
+ kind: "network_error",
45
+ message,
46
+ };
47
+ }
2
48
  function asRecord(input) {
3
49
  if (!input || typeof input !== "object" || Array.isArray(input))
4
50
  return undefined;
@@ -113,24 +159,30 @@ async function requestUsage(input) {
113
159
  access: input.oauth.access,
114
160
  accountId: input.accountId,
115
161
  }),
162
+ signal: input.signal,
116
163
  });
117
164
  }
118
165
  export async function fetchCodexStatus(input) {
119
166
  const fetchImpl = input.fetchImpl ?? globalThis.fetch;
120
167
  const now = input.now ?? Date.now;
121
168
  const explicitAccountId = input.accountId;
169
+ const timeoutMs = input.timeoutMs ?? CODEX_USAGE_TIMEOUT_MS;
122
170
  let oauth = input.oauth;
123
171
  let authPatch;
124
172
  for (let attempt = 0; attempt < 2; attempt += 1) {
125
173
  let response;
174
+ const controller = new AbortController();
175
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
126
176
  try {
127
177
  response = await requestUsage({
128
178
  oauth,
129
179
  accountId: explicitAccountId ?? oauth.accountId,
130
180
  fetchImpl,
181
+ signal: controller.signal,
131
182
  });
132
183
  }
133
184
  catch (error) {
185
+ clearTimeout(timeout);
134
186
  if (isTimeoutError(error)) {
135
187
  return {
136
188
  ok: false,
@@ -148,18 +200,19 @@ export async function fetchCodexStatus(input) {
148
200
  },
149
201
  };
150
202
  }
203
+ finally {
204
+ clearTimeout(timeout);
205
+ }
151
206
  if (response.status === 401 && attempt === 0 && input.refreshTokens) {
152
207
  let refreshed;
153
208
  try {
154
209
  refreshed = await input.refreshTokens(oauth);
155
210
  }
156
211
  catch (error) {
212
+ const mappedError = mapRefreshTokenError(error);
157
213
  return {
158
214
  ok: false,
159
- error: {
160
- kind: "network_error",
161
- message: error instanceof Error ? error.message : String(error),
162
- },
215
+ error: mappedError,
163
216
  };
164
217
  }
165
218
  if (!refreshed || !refreshed.access) {
@@ -14,6 +14,7 @@ export type CodexAccountSnapshot = {
14
14
  export type CodexAccountEntry = {
15
15
  name?: string;
16
16
  providerId?: string;
17
+ workspaceName?: string;
17
18
  refresh?: string;
18
19
  access?: string;
19
20
  expires?: number;
@@ -60,6 +60,8 @@ function pickEntry(input) {
60
60
  next.name = pickString(source.name);
61
61
  if (pickString(source.providerId))
62
62
  next.providerId = pickString(source.providerId);
63
+ if (pickString(source.workspaceName))
64
+ next.workspaceName = pickString(source.workspaceName);
63
65
  if (pickString(source.refresh))
64
66
  next.refresh = pickString(source.refresh);
65
67
  if (pickString(source.access))
@@ -6,6 +6,7 @@ type WriteMeta = {
6
6
  export type MenuAccountInfo = {
7
7
  id?: string;
8
8
  name: string;
9
+ workspaceName?: string;
9
10
  index: number;
10
11
  isCurrent?: boolean;
11
12
  };
@@ -421,6 +421,8 @@ function pruneTouchWriteCache(input) {
421
421
  }
422
422
  }
423
423
  export function buildPluginHooks(input) {
424
+ const authProvider = input.auth.provider ?? COPILOT_PROVIDER_DESCRIPTOR.providerIDs[0] ?? "github-copilot";
425
+ const enableCopilotAuthLoader = isCopilotProviderID(authProvider);
424
426
  const compactionLoopSafetyBypass = createCompactionLoopSafetyBypass();
425
427
  const loadStore = input.loadStore ?? readStoreSafe;
426
428
  const loadStoreSync = input.loadStoreSync ?? readStoreSafeSync;
@@ -1121,9 +1123,9 @@ export function buildPluginHooks(input) {
1121
1123
  return {
1122
1124
  auth: {
1123
1125
  ...input.auth,
1124
- provider: input.auth.provider ?? COPILOT_PROVIDER_DESCRIPTOR.providerIDs[0] ?? "github-copilot",
1126
+ provider: authProvider,
1125
1127
  methods: input.auth.methods,
1126
- loader,
1128
+ loader: enableCopilotAuthLoader ? loader : undefined,
1127
1129
  },
1128
1130
  config: async (config) => {
1129
1131
  if (!config.command)
@@ -3,6 +3,7 @@ import { stdin as input, stdout as output } from "node:process";
3
3
  import { fetchCodexStatus } from "../codex-status-fetcher.js";
4
4
  import { runCodexOAuth } from "../codex-oauth.js";
5
5
  import { getActiveCodexAccount, readCodexStore, writeCodexStore, } from "../codex-store.js";
6
+ import { recoverInvalidCodexAccount } from "../codex-invalid-account.js";
6
7
  import { readAuth } from "../store.js";
7
8
  function pickName(input) {
8
9
  const accountId = input.accountId?.trim();
@@ -44,6 +45,13 @@ function toMenuQuota(entry) {
44
45
  completions: undefined,
45
46
  };
46
47
  }
48
+ function withoutRecoveryWarning(snapshot) {
49
+ if (!snapshot)
50
+ return undefined;
51
+ const next = { ...snapshot };
52
+ delete next.recoveryWarning;
53
+ return next;
54
+ }
47
55
  async function promptText(message) {
48
56
  const rl = createInterface({ input, output });
49
57
  try {
@@ -66,8 +74,11 @@ export function createCodexMenuAdapter(inputDeps) {
66
74
  const authorizeOpenAIOAuth = inputDeps.runCodexOAuth ?? runCodexOAuth;
67
75
  const refreshSnapshots = async (store) => {
68
76
  const names = Object.keys(store.accounts);
77
+ const pendingRecoveryWarnings = new Map();
69
78
  for (const name of names) {
70
79
  const entry = store.accounts[name];
80
+ if (!entry)
81
+ continue;
71
82
  const oauth = toOAuth(entry);
72
83
  if (!oauth.access && !oauth.refresh) {
73
84
  store.accounts[name] = {
@@ -88,6 +99,30 @@ export function createCodexMenuAdapter(inputDeps) {
88
99
  },
89
100
  }));
90
101
  if (!result.ok) {
102
+ if (result.error.kind === "invalid_account") {
103
+ const recovered = await recoverInvalidCodexAccount({
104
+ store,
105
+ invalidAccountName: name,
106
+ setAuth: async (next) => {
107
+ await inputDeps.client.auth.set(next);
108
+ },
109
+ });
110
+ store.accounts = recovered.store.accounts;
111
+ store.active = recovered.store.active;
112
+ if (recovered.replacement) {
113
+ const replacement = store.accounts[recovered.replacement];
114
+ const weekRemaining = replacement?.snapshot?.usageWeek?.remaining ?? 0;
115
+ const fiveHourRemaining = replacement?.snapshot?.usage5h?.remaining ?? 0;
116
+ if (weekRemaining > 0 && fiveHourRemaining <= 0) {
117
+ pendingRecoveryWarnings.set(recovered.replacement, {
118
+ code: "week_recovery_only",
119
+ removed: recovered.removed,
120
+ replacement: recovered.replacement,
121
+ });
122
+ }
123
+ }
124
+ continue;
125
+ }
91
126
  store.accounts[name] = {
92
127
  ...entry,
93
128
  snapshot: {
@@ -113,10 +148,11 @@ export function createCodexMenuAdapter(inputDeps) {
113
148
  ...(result.authPatch?.accountId !== undefined ? { accountId: result.authPatch.accountId } : {}),
114
149
  name: nextName,
115
150
  providerId: "openai",
151
+ workspaceName: result.status.identity.workspaceName ?? entry.workspaceName,
116
152
  accountId: result.status.identity.accountId ?? result.authPatch?.accountId ?? entry.accountId,
117
153
  email: result.status.identity.email ?? entry.email,
118
154
  snapshot: {
119
- ...(entry.snapshot ?? {}),
155
+ ...(withoutRecoveryWarning(entry.snapshot) ?? {}),
120
156
  plan: result.status.identity.plan ?? entry.snapshot?.plan,
121
157
  usage5h: {
122
158
  entitlement: result.status.windows.primary.entitlement,
@@ -141,6 +177,24 @@ export function createCodexMenuAdapter(inputDeps) {
141
177
  if (store.active === name || !store.active)
142
178
  store.active = nextName;
143
179
  }
180
+ for (const [name, entry] of Object.entries(store.accounts)) {
181
+ const warning = pendingRecoveryWarnings.get(name);
182
+ const snapshot = withoutRecoveryWarning(entry.snapshot);
183
+ store.accounts[name] = {
184
+ ...entry,
185
+ ...(snapshot ? { snapshot } : {}),
186
+ };
187
+ if (!warning)
188
+ continue;
189
+ const nextSnapshot = {
190
+ ...(store.accounts[name].snapshot ?? {}),
191
+ };
192
+ nextSnapshot.recoveryWarning = warning;
193
+ store.accounts[name] = {
194
+ ...store.accounts[name],
195
+ snapshot: nextSnapshot,
196
+ };
197
+ }
144
198
  };
145
199
  return {
146
200
  key: "codex",
@@ -201,6 +255,7 @@ export function createCodexMenuAdapter(inputDeps) {
201
255
  fallback: `openai-${now()}`,
202
256
  }),
203
257
  providerId: "openai",
258
+ workspaceName: oauth.workspaceName,
204
259
  refresh,
205
260
  access,
206
261
  expires: oauth.expires,
@@ -215,6 +270,7 @@ export function createCodexMenuAdapter(inputDeps) {
215
270
  return Object.entries(store.accounts).map(([name, entry], index) => ({
216
271
  id: entry.accountId ?? name,
217
272
  name: entry.email ?? entry.accountId ?? name,
273
+ workspaceName: entry.workspaceName,
218
274
  index,
219
275
  isCurrent: store.active === name,
220
276
  source: entry.source,
package/dist/ui/menu.d.ts CHANGED
@@ -3,6 +3,7 @@ import { confirm } from "./confirm.js";
3
3
  export type AccountStatus = "active" | "expired" | "unknown";
4
4
  export interface AccountInfo {
5
5
  name: string;
6
+ workspaceName?: string;
6
7
  index: number;
7
8
  addedAt?: number;
8
9
  lastUsed?: number;
package/dist/ui/menu.js CHANGED
@@ -318,6 +318,7 @@ export function buildMenuItems(input) {
318
318
  const numbered = `${account.index + 1}. ${account.name}`;
319
319
  const label = `${numbered}${currentBadge}${statusBadge ? " " + statusBadge : ""}${quotaBadge}`;
320
320
  const detail = [
321
+ account.workspaceName,
321
322
  account.lastUsed ? formatRelativeTime(account.lastUsed) : undefined,
322
323
  account.plan,
323
324
  account.models ? `${account.models.enabled}/${account.models.enabled + account.models.disabled} mods` : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-copilot-account-switcher",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "description": "GitHub Copilot account switcher plugin for OpenCode",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",